Knockout JS is a Javascript library using MVVM ( Model-View-ViewModel ) pattern to bind data with DOM elements. It is widely used in Magento 2, where implementations often include a ViewModel in javascript and an HTML template bound to the viewmodel.

 

In this article, we will go through the steps to create a module called Magenest_Feedback, used to collect feedback on customer about products they’ve ordered. We will use KnockoutJS in Magento 2 to :

 

- Get the feedback template

 

- Get feedback data to frontend page

 

- Send and store feedback and update its status

 

The finished module will look like this:

 

 

 

Initialize module

 

Let's skim through the usual steps.

 

First, we need to create the registation.php and etc/module.xml files:

Registration.php :

 

<?php
\Magento\Framework\Component\ComponentRegistrar::register(
        \Magento\Framework\Component\ComponentRegistrar::MODULE,
        'Magenest_Feedback',
        __DIR__
    );

etc/module.xml :
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
    <module name="'Magenest_Feedback'" setup_version="1.0.0">
        <sequence>
            <module name="Magento_Cms"/>
        </sequence>
    </module>
</config>

 

Next up, we need to create a table to store the feedback. Create the file Setup/InstallSchema.php:

 

<?php
// /app/code/Magenest/Feedback/Setup/InstallSchema.php
namespace Magenest\Feedback\Setup;

use Magento\Framework\Setup\InstallSchemaInterface;
use Magento\Framework\Setup\ModuleContextInterface;
use Magento\Framework\Setup\SchemaSetupInterface;

class InstallSchema implements InstallSchemaInterface
{

   /**
    * Installs DB schema for a module
    *
    * @param SchemaSetupInterface $setup
    * @param ModuleContextInterface $context
    * @return void
    */
   public function install(SchemaSetupInterface $setup, ModuleContextInterface $context)
   {
       // TODO: Implement install() method.
       $installer = $setup;
       $installer->startSetup();
       /**
        * Create table 'feedback'
        */
       $table = $installer->getConnection()
           ->newTable($installer->getTable('magenest_feedback'))
           ->addColumn(
               'entity_id',
               \Magento\Framework\DB\Ddl\Table::TYPE_SMALLINT,
               null,
               ['identity' => true, 'unsigned' => true, 'nullable' => false, 'primary' => true],
               'feedback id'
           )
           ->addColumn(
               'product_id',
               \Magento\Framework\DB\Ddl\Table::TYPE_INTEGER,
               32,
               ['nullable' => false],
               'product id'
           )
           ->addColumn(
               'order_id',
               \Magento\Framework\DB\Ddl\Table::TYPE_INTEGER,
               32,
               ['nullable' => false],
               'order id'
           )
           ->addColumn(
               'customer_id',
               \Magento\Framework\DB\Ddl\Table::TYPE_INTEGER,
               32,
               ['nullable' => false],
               'customer id'
           )
           ->addColumn(
               'message',
               \Magento\Framework\DB\Ddl\Table::TYPE_TEXT,
               255,
               ['nullable' => FALSE],
               'User message'
           )
           ->addColumn(
               'status',
               \Magento\Framework\DB\Ddl\Table::TYPE_BOOLEAN,
               3,
               ['unsigned' => true, 'nullable' => false, 'default' => '0'],
               'feedback status'
           )
           ->addColumn(
               'crated_at',
               \Magento\Framework\DB\Ddl\Table::TYPE_TIMESTAMP,
               90,
               ['nullable' => FALSE],
               'created time'
           )
           ->setComment('customer feedback product which ordered   ');
       $installer->getConnection()->createTable($table);
       $setup->endSetup();
   }
}

 

Create a custom frontend page Customer Page

 

Step 1: File routes.xml

 

<?xml version="1.0"?>
<!--File: app/code/Magenest/Feedback/etc/frontend/routes.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
   <router id="standard">
       <route id="customer_feedback" frontName="feedback">
           <module name="Magenest_Feedback"/>
       </route>
   </router>
</config>

 

Step 2: Create a controller

 

<?php
// /app/code/Magenest/Feedback/Controller/Customer/Index.php
namespace Magenest\Feedback\Controller\Customer;

use Magento\Framework\App\Action\Action;
use Magento\Framework\App\Action\Context;
use Magento\Framework\App\ResponseInterface;
use Magento\Framework\View\Result\PageFactory;

class Index extends Action
{
   protected $_pageFactory;

   public function __construct(Context $context, PageFactory $pageFactory)
   {
       parent::__construct($context);
       $this->_pageFactory = $pageFactory;
   }

   /**
    * Execute action based on request and return result
    *
    * Note: Request will be added as operation argument in future
    *
    * @return \Magento\Framework\Controller\ResultInterface|ResponseInterface
    * @throws \Magento\Framework\Exception\NotFoundException
    */
   public function execute()
   {
       return $this->_pageFactory->create();
   }
}

 

With these files, you can access  http://baseURL/feedback/customer/ with default magento layout and blank content area.

 

 

Step 3: Create a link in the Navigation tab on Customer Account Page & Update Customer Page Layout

 

- Add our custom link in customer’s dashboard layout customer_account.xml

<!--File: app/code/Magenest/Feedback/view/frontend/layout/customer_account.xml -->
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
   <body>
       <referenceBlock name="customer_account_navigation">
           <block class="Magento\Framework\View\Element\Html\Link\Current" name="customer-account-navigation-feedback">
               <arguments>
                   <argument name="path" xsi:type="string">feedback/customer</argument>
                   <argument name="label" xsi:type="string">Customer Feedback</argument>
               </arguments>
           </block>
       </referenceBlock>
   </body>
</page>

 

Our new entry is as follow:

 

 

- Create the layout for our custom page:

 

<!--File: app/code/Magenest/Feedback/view/frontend/layout/customer_feedback_customer_index.xml -->
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
   <update handle="customer_account"/>
   <head>
       <css src="Magenest_Feedback::css/customer-feedback.css"/>
   </head>
   <body>
       <referenceBlock name="page.main.title">
           <action method="setPageTitle">
               <argument translate="true" name="title" xsi:type="string">Customer Feedback</argument>
           </action>
       </referenceBlock>
       <referenceContainer name="content">
           <block class="Magenest\Feedback\Block\Customer\Feedback" name="customer_feedback" template="Magenest_Feedback::customer_feedback.phtml" cacheable="false"/>
       </referenceContainer>
   </body>
</page>

 

Create the file customer_feedback.phtml. Here we bind a scope called ‘feedback-form’ and declare a component. KnockoutJS will handle this component and display the result on this template

 

<?php
/**
* @var \Magenest\Feedback\Block\Customer\Feedback $block ;
* @linkFile app/code/Magenest/Feedback/view/frontend/templates/customer_feedback.phtml
*/
?>
<div id="customer-feedback" data-bind="scope: 'feedback-form'">
   <!-- ko template: getTemplate() --> <!-- /ko -->
</div>

<script type="text/x-magento-init">
   {
       "#customer-feedback": {
           "Magento_Ui/js/core/app": {
               "components": {
                   "feedback-form": {
                       "component": "Magenest_Feedback\/js\/view\/customer-feedback",
                       "listProducts": <?php /* @escapeNotVerified */ echo $block->getListProduct(); ?>
                   }
               }
           }
       }
   }

</script>

 

Before we can write the component, we need to write the block for this template, to get product data and pass to the component.

 

Step 4: File block

 

Using this block, we get the products customers have bought, and separate between products with and without feedback and return the result to the component:

 

<?php
// File: app/code/Magenest/Feedback/Block/Customer/Feedback.php
namespace Magenest\Feedback\Block\Customer;

use Magento\Customer\Model\Session;
use Magento\Framework\View\Element\Template;
use function PHPSTORM_META\elementType;

class Feedback extends Template
{
   protected $customerSession;
   protected $_orderCollectionFactory;
   protected $orderRepository;
   protected $_productLoader;
   protected $_storeManager;
   protected $feedbackCollectionFactory;
   protected $_productImageHelper;

   public function __construct(
       Template\Context $context,
       \Magento\Sales\Model\ResourceModel\Order\CollectionFactory $orderCollectionFactory,
       \Magento\Sales\Api\OrderRepositoryInterface $orderRepository,
       Session $customerSession,
       \Magento\Catalog\Model\ProductFactory $_productLoader,
       \Magento\Store\Model\StoreManagerInterface $storeManager,
       \Magenest\Knockout\Model\ResourceModel\Feedback\CollectionFactory $feedbackCollectionFactory,
       \Magento\Catalog\Helper\Image $productImageHelper,
       array $data = [])
   {
       $this->customerSession = $customerSession;
       $this->_orderCollectionFactory = $orderCollectionFactory;
       $this->orderRepository = $orderRepository;
       $this->_productLoader = $_productLoader;
       $this->_storeManager = $storeManager;
       $this->feedbackCollectionFactory = $feedbackCollectionFactory;
       $this->_productImageHelper = $productImageHelper;
       parent::__construct($context, $data);
   }

   public function setActive($path)
   {
       $this->_activeLink = $this->_completePath($path);
       return $this;
   }

   public function getProductBought(){
       // get customer ID from session
       $customerId = $this->customerSession->getCustomer()->getId();
       // get order which bought by customer
       $orders = $this->_orderCollectionFactory->create()->addFieldToSelect('entity_id')->addFieldToFilter('customer_id', $customerId);
       $orderedProduct = [];
       // get product info
       foreach ($orders as $item){
           $order = $this->orderRepository->get($item['entity_id']);
           $orderedItems = $order->getAllVisibleItems();

           foreach ($orderedItems as $item) {
               $store = $this->_storeManager->getStore();
               $product = $this->_productLoader->create()->load($item->getData('product_id'));
               $orderedProduct[] = [
                   'order_id' => $order->getEntityId(),
                   'product_id' => $product->getId(),
                   'name' => $product->getName(),
                   'sku' => $product->getSku(),
                   'image' => $this->_productImageHelper->init($product, 'product_thumbnail_image')
                       ->constrainOnly(TRUE)
                       ->keepAspectRatio(TRUE)
                       ->keepTransparency(TRUE)
                       ->keepFrame(FALSE)
                       ->resize(240, 300)->getUrl(),
                   'url' => $product->getProductUrl(),
                   'comment_status' => false,
                   'message' => '',
                   'customer_id' => $customerId
               ];
           }

       }
       return $orderedProduct;
   }

   public function getListProduct(){
       $listProducts = $this->getProductBought();
      
       // check if product have feedback?
       foreach ($listProducts as $index => $product){
           $feedback = $this->feedbackCollectionFactory->create()
               ->addFieldToFilter('order_id', $product['order_id'])
               ->addFieldToFilter('product_id', $product['product_id'])
               ->addFieldToFilter('customer_id', $product['customer_id']);
           if (count($feedback->getData())){
               $listProducts[$index]['comment_status'] = true;
               $listProducts[$index]['message'] = $feedback->getData()[0]['message'];
           }
       }

       return json_encode($listProducts);
   }

}

 

Step 5: Component

 

Our component using Magento 2 KnockoutJS to process data:

 

// File: app/code/Magenest/Feedback/view/frontend/web/js/view/customer-feedback.js
define([
   'uiComponent',
   'ko',
   'jquery',
   'Magento_Ui/js/modal/alert',
   'mage/backend/tabs',
   'Magento_Ui/js/modal/modal'
], function(uiComponent, ko, $, alert) {
   'use strict';

   function ProductReview(data){
       var self = this;

       self.order_id = data.order_id;
       self.product_id = data.product_id;
       self.name = data.name;
       self.customer_id = data.customer_id;
       self.image = data.image;
       self.message = ko.observable(data.message);
       self.comment_status = ko.observable(data.comment_status);
      
       self.submitReview = function () {
           var data = {
               'order_id': self.order_id,
               'product_id': self.product_id,
               'customer_id': self.customer_id,
               'message': self.message(),
               'status': self.comment_status()
           };
           $.ajax({
               url: '/feedback/customer/submitreview',
               data: data,
               type: 'post',
               dataType: 'json',
               context: this,
               success: function (response) {
                   if (response.status === true){
                       alert({
                           content: $.mage.__('Thanks for Submitting.')
                       });
                       self.comment_status(true);
                   }
               },
           });
       }
   };

   return uiComponent.extend({
       defaults: {
           template: 'Magenest_Feedback/customer-feedback'
       },


       initialize: function (config) {
           var products = [];
           $.each(config.listProducts, function (index, product) {
               products.push(new ProductReview(product));
           });
           this.products = products;
           this.product = ko.observable();

           this._super();
       },

       initTabs: function () {
           $('#horizontal_tabs').tabs();
       },

       initModal: function(){
           var self = this;
           $('#feedback-form').modal({
               buttons: [{
                   text: 'Send Review',
                   click: function() {
                       self.product().submitReview();
                       this.closeModal();
                   }
               }]
           });
       },
      
       openReviewForm: function (product, data) {
           this.product(product);
           $('#feedback-form').modal('openModal');
       }
      
   });
});

 

Template file:

 

// File: app/code/Magenest/Feedback/view/frontend/web/template/customer-feedback.html
<div id="horizontal_tabs" data-bind="afterRender: initTabs">
   <ul class="tab" data-role="title">
       <li class="tablinks"><a href="#tabs-1">Product ordered</a></li>
       <li class="tablinks"><a href="#tabs-2">Product Have Feedback</a></li>
   </ul>
   <div data-role="content" class="tabcontent">
       <div id="tabs-1">
           <div class="list-product" data-bind="foreach: { data: products, as: 'product' }">
               <!-- ko if: product.comment_status() === false -->
               <div class="product">
                   <div class="thumbnail"><img data-bind="attr: {src: product.image}" alt=""></div>
                   <div class="title" data-bind="text: product.name + ' (Order #' + product.order_id + ')'"></div>
                   <div class="action"><button data-bind="click: function(data, event) { $parent.openReviewForm(product, data) }">Write comment</button></div>
               </div>
               <!-- /ko -->
           </div>
       </div>
       <div id="tabs-2">
           <div class="list-product" data-bind="foreach: { data: products, as: 'product' }">
               <!-- ko if: product.comment_status() === true -->
               <div class="product">
                   <div class="thumbnail"><img data-bind="attr: {src: product.image}" alt=""></div>
                   <div class="title" data-bind="text: product.name + '(Order #' + product.order_id + ')'"></div>
                   <div class="title" data-bind="text: 'Comment: ' + product.message()"></div>
               </div>
               <!-- /ko -->
           </div>
       </div>
   </div>

</div>
<div id="feedback-form" title="Send Feedback" data-bind="afterRender: initModal">
   <form>
       <fieldset data-bind="with: product">
           <label for="name">Message</label>
           <textarea type="text" id="name" value="Jane Smith" class="text ui-widget-content ui-corner-all" data-bind="value: message"></textarea>
       </fieldset>
   </form>
</div>

 

Here’s a breakdown of the component and template file we’ve made:

 

- First, the component file needs to have a template assigned. This template will be used when we call getTempalte() from .phtml file:

defaults: {
   template: 'Magenest_Feedback/customer-feedback'
}, 

 

- Our template uses a tabs widget. We initialize this widget after rendering the template, using: data-bind="afterRender: initTabs" . In our components, we write a function to initialize tabs widget. In summary, to use jQuery widget on the template, we need to use afterRender to ensure the widget is applied after the template is rendered.

 

- Similarly, we use the modal widget to display the feedback form

 

- Next, we need to initialize the variables to use in the template:

 

- Here we define 2 variables: products - to store products the current customer have bought, and product - the current product in focus.

 

- We also create a function storing required variables and parameters for the component:

initialize: function (config) {
   var products = [];
   $.each(config.listProducts, function (index, product) {
       products.push(new ProductReview(product));
   });
   this.products = products;
   this.product = ko.observable();

   this._super();
},

 

- To pass data to the template, we use a foreach loop and if to classify product types ( has feedback or not ).

 

- Variable products are defined when initializing the component, containing product parameters.

 

- We use function SubmitReview with ajax request to save feedback.

 

- The variable product is set to the product in focus, after clicking ‘Write feedback’ on a product.

 

Step 6: Save feedback to the database

 

<?php
// File: app/code/Magenest/Feedback/Controller/Customer/SubmitReview.php
namespace Magenest\Feedback\Controller\Customer;

use Magento\Framework\App\Action\Action;
use Magento\Framework\App\Action\Context;
use Magento\Framework\App\ResponseInterface;

class SubmitReview extends Action
{
   protected $feedbackFactory;

   public function __construct(Context $context, \Magenest\Feedback\Model\FeedbackFactory $feedbackFactory)
   {
       $this->feedbackFactory = $feedbackFactory;
       parent::__construct($context);
   }

   /**
    * Execute action based on request and return result
    *
    * Note: Request will be added as operation argument in future
    *
    * @return \Magento\Framework\Controller\ResultInterface|ResponseInterface
    * @throws \Magento\Framework\Exception\NotFoundException
    */
   public function execute()
   {
       // TODO: Implement execute() method.
       $response = $this->resultFactory
           ->create(\Magento\Framework\Controller\ResultFactory::TYPE_JSON)
           ->setData([
               'status'  => true,
           ]);
       try {
           $formDataRequest = $this->getRequest()->getParams();
           $feedbackModel = $this->feedbackFactory->create();
           $feedbackModel->setData($formDataRequest);
           $feedbackModel->save();

       } catch (\Exception $exception){
           $response = $this->resultFactory
               ->create(\Magento\Framework\Controller\ResultFactory::TYPE_JSON)
               ->setData([
                   'status'  => false,
                   'message' => $exception
               ]);
       } finally {
           return $response;
       }
   }
}

 

The final touches

 

- CSS styling for our custom page:

/*File: app/code/Magenest/Feedback/view/frontend/web/css/customer-feedback.css */
/* Style the tab */
.tab {
   overflow: hidden;
   border: 1px solid #ccc;
   background-color: #f1f1f1;
   list-style: none;
}
ul > li {
   margin-bottom: 0rem !important;
}
/* Style the buttons inside the tab */
.tab li {
   background-color: inherit;
   float: left;
   border: none;
   outline: none;
   cursor: pointer;
   padding: 14px 16px;
   transition: 0.3s;
   font-size: 17px;
}

/* Change background color of buttons on hover */
.tab li:hover {
   background-color: #ddd;
}

/* Create an active/current tablink class */
.tab li.ui-tabs-active {
   background-color: #ccc;
}

/* Style the tab content */
.tabcontent {
   padding: 6px 12px;
   border: 1px solid #ccc;
   border-top: none;
}

.list-product .product {
   display: inline-block;

   margin: 5px 0px;

   text-align: center;

   border: #d7d7d7 1px solid;

   padding: 3px;
}

 

- Plugin in di.xml to disable guests from visiting our feedback page:

<!--File: app/code/Magenest/Feedback/etc/di.xml-->
<?xml version="1.0" encoding="UTF-8" ?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
   <type name="Magenest\Feedback\Controller\Customer\Index">
       <plugin name="authentication" type="Magento\Sales\Controller\Order\Plugin\Authentication"/>
   </type>
</config>

 

Conclusion

 

I hope that this tutorial is helpful to you.

 

You can get the completed module here.