Ever had a list in SharePoint with a choice field that allowed multiple selections (i.e. checkboxes)? And with many things to choose from? See the picture below. In this particular case, you’d need to scroll down a page or two to see all of the choices. I’ve seen a lot of people try to solve this problem by making the choices wrap around inline instead of one per line, which is a fine solution if your choices are relatively small strings and there is a small enough number of them. But what if there are over 100 choices, and some of them are pretty big strings? That’s the problem I’m trying to solve with this Entity Editor Display Template.
So what is an entity editor? Think people picker. It provides an input where you can start typing the name of something and it will provide autocomplete with a known list of possible values (in this case the available choices of a choice field). You can then ‘resolve’ your choice, by arrowing up and down in the autocomplete, and clicking with the mouse or hitting enter when the right choice is selected. You can also hit enter when there are no autocomplete choices available, and it will either raise a validation if fill-in choices are not allowed in the field or add whatever you’ve typed as a resolved entity if they are allowed.
Like my last post, this solution will use pure JavaScript for DOM manipulation, but it does have a dependency. It uses a library call Awesomplete for the autocomplete functionality. It’s very light-weight (under 2k minimized) and provides a very nice autocomplete implementation. But as small as it is, it’s still twice the size of my entity editor CSR, so I don’t want to roll my own.
What are we Building?
I’ve already explained what we’re building; now I’m going to show it. Below is a picture of the same form I showed above, but the entity editor template has been applied to the 3 choice fields (of course, you only saw 2 of the choice fields in the previous image, because the second was so obnoxiously big that it pushed the 3rd way off the page).
The first choice is a single select, so it doesn’t technically require the entity editor template, but in for a penny, in for a pound as the saying goes. The entity editor template can be applied to any choice field (drop down, radio buttons, or checkboxes). That doesn’t mean that it will look different, it’s going to look the same for each, but it will behave a bit differently for single select vs. multiple select. The other two are both multiple select choice fields with a lot of choices. I think it’s fairly obvious that this form is a bit nicer than the previous one. If you don’t, get out of here already, this post isn’t for you!
If it’s a single select choice field (either drop down or radio buttons), the entity editor will let the user resolve one entity and then take away the cursor. There’s no way to enter more. If it’s multi-select, they can keep resolving entities to their heart’s content until they run out of choices. Or beyond that, if fill-in choices are allowed on the choice field.
Resolving the entity is just taking whatever is in the input and seeing if it uniquely resolves to a choice, or selecting an autocomplete choice, or taking whatever is in the input and adding it as a new resolved entity if fill-in choices are allowed. Resolution can only be attempted if the input control has text in it. And it can be initiated by either selecting from the drop down, hitting enter, or submitting the form. When the input is successfully resolved, a new entity (grey rounded box) is added and the input is cleared. If resolution is attempted, but the resolution is unsuccessful (i.e. user typed something, not in the choices, and fill-in choices not allowed), then a validation error is raised and displayed.
You can remove a resolved entity by clicking the little blue x inside of it. If it’s a multiple selection choice field, and the input is empty, you can also delete the last entity just by hitting the backspace key while the input has focus.
If you’ve been doing SharePoint for any significant period of time, the similarity to people picker should be pretty obvious. Change the styles just a little bit and it could look exactly like a people picker if that’s what you want.
The HTML and CSS
Now let’s break down the parts of the entity editor UI first, because it will make the code easier to understand:
The entity editor is just a div with some stuff in it. It has a grey border that changes color on hover, making it mimic other input fields in SharePoint. From now on, I’m just going to refer to this as entityEditor, because that’s also the name I use for it in all of my source code.
Inside that, there’s just a span for each resolved entity containing the actual entity text, and if in the new form or edit form, an anchor tag to delete the entity.
Finally, on new or edit forms, there is an input with no border. It having no border is important, as it makes it invisible except for a floating cursor, making it appear that the entityEditor is the input, which is what we want. It gets hidden on single select choice fields once an entity has been resolved. The input is consistently referenced in the code as entityEditorInput.
And below is the actual HTML produced. Nothing special here, except that I add data attributes to various nodes. This allows event handlers on those nodes to quickly tell which field they are working with. Also, note that the Awesomplete library wraps my input in a div and inserts a couple of other siblings for its own purposes. That’s only significant because some of the event handlers we’ll talk about later fire on the input, and they find the entity editor using parentNode.parentNode, which if you look at my methods for generating the DOM, that doesn’t look right (looks like it should be just parentNode), but since Awesomplete added a div it is right. Also, the last node in the outer div is a span that will display any validation errors.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 | <!--The outer div--> <div> <!--This is entityEditor in JavaScript--> <div id="TagsCoveredEntityEditor" data-fieldname="TagsCovered" class="csrdemos-entityeditor"> <!--Span for each resolved entity--> <span title="REST API" data-fieldname="TagsCovered" data-value="REST API" class="csrdemos-entity"> REST API <!--The delete anchor for this entity--> <a title="Remove Entity" href="#" data-fieldname="TagsCovered" data-value="REST API" class="csrdemos-remove"> x </a> </span> <!--Span for each resolved entity--> <span title="JavaScript" data-fieldname="TagsCovered" data-value="JavaScript" class="csrdemos-entity"> JavaScript <!--The delete anchor for this entity--> <a title="Remove Entity" href="#" data-fieldname="TagsCovered" data-value="JavaScript" class="csrdemos-remove"> x </a> </span> <!--This div is added by awesomplete when I apply it to the input--> <div class="awesomplete"> <!--This is entityEditorInput in JavaScript--> <input id="TagsCoveredEntityEditorInput" name="TagsCoveredEntityEditorInput" data-fieldname="TagsCovered" class="csrdemos-entityeditorinput" autocomplete="off" aria-expanded="false" aria-owns="awesomplete_list_2" role="combobox" type="text"> <!--This list is dynamically populated with the awesomplete values--> <ul role="listbox" id="awesomplete_list_2" hidden=""> </ul> <!--Added by awesomplete--> <span class="visually-hidden" role="status" aria-live="assertive" aria-atomic="true"> Type 1 or more characters for results. </span> </div> </div> <!--A span that will display any validation errors--> <span id="TagsCoveredEntityEditorError" class="ms-formvalidation ms-csrformvalidation"></span> </div> |
Finally, below is the CSS, which like my previous examples, is embedded into the JavaScript and shoved inline into the DOM on pre-render in order to avoid FOUC (Flash of Unstyled Content). There’s nothing special here, almost all of it would have worked the same in browsers 10 years ago, except for border-radius. That wasn’t supported by older browsers, but the only result would be that the entities would appear more rectangular. Few people are using a browser that old these days, and if they are, screw them (I mean that in the nicest possible way of course).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 | /* * The outer div, looks like an input */ .csrdemos-entityeditor { border: 1px solid #ababab; width: 390px; padding: 3px; background: white; } /* * Change the outer div border on hover */ .csrdemos-entityeditor:hover { border: 1px solid #92c0e0; } /* * The entity editor input, no border, so both this and the entity spans * will appear inside the entity editor border, making it look like the whole * thing is a 'custom' editor */ input.csrdemos-entityeditorinput { width: 140px; border: none; outline: none; } /* * Styling for the entity spans, a grey box with border */ .csrdemos-entity { display: inline-block; padding: 2px 3px 1px 5px; margin-right: 2px; margin-bottom: 1px; position: relative; background-color: #eee; border: 1px solid #333; -moz-border-radius: 7px; -webkit-border-radius: 7px; border-radius: 7px; color: #333; font: normal 11px Verdana, Sans-serif; } /* * Styling for the entity delete anchor (small blue x) */ .csrdemos-remove { margin-left: 5px; color: #0072c6; } |
The technique used here is pretty simple, and this kind of control could be used in a lot of web-based applications, not just SharePoint. In fact, I stole it from a PHP/MySQL blog post I read about 10 years ago, which I would have included in my references but I can’t find it anymore.
The Implementation
Hey, I’ve got an idea, let’s talk some JavaScript. Now what follows is going to be a whole lot of code, and very brief explanations, mostly because a lot of the code is just going to be rendering the DOM I described above, or handling events and manipulating the DOM elements in ways I’ve described above.
The Shell
This is the entire entity editor implementation, minus the parts with the TBD comments of course. This looks a lot like the shell for just about every other CSR template I’ve done in this series, so if you can’t figure it out, you might need to go back and read some of the earlier posts in this series. The rest of this post is going to describe those TBDs pretty much in the order in which they occur.
The render methods and any helpers they need are going to be entirely contained in the Object literal instance called impl. Every bit of source code I show for the remainder of this article will be fleshing out member methods and properties of that instance.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 | (function() { // this is the only thing you need to modify to add new fields, it is an array of // field internal names var fields = [ "SingleTag", "TagsCovered", "TagsNotCovered" ]; /********************************************************************************* * If you just want to use this CSR for one or more fields, just modify the array * above and leavs alone the code below. Disclaimer: technically, I do not recommend * using scripts in production that you don't understand, so if you're up for it read * the code below, but there is no need to modify it unless you're good at JavaScript * and want to adjust the way it works. *********************************************************************************/ // test is form with client side rendering if (typeof(SPClientTemplates) === 'undefined') return; /* * Implementation class for the overrides. */ var impl = { /* * Implementation for the display form and view overrides. */ renderDisplay: function(ctx) { // TBD }, /* * Implementation for the new and edit form override. */ renderEdit: function(ctx) { // TBD }, /* * Implementation for the pre render override. It's purpose is to inject CSS. */ preRender: function(ctx) { // TBD }, /* * Implementation for the post render override. It's purpose is to attach event * receivers. */ postRender: function(ctx) { // TBD }, }; /* * Create an empty overrides object. */ var entityEditorOverrides = { Templates: { 'Fields': {} } }; /* * Add an overrides object for each field we want to customize. */ fields.forEach(function(v) { entityEditorOverrides.Templates.Fields[v] = { 'View': impl.renderDisplay, 'DisplayForm': impl.renderDisplay, 'NewForm': impl.renderEdit, 'EditForm': impl.renderEdit }; }); /* * Add pre and post render overrides. */ entityEditorOverrides.OnPreRender = impl.preRender; entityEditorOverrides.OnPostRender = impl.postRender; /* * Register my template overrides with SPClientTemplates. */ if (typeof _spPageContextInfo !== 'undefined' && _spPageContextInfo !== null) { // MDS is enabled var url = (_spPageContextInfo.siteServerRelativeUrl === '/' ? "" : _spPageContextInfo.siteServerRelativeUrl) + '/Style%20Library/EntityEditorCSR.js'; // register a callback to register the templates on partial page loads RegisterModuleInit(url.toLowerCase(), function() { SPClientTemplates.TemplateManager.RegisterTemplateOverrides(entityEditorOverrides); }); } // also just register for full page loads (F5/refresh) SPClientTemplates.TemplateManager.RegisterTemplateOverrides(entityEditorOverrides); })(); |
On Pre-render
The only role of the pre-render method in this example is to load CSS. My CSS is hard-coded in the JavaScript and shoved inline into the DOM, so it’s available before any of my DOM is rendered avoiding FOUC (as previously mentioned).
The pre-render method also inserts a style element into the head to load Awesomplete’s CSS from an external file. I could have just sucked this CSS into my JavaScript and loaded it inline too, but FOUC is less of an issue here because nothing that Awesomplete does is going to be visible until the user starts typing anyway, and if the CSS hasn’t loaded yet the page is probably pretty ugly and unresponsive anyway (i.e. it’s still SharePoint after all).
The property impl.cssLoaded is a boolean member property used as a flag to ensure that I don’t load the CSS more than once.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | // boolean flag that indicates if we've already loadded the css cssLoaded: false, /* * Implementation for the pre render override. It's purpose is to inject CSS. */ preRender: function(ctx) { // only do this once if (!impl.cssLoaded) { impl.cssLoaded = true; var head = document.querySelector("head"); // create a new style element with our CSS var style = document.createElement("style"); style.setAttribute("type", "text/css"); style.innerHTML = entityEditorCss; // append the style element to the head head.appendChild(style); // append the awesomecomplete CSS to the head var awesomeCompleteCss = document.createElement("link"); awesomeCompleteCss.setAttribute("rel", "stylesheet"); awesomeCompleteCss.setAttribute("href", _spPageContextInfo.siteAbsoluteUrl + "/Style Library/awesomplete.css"); head.appendChild(awesomeCompleteCss); } }, |
Rendering for Display Forms and Views
As usual, I have a single method for both display forms and views, because usually they need to do the same thing, or at least very nearly the same thing. Also as usual, I try not to render a bunch of HTML by concatenating a bunch of strings and variables. Instead, I create DOM nodes (either jQuery, or as in this case pure JavaScript) and manipulate them through the API. Literally, all this method is doing is creating a div. Then, if the current item has a non-empty value for our field, it splits it up if necessary, creates a span for each value, and shoves that span into the div. Finally, it returns the div source (outerHTML).
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | /* * Implementation for the display form and view overrides. */ renderDisplay: function(ctx) { var values = ctx.CurrentItem[ctx.CurrentFieldSchema.Name]; // create the outer div var div = document.createElement("div"); if (values.length > 0) { // split the values into an array (on semi-colon) if (typeof(values) === "string") { values = values.split(";"); } // for each value values.forEach(function(v) { // create an entity span and append it to the div var span = document.createElement("span"); span.className = "csrdemos-entity"; span.innerHTML = v; div.appendChild(span); }); } return div.outerHTML; }, |
Rendering for New and Edit Views
I also generally use a single method for rendering fields on new and edit forms, since they generally render the same thing with the only difference being that the edit form may have to initialize the input value from the render context (actually, the new form may need to do this also, if you’ve configured a default value for this field). Here is that method:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | // this will map a field name to an object containing it's schema fieldMap: {}, /* * Implementation for the new and edit form override. */ renderEdit: function(ctx) { var current = SPClientTemplates.Utility.GetFormContextForCurrentField(ctx); // add a field map entry for the field, and set it's source (the autocomplete list) // and schema (the field schema from the SharePoint form context) var f = impl.fieldMap[current.fieldName] = {}; f.source = current.fieldSchema.MultiChoices; f.schema = current.fieldSchema; // construct the outer html for our control var outerDiv = impl.appendOuterContainer(current); var entityEditor = outerDiv.querySelector(".csrdemos-entityeditor"); // initialize the editor with current values impl.appendInitialEntities(ctx, current, entityEditor); // add the text input to the entitiy edity div impl.appendInput(ctx, current, entityEditor); // register a callback to return the current value current.registerGetValueCallback(current.fieldName, function() { return impl.getFieldValue(current); }); // register validators for this control impl.registerValidators(current); return outerDiv.outerHTML; }, |
Doesn’t look like much, but mostly it farms out the work to a bunch of helper methods in impl.
But first, it stores some field specific stuff in a map of field names to objects, in a property called impl.fieldMap. For instance, for the 3 fields my CSR is intended to intercept, I’ll end up with a mapping that looks like (condensed view):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 | { "SingleTag": { "schema": { "Id": "8445d8bb-2243-4a38-a31f-8ad08cf4d0e3", "Title": "Track", "InternalName": "SingleTag", "StaticName": "SingleTag", "Hidden": false /* a bunch more properties */ }, "source": [ "Business", "End User", "IT Pro" ] }, "TagsCovered": { "schema": { "Id": "66e6459b-cf4e-4993-ac61-c438441f826f", "Title": "Topics covered well", "InternalName": "TagsCovered", "StaticName": "TagsCovered", "Hidden": false /* a bunch more properties */ }, "source": [ "CSOM", "JSOM", "Client Side Rendering", "JavaScript", "jQuery", "Angular" /* a bunch more tags */ ] }, "TagsNotCovered": { "schema": { "Id": "41f3fd3e-e5c8-4052-84c5-d6837a1430b6", "Title": "Expected topics not covered", "InternalName": "TagsNotCovered", "StaticName": "TagsNotCovered", "Hidden": false /* a bunch more properties */ }, "source": [ "CSOM", "JSOM", "Client Side Rendering", "JavaScript", "jQuery", "Angular" /* a bunch more tags */ ] } } |
So at the moment, the field map stores two properties, schema and source, both of which it gets from the rendering context. schema is the field schema for the field and contains a lot of useful stuff (like for choice fields, the available choices and whether fill-in choices are allowed). source is an array of available choices for this field, which will be the source for the Awesomplete type ahead. I store these things so they can later be used by callback functions like JavaScript event handlers, which can generally determine the current field name through data attributes, but aren’t passed the context.
Next I need to build the DOM elements that will render my control. That’s done by three helper methods shown below, appendOuterContainer (renders the entity editor div), appendInitialEntities (adds an entity span for each current value), and appendInput (adds the entity editor input). appendInitialEntities further farms out building the actual DOM nodes to appendEntity, which is also called later as various event callbacks resolve entities.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 | /* * Construct the outer div for the entity editor. This will just be referred to * as the entity editor, as it encapsulates all of that markup/functionality. */ appendOuterContainer: function(current) { var outerDiv = document.createElement("div"); var entityEditor = document.createElement("div"); entityEditor.setAttribute("id", current.fieldName + 'EntityEditor'); entityEditor.setAttribute("data-fieldname", current.fieldName); entityEditor.className = 'csrdemos-entityeditor'; outerDiv.appendChild(entityEditor); return outerDiv; }, /* * Add a span for each entity in ctx.CurrentFieldValue. */ appendInitialEntities: function(ctx, current, entityEditor) { // if the field has a current value, initilize the control with it if (ctx.CurrentFieldValue.length > 0) { // parse the values into an array var values = ctx.CurrentFieldValue.replace(/^;#/, ''). replace(/;#$/, '').split(';#'); // for each value values.forEach(function(v) { // insert a new entity impl.appendEntity(current.fieldName, v, entityEditor); }); } }, /* * Append a single entity as a span, just in front of the input, and remove it * from the source list for the autocomplete. An entity is just a stylized span * with an anchor (x) to remove it. Also, if this is after post render, attach * handleEntityRemove to the little x anchor. Otherwise, the post render override * will do that for all of the entities. */ appendEntity: function(fieldName, value, entityEditor) { // create an anchor tag to remove this entity var anchor = document.createElement("a"); anchor.setAttribute("title", "Remove Entity"); anchor.setAttribute("href", "#"); anchor.setAttribute("data-fieldname", fieldName); anchor.setAttribute("data-value", value); // add text of an X and a class with which to style it anchor.className = "csrdemos-remove"; anchor.innerHTML = "x"; // create the span from the value and the anchor var span = document.createElement("span"); span.setAttribute("title", value); span.setAttribute("data-fieldname", fieldName); span.setAttribute("data-value", value); span.className = "csrdemos-entity"; span.innerHTML = value; span.appendChild(anchor); // push the span onto entity editor children, right before the input // if present, and setup event receivers, otherwise just append it as this // is the initial rendering and post render will setup the event receivers var f = impl.fieldMap[fieldName]; var entityEditorInput = entityEditor.querySelector("input"); if (entityEditorInput) { // if the input exists, then the entity is being appended after post render // (like an autocomplete select), so we need to insert the entity before the // input and do some extra stuff var schema = f.schema; // insert the entity and clear the input entityEditor.insertBefore(span, entityEditorInput.parentNode); // clear the input, and hide it for single select choices entityEditorInput.value = ""; if (schema.FieldType !== "MultiChoice") { entityEditorInput.style.display = "none"; } // add a remove entitity handle to the anchor anchor.addEventListener("click", impl.handleEntityRemove); } else { entityEditor.appendChild(span); } // remove the value from the list of potential values, so // autocomplete won't allow duplicates var source = f.source; var i = source.indexOf(value); if (i > -1) { source.splice(i, 1); } // re-initialize autocomplete with the updated source array if (f.awesomplete) { f.awesomplete.list = source; } }, /* * Add the input control to the entity editor. */ appendInput: function(ctx, current, entityEditor) { // add an input for the user to type into, this is the autocomplete input var entityEditorInput = document.createElement("input"); entityEditorInput.setAttribute("id", current.fieldName + 'EntityEditorInput'); entityEditorInput.setAttribute("name", current.fieldName + 'EntityEditorInput'); entityEditorInput.setAttribute("type", "text"); entityEditorInput.setAttribute("data-fieldname", current.fieldName); entityEditorInput.className = 'csrdemos-entityeditorinput'; // if single entry choice, and we already have a value, hide the input var f = impl.fieldMap[current.fieldName]; var schema = f.schema; if (schema.FieldType !== "MultiChoice" && ctx.CurrentFieldValue.length > 0) { entityEditorInput.style.display = "none"; } // add the input to the entity editor entityEditor.appendChild(entityEditorInput); // finally, append a span as a sibling of the outer div where we'll output // any validation errors var span = document.createElement("span"); span.setAttribute("id", current.fieldName + 'EntityEditorError'); span.className = 'ms-formvalidation ms-csrformvalidation'; entityEditor.parentNode.appendChild(span); }, |
Next, the rendering method registers a get value callback, which ultimately calls impl.getFieldValue shown below. This method does a lot of the heavy lifting of trying to resolve entities from unresolved text in the input buffer, when the user tries to submit the form. If it finds an exact match ignoring case, it resolves to that and clears the input. If fill in choices are allowed and there is still text in the input, it resolves whatever is there and clears the input. If there is still text in the input once this function is done, then there is a validation error, so it really does the heavy lifting for validation too.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 | /* * Return the current value from the data-value attribute of my div. */ getFieldValue: function(current) { var result = []; // get the field name from the form context var fieldName = current.fieldName; // get the field resources from the map, and from them the autocomplete source and schema var f = impl.fieldMap[fieldName]; var source = f.source; var schema = f.schema; // get the entity editor, the input, and the validation error message span var entityEditor = document.getElementById(current.fieldName + 'EntityEditor'); var entityEditorInput = entityEditor.querySelector("input"); var entityError = document.getElementById(current.fieldName + 'EntityEditorError'); // if there is an unresolved value in the input, see if it matches a valid choice (case insensative) if (entityEditorInput.value.length > 0) { var index = impl.inArrayIgnoreCase(entityEditorInput.value, source); if (index > -1) { // if we found a valid choice, add it as an entity, clear the input, and clear the validation // error if any, use the source array instead of the input.value so we add the choice with // the correct case impl.appendEntity( current.fieldName, source[index] entityEditorInput); entityError.setAttribute('role', ''); entityError.innerHTML = ""; } } // if there is still an unresolved value in input, and fill in choices are allowed, add whatever // is in input as an entity if (schema.FillInChoice === true) { if (entityEditorInput.value.length > 0) { impl.appendEntity(current.fieldName, entityEditorInput.value, entityEditorInput); entityError.setAttribute('role', ''); entityError.innerHTML = ""; } } // now scoop up all of the entities (all the span.csrdemos-entity elements) var entities = entityEditor.querySelectorAll('.csrdemos-entity'); if (entities !== null) { for (var i = 0; i < entities.length; i++) { result.push(entities[i].getAttribute("data-value")); } } // if this is a multi-choice field, join the array and format as a multi-choice value if (schema.FieldType === "MultiChoice") { if (result.length === 0) { result = ''; } else { result = ';#' + result.join(';#') + ';#'; } } // otherwise it's a single choice, we don't have to worry about finding multiple entities because the // input gets hidden for single choice any time we have an entity else { result = result.length === 1 ? result[0] : ''; } // return the result as a string, if anything is still in the input at this point (i.e. there is an // unresolved entity), it is a validation error which will be handled by the custom validator return result; }, |
Finally the render method calls impl.registerValidators. This renders one custom validator, which checks if the field does not allow fill-in choices and if there is still text in the input, and if both conditions are true, it raises a validation error. Validation is always called after the get value callback, since the result of the get value callback is what’s passed into the validators, which is why I can count on the input being empty at this point if all input has been successfully resolved.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 | /* * Setup validation for the input form. */ registerValidators: function(current) { // create a validator set var fieldValidators = new SPClientForms.ClientValidation.ValidatorSet(); // create a custom validator with an object literal instead of new and a constructor, // this creates a closure where I can still access the form context (current), otherwise // the validate method only gets value passed to it and I need more than that to // validate fieldValidators.RegisterValidator({ Validate: function(value) { var isError = false; var errorMessage = ''; // if fill in choice is enabled for this field if (current.fieldSchema.FillInChoice === false) { // get the entity editor and inpu var entityEditor = document.getElementById(current.fieldSchema.Name + 'EntityEditor'); var entityInput = entityEditor.querySelector("input"); // the getValue callback is called before validate, and will resolve anything it can and // clear the input, so if there is anthing left in the input, it's invalid if (entityInput.value.length > 0) { isError = true; errorMessage = "'" + entityInput.value + "' is not resolved; Fill in choices are not support, so all entities must be resolved."; } } // return the validation result return new SPClientForms.ClientValidation.ValidationResult(isError, errorMessage); } }); // if required, add a required field validator if (current.fieldSchema.Required) { fieldValidators.RegisterValidator(new SPClientForms.ClientValidation.RequiredValidator()); } // register a callback method for the validators, to render an error message current.registerValidationErrorCallback(current.fieldName, function(error) { var entityError = document.getElementById(current.fieldName + 'EntityEditorError'); entityError.setAttribute('role', error.validationError ? 'Alert' : ''); entityError.innerHTML = error.errorMessage; }); // register the validator set, this replaces any previous validators, so if I don't include // a required validator, the field isn't required current.registerClientValidator(current.fieldName, fieldValidators); }, |
And that’s it for rendering. The render method returns the HTML source from the outer div (i.e. outerHTML).
On Post Render
And that brings us to the post render method, whose primary responsibility is to attached event handlers to the now fully rendered fields. This includes applying Awesomplete to the entityEditorInput, because Awesomplete is just a bit of HTML, CSS, and behaviors (i.e. event handlers), and it too requires the field to be in the live DOM before it can do it’s magic. Here is the post render method:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 | /* * Implementation for the post render override. It's purpose is to attach event * receivers. */ postRender: function(ctx) { // get the field name from the schema, there's only one field in the schema array // at this point, so that's the one we want var fieldName = ctx.ListSchema.Field[0].Name; // if this field is in our array, set up event handlers on it if (fields.indexOf(fieldName) > -1) { // find the entity editor and input from the field name var entityEditor = document.getElementById(fieldName + 'EntityEditor'); var entityEditorInput = entityEditor.querySelector("input"); // get the field resources from the map and the autocomplete source var f = impl.fieldMap[fieldName]; var source = f.source; // initialize the autocomplete on the input f.awesomplete = new Awesomplete(entityEditorInput, { minChars: 1, autoFirst: true, list: source, filter: Awesomplete.FILTER_CONTAINS, maxItems: 30, sort: function (a, b) { // custom compare method sorts alphabetically (case insensitive) if (a.toLowerCase() < b.toLowerCase()) return -1; if (a.toLowerCase() > b.toLowerCase()) return 1; return 0; } }); // get called back after the autocomplete has selected an item entityEditorInput.addEventListener("awesomplete-selectcomplete", impl.handleSelectComplete); // also add a key down even callback entityEditorInput.addEventListener("keydown", impl.handleInputKeyDown); // if the outer div receives a click event, put focus on the input entityEditor.addEventListener("click", impl.handleEditorClick); // add remove handlers to the little x anchor of each entity var entities = entityEditor.querySelectorAll(".csrdemos-remove"); for(var i=0; i < entities.length; i++) { entities[i].addEventListener("click", impl.handleEntityRemove); } } } |
First, we check to see if the field that was just rendered is one of our entity editor fields, and if not we don’t do anything. Otherwise, we start attaching event handlers, starting with creating an instance of Awesomplete. We initialize Awesomplete with the properties stored in the fieldMap property we created earlier. We also add the Awesomplete instance for each field back to the fieldMap, so event handlers can access it to remove entities from it’s source as they’re resolved, and add them back when resolved entities are removed.
We then add 3+ event handlers to 2+ DOM elements, to implement behaviors for our entityEditor. I’ll cover them in ascending order by size/complexity.
The handleEditorClick callback handles the click event on the entityEditor. On a click event anywhere inside the entityEditor, it will transfer focus to the entityEditorInput. Otherwise, in order to get focus on the input, the user would have to click directly on the input, which would be pretty annoying, since it has no border and is virtually invisible until focused (at which point at least it has a blinking cursor). Ok, they could also tab to it, but only a small subset of users use tab to navigate forms, or even know they can do that.
1 2 3 4 5 6 7 8 9 10 11 | /* * If the user clicks anywhere inside the entity editor, focus the input. Otherwise, * since the input itself has no border, it could be confusing to the user. */ handleEditorClick: function(e) { var fieldName = e.target.getAttribute("data-fieldname"); if (fieldName) { var entityEditor = document.getElementById(fieldName + "EntityEditor"); entityEditor.querySelector("input").focus(); } }, |
The next event receiver handles a custom event that is registered by Awesomplete called “awesomplete-selectcomplete”. This event fires after an item has been selected from the Awesomplete drop down, the value selected has overwritten the value of the input, and the drop down has closed. This method resolves the entity in the input, removing it from the Awesomplete list, adding an entity span to the entityEditor, and finally clearing the input. It also hides the entityEditorInput if the current choice field is single select, preventing the user from adding more entities.
1 2 3 4 5 6 7 8 9 10 11 | /* * Handle the custom event from Awesomplete that gets called after selection is complete. * This replaces whatever is in the input with the selected value. */ handleSelectComplete: function(e) { var fieldName = e.target.getAttribute("data-fieldname"); var entityEditor = document.getElementById(fieldName + "EntityEditor"); var entityEditorInput = document.getElementById(fieldName + "EntityEditorInput"); impl.appendEntity(fieldName, e.text.value, entityEditor); entityEditorInput.value = ""; }, |
The handleEntityRemove event receiver is applied to the click event of the small blue (x) anchor for each entity. This is why I said 3+ event handlers on 2+ DOM elements earlier, because this one could be added zero or more times to zero or more anchors, depending on how many resolved entities we have. It removes itself as an event handler from the entity span, in order to prevent resource leaks. It then removes the entity span, adds the entity value back to the source array for the Awesomplete, and re-initializes the Awesomplete instance with the updated source array. It also shows the entityEditorInput, if it was previously hidden (single select choice), so the user can choose another entity for this field.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 | /* * Handle the click event on the little x anchor for an entity, which removes the entity. */ handleEntityRemove: function(e) { // don't bubble up or do the default action, I want to handle this event completely e.preventDefault(); e.stopPropagation(); // get the anchor tag that was clicked and the field name from it var anchor = e.target; var fieldName = anchor.getAttribute("data-fieldname"); // get our field resources from the map, and the source list for autocomplete var f = impl.fieldMap[fieldName]; var source = f.source; // get the entity editor and input from the anchor tag var entityEditor = anchor.parentNode.parentNode; var entityEditorInput = entityEditor.querySelector("input.csrdemos-entityeditorinput"); // remove the event listener for the x anchor anchor.removeEventListener("click", impl.handleEntityRemove); // remove the span for this value, and it back to the source array to be used for autocomplete source.push(anchor.getAttribute("data-value")); entityEditor.removeChild(anchor.parentNode); // re-initialize autocomplete f.awesomplete.list = source; // show the input, in case it's a single entry input that was hidden due to being 'full' entityEditorInput.style.display = "inline-block"; entityEditorInput.focus(); }, |
The last event handler handles the key down event for the entityEditorInput. It does the following:
- If the key pressed was the backspace key, and the entityEditorInput is empty, it removes the last resolved entity if there are any. This is just a convenience method, providing a quick way to clear out all entities from a multiple selection entity editor. If there are 10 resolved entities, the user can either click all 10 (x) anchors, or just focus the the entityEditorInput and hit the backspace key 10 times.
- If the key is the return key, and if the Awesomplete dialog is open, “awesomplete-selectcomplete” event fires first, resolves the entity, and clears the entityEditorInput, so we do nothing if the input is empty. But if there is still text in the entityEditorInput, we need to try to resolve it. To do that we check the field schema in the fieldMap to see if the field allows fill-in choices. If yes, we resolve whatever they typed by adding it as a new resolved entity span and clear the input. If no, we raise a validation error.
- Finally, on any other key, we clear any validation error, since the value has changed and we won’t know if it’s an error until the user attempts to resolve it again.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 | /* * Handle the key down event for the input. If the key is a return, but the input value * is empty, it does nothing. If it has a value in the input, it tries * to resolve the input to an entity (equals ignoring case one of the choices). If it can't * resolve it, but the field allows fill-in choices, then it adds whatever is in the input to * the fill-in choices. If fill-in choices are not enabled, it shows a validation error message. * And finally, if any other key is pressed, it clears any existing validation * message. Also, if the input is empty, and they hit the backspace key, remove the last * resolved entity if there are any. */ handleInputKeyDown: function(e) { // get the entity editor, input and field name var entityEditorInput = e.target; var entityEditor = entityEditorInput.parentNode.parentNode; var fieldName = entityEditor.getAttribute("data-fieldname"); // get the field resources from the map, and from them the autocomplete source and field schema var f = impl.fieldMap[fieldName]; var schema = f.schema; var source = f.source; // backspace key if (e.which === 8) { // if the input is empty if (entityEditorInput.value.length === 0) { var entities = entityEditor.querySelectorAll(".csrdemos-entity"); // if we have entities if (entities.length > 0) { // find the last entity var last = entities[entities.length - 1]; // remove the event listener for the x anchor last.removeEventListener("click", impl.handleEntityRemove); // remove it var value = last.getAttribute("data-value"); entityEditor.removeChild(last); if (source.indexOf(value) < 0) { // push the value back into the source list source.push(value); } } } } // if the key is return var entityError = document.getElementById(fieldName + 'EntityEditorError'); if (e.which === 13) { e.preventDefault(); var val = entityEditorInput.value; // if there is nothing in the input, do nothing if (val.length > 0) { var index = impl.inArrayIgnoreCase(val, source); // if the input text equals, ignoring case, a source item, add the source item if (index > -1) { impl.appendEntity(fieldName, source[index], entityEditor); f.awesomplete.close(); } else if (!impl.isDuplicateValue(entityEditor, val)) { // ignore duplicate entries // if fill-in choices are allowed, add whatever they typed if (schema.FillInChoice === true) { impl.appendEntity(fieldName, val, entityEditor); f.awesomplete.close(); } else { // else raise a validation error var errorMessage = "'" + val + "' is not resolved; Fill in choices are not support, so all entities must be resolved."; entityError.setAttribute('role', 'alert'); entityError.innerHTML = errorMessage; } } } } else { // on any other key, just clear the error span, since we won't know if // it's an error until they try to resolve it again (i.e. hit return or submit) entityError.setAttribute('role', ''); entityError.innerHTML = ""; } }, |
So that’s it, piece of cake right? I’ve now explained all of the code except for a couple of small, simple, helper functions that are well documented. The complete source code is available at the bottom of the post.
Sum Up
This is a pretty long blog post, but how could it not be, it’s mostly code. As for the narrative, I tried to explain what I’d want to cover if this were a SharePoint Saturday presentation. Of course, if this were really a presentation, I’d forget half of it, say “oh, I forgot this other thing” a lot, and jump back and forth in the code to explain those missed points. Come to think of it, I did that a lot while writing this too, you just weren’t with me for that part. 😉
As with the Star Ratings adapter, to get it deployed, just copy the files to your style library and use the utility page I described in Setting the JSLink Property of a Field Using JavaScript to set the JSLink property on each of the fields to which you want to apply the entity editor rendering template. That script only works on site columns. If you want to do this with say a list column, you’ll need to rework it a bit. Once applied, your fields should work like what I described above in the ‘What are we Building?’ section. Remember that we do have a dependency this time, on Awesomplete, and the utility page expects one JavaScript file per line, which will get loaded in order, so here is what that utility page looks like for one of my fields:
As a simple alternative, you could just copy the files up and set the JSLink property on a web part in the forms and views you want to apply this to. And of course, this will work regardless of whether you’re apply thing to site columns or list columns, but setting the JSLink on a site column is a better solution IMHO.
This will probably be my last post in this Client Side Rendering series. At this point, I’ve covered everything I wanted to cover. There’s always Search Display Templates, but if I decide to do that I’ll spin up a separate series dedicated to that, as this one has gotten quite long enough.
Reference
EntityEditorCSR.zip – the source code