Umbraco, knockout js and forms part 2

This post extends part 1 and talks about how to get all the already submitted reviews, display them, and enable rating and commenting functionality on them, all using knockout js. This includes using knockout templates, adding additional functions/observables to pre-mapped view models, extending knockout binding handlers, and implementing a ‘show more’ function to dynamically load another page of results.

The problem

So, a load of users have submitted reviews, but they now need to be shown in blocks of 10 with a ‘show more’ button at the end. When this is pressed, another batch of 10 is loaded and appended to the existing list. Also, each review needs to look like this…

product-review

So it shows all the usual stuff, along with options to view/submit comments, rate the review as useful or useless and share the review using social media.

The solution

For this I created another partial that would grab all the reviews, loop through them and also create another modal form so users would be able to comment on other people’s reviews. I created a new knockout view model since, although it would be related to the product review form view model, its functionality is different enough to make the separation logical. In general terms the partial outputs 2 knockout templates, one for the product reviews and one for the product review comments. It also outputs the comments form as another c# partial. Inside that partial, all the knockout binding occurs.

The comment view model

This is the same as the product review view model in part 1. I have a C# view model with properties and data annotations that is used by a partial to output a form. The form in this case has a few extra bits added…

<div class='alert alert-success' role='alert' data-bind="fadeVisible: commentFormSubmitted()">
</div>
<form data-bind="submit: submitReviewComment, fadeVisible: !commentFormSubmitted()" method="post" id="productReviewCommentForm">

Here, we have an alert div that displays a message on submit. Its visibility property is linked to a knockout observable that gets set when the form is submitted. I added a knockout binding handler that extends the knockout visible binding. This means that objects fade in and out instead of just appearing and disappearing. The binding handler code was just taken from here Knockoutjs animated transitions. To actually bind the model data to the knockoutjs view model, I did this…

@{
string data = new JavaScriptSerializer().Serialize(Model);
<script type="text/javascript">
    var productReviewsViewModel = new ProductReviewsViewModel(@Html.Raw(data));
    ko.applyBindings(productReviewsViewModel, document.getElementById('productReviews'));
</script>
}

The important bit here is that the bindings are applied only to the ‘productReviews’ div. If this parameter was not added, it would result in an error since bindings cannot be applied multiple times to the DOM as a whole. One other important aspect is to bind the parentId to the form, so the comment is added to the right review. There is only one comments modal, so the parentId value must be updated based on which ‘add a comment’ button is pressed. With knockoutjs, this is simply a case of binding the ParentId property to a knockoutjs observable.

@Html.HiddenFor(x => x.ParentId, new { @id = "productReviewCommentParentId", data_bind = "value: ParentId" })

Showing the reviews

In the knockout view model, the reviews can now be loaded. This is an ajax request to the umbraco api controller using a start index and productId as values to get the data.

self.loadReviews = function () {
  var productId = $('#productId').val();  // 1

  $.getJSON("/umbraco/api/ProductReview/LoadReviews", {
    startIndex: self.startIndex(),
    productId: productId
  }).done(function (data) {
    //concatenate the data and increment the button start index value
    if (data.length > 0) {  
      var temp = ko.mapping.fromJS(data, reviewMapping); // 2
      self.allReviews(self.allReviews().concat(temp()));  // 3
      self.startIndex(self.startIndex() + pageCount);  // 4
      if (data.length < pageCount) {  // 5
        self.moreResults(false);
      } else {
        self.moreResults(true);
      }
    } else {
      self.moreResults(false);
    }
  });
};

Here’s an explanation…

  1. The productId is required so that the right set of reviews are loaded. This is picked up from a hidden field
  2. Once the data has been loaded, its put into a temporary variable, which becomes an observable array when its mapped. One important bit of this is the second argument of the fromJS function, ‘reviewMapping.’ This is an object that is used to map each item in the array and transform it in some way. In this case, additional parameters and functions are added. They are added here because there is no point in having them as part of the c# view model. I’ll talk about this bit later.
  3. This appends the data to the existing observable array ‘allReviews.’ Initially, this is set as an empty array. This means that as more data is loaded, the array will be appended to rather than overwritten. This is part of the ‘show more’ functionality that I’ll talk about later.
  4. The startIndex property is incremented by the page count, which by default is 10. So, the next time data is loaded, it’ll start from the 10th item and get 10 more.
  5. If the length of data that comes back is less than the page count, it means that there isn’t any more to come, so a boolean observable ‘moreResults’ is set to false. This is bound to the visibility binding on the ‘show more’ button that’s going to sit on the template.

Extending knockout mapping

Pt.2 above is where data coming in is mapped to an observable array. However, each data item first passes through a kind of transformation object called ‘reviewMapping.’ This is the code…

var Review = function (review) {
  ko.mapping.fromJS(review, {}, this);
  this.showingComments = ko.observable(false);
  this.showHideComments = function () {
    this.showingComments(!this.showingComments());
  };
};

var reviewMapping = {
  create: function (options) {
    return new Review(options.data);
  }
};

The ‘create’ function allows additional properties and functions to be added to the mapped object. In this case, a boolean property and a function are added that is used to toggle showing/hiding review comments.

The templates

Now that the data has been bound, it can be looped through using a knockoutjs template.

<div data-bind="template: { name: 'productReviewsList', foreach: allReviews, as: 'review' }" class="allReviews"></div>

I’m not going to go through every line of the template, just the interesting(ish) bits.

<script type="text/html" id="productReviewsList">
<div class="row border-bottom  product-review" data-bind="attr: { id: Id }" >

This starts the template off and sets the html ‘id’ attribute to the review Id. This is used for the social sharing functionality, since its now possible to link directly to an anchor on the page.

Star rating

To do the star rating, I used this example and just did a data-bind on the style ‘width’ html attribute. Its taken from the css star rating example here

<div class="star-ratings-css">
  <div class="star-ratings-css-top" data-bind="style: { width: $root.getRating(Rating) }"><span>★</span><span>★</span><span>★</span><span>★</span><span>★</span></div>
  <div class="star-ratings-css-bottom"><span>★</span><span>★</span><span>★</span><span>★</span><span>★</span></div>
</div>

The binding links to a function that returns a percentage string based on a potential 5 star rating.

Showing/hiding comments

Here’s the code…

<a href="#" data-bind="visible: CommentCount(), click: showHideComments, text: !showingComments() ? '(Show all)' : '(Hide)'"></a>

This means that the link is only shown if there are actually comments. The text is dependent on the state of the comments. On click, the review’s ‘showHideComments’ function is called, which is a simple toggle. The ‘showingComments’ observable is also bound to the visibility binding of a child template that actually shows the comments. See below…

<div class="all-comments well row" data-bind="fadeVisible: showingComments">
  <h4>Comments</h4>
  <div data-bind="template: { name: 'productReviewComments', foreach: RelatedComments }"></div>
</div>

This binds to another template that loops through the ‘RelatedComments’ property of the review.

Adding comments

To actually add comments, I did the following…

<button class="btn btn-primary" data-bind="visible: CommentCount() < 20, click: $root.setParentId" data-toggle="modal" data-target="#productReviewComment">Add a comment</button>

This button has visibility and click bindings. If there are 20 or more comments, commenting is automatically disabled. On click, the parentId of the comments form is set and the form modal is opened.

self.setParentId = function (review) {
  self.commentFormSubmitted(false); // 1
  self.ParentId(review.Id()); // 2
};
  1. Set the ‘commentFormSubmitted’ observable to false. This observable is bound to the visibility of the comments form and form submission alert div
  2. This sets the ParentId observable which is bound on the comments form. This means that when the form is submitted, it will pass the correct product review.
Rating reviews

There is a simple mechanism for rating reviews as useful or useless. The logic here is, if useful is clicked, it should increment the useful property on the product review node in Umbraco. If useless is clicked, it should increment the useless property. However, there is a bit of hidden logic here.

  1. if ‘useful’ is clicked twice, the rating should increment then decrement. same for the ‘useless’ rating.
  2. if ‘useful’ is clicked then ‘useless,’ it should decrement the ‘useful’ rating and increment the ‘useless’ rating.
  3. users should not be able to click multiple times on the ‘useful’ or ‘useless’ rating. So the rating should be persistent to some extent.

Here’s the markup for the ‘useful’ rating…

<button class="btn btn-success useful-yes" data-bind="click: $root.reviewUsefulYes"><span class="glyphicon glyphicon-thumbs-up" aria-hidden="true"></span> Yes (<span data-bind="text: ReviewUsefulYesCount"></span>)</button>

It binds to a root function to control rating. A side point, I did try binding to the same function ‘reviewUseful’ and using the target to determine what to do but for some reason this caused issues, so I just extracted out 2 functions, ‘reviewUsefulYes,’ and ‘reviewUsefulNo.’ Here’s the function…

self.reviewUsefulYes = function (review, evt) {
  self.reviewUseful(review, evt, true);
};

self.reviewUseful = function (review, evt, isUseful) {
  var target = $(evt.target);
  target.attr('disabled', 'disabled'); 
  $.getJSON("/umbraco/api/ProductReview/RateReview", {
    reviewId: review.Id,
    reviewIsUseful: isUseful
  }).done(function (data) {
    if (data !== null) {
      review.ReviewUsefulNoCount(data.Data.ReviewUsefulNoCount);
      review.ReviewUsefulYesCount(data.Data.ReviewUsefulYesCount);
    }
    target.removeAttr('disabled');
  });
};

This function basically disables the button, posts the data, then on done, enables the button again and updates the count values. Most of the processing for this happens on the server side. Basically, a cookie is set every time someone rates a review, which lasts for 2 days. The cookie name is set to the product review id and the value set to either true or false, corresponding to ‘useful’ or ‘useless.’ On the server, once the rating has been processed, the property on the review node is updated, and then the new count returned along with a message to the client.

Sharing reviews

The last bit of this is the functionality to share a review (why you would do that I don’t know). Here’s the markup

<li><a target="_blank" data-bind="attr: { href: $root.facebookShareUrl($data) }" class="fb">share on Facebook</a></li>

The function that is called is this…

self.facebookShareUrl = function (review) {
  return 'https://www.facebook.com/sharer/sharer.php?u=' + escape(encodeURI(window.location.href + '#' + review.Id()));
};

This just takes the review id and appends it to the current url as an anchor, meaning that the facebook link would go directly to the review. There are issues here such as what happens when a user shares a review on page 3 of the reviews but I’ll deal with that when I have to 🙂

Conclusion

This post has discussed using knockoutjs templates, extending binding handlers and linking to Umbraco API controllers. The result is a dynamic, client-side solution that seamlessly integrates with the Umbraco back-end via API controllers.

3 thoughts on “Umbraco, knockout js and forms part 2

Leave a Reply