Articles

Responsive ModX Commenting with Quip

Mike Harvey

Article Header

Details on the recent refactoring some key elements of ModX Quip Commenting AddOn for responsive design and simpler user experience

Background

I offered to document the changes I have made to ModX Quip which is a nice commenting addon for ModX CMS, but is lacking in some useability features compared to Wordpress and others. My goals were to make it responsive (using bootstrap 3.0) and quicker with more inline functions and fewer postbacks to the server. I am still working on integrating reCaptchav2, but to do it in an unobtrusive way without hacking the existing quip recaptcha functionality. I also tested some markdown functionality of the Comments textarea using TinyMCE, and Markdown-It, but decided against it for several reasons and scrapped the markdown idea.

Included here are all the custom chunks, scripts, and styles I used in the process with as much explanation I think necessary for a reasonably experienced ModX developer. Much thanks to the awesome ModX Community for their input and support not only for this work, but for the past several years.

Platform and Versions

As of the writing of this, all the relevant versions are the latest stable versions available:

  • Platform: ModxCLoud
  • Version: Revo 2.3.5pl
  • Jquery: 2.0.2
  • Bootstrap: v3.3.5 with all components
  • Articles: 1.7.11-pl
  • Quip: 2.3.3-pl
  • getResources: 1.6.1-pl

Without further ado, see the tabs below for each component of the solution.

Templates and Chunks

All of the formatting for the following chunks is using Bootstrap 3.0 css and components to enable a responsive layout. The addtional css required will be shown in the next section.
  1. CommentsContainerTPL chunk

    Chunk to include at the bottom of your article template to show the comments for that article:
    [[!Quip@CMHQuip? &thread=`article-[[*id]]`]]
    
    <hr />
    
    <a id="post-a-comment" class="post-comment replyTo main-comment-form" data-pid="0" data-toggle="collapse" href="#comment-form" aria-expanded="false" aria-controls="comment-form"><h2 style="margin-bottom:20px">Add a Comment</h2></a><br /><br />
       <div class="row">
               <div class="col-sm-12">
                       <div id="comment-form" class="post-comment-body collapse">
                              [[!QuipReply@CMHReply? &thread=`article-[[*id]]`]]
                       </div>
                </div>
        </div>
                    
  2. CommentsWrapper chunk

    Set this as your Comments Wrapper Chunk in the Articles>Comments>Display tab:
    <div class="quip">
        <h3> [[%quip.comments]] ( [[+total]])</h3>
        
        <div id="quip-topofcomments- [[+idprefix]]"></div>
    
        [[+comments:notempty=`
        [[+comments]]
        `]]
    
        [[+pagination]]
    </div>
                    
  3. MediaCommentTpl chunk

    Chunk for each comment. This is formatted as a Bootstrap media compenent which handles nesting very nicely for threaded comments. Set this as your Comment Chunk in the Articles>Comments>Display tab:
    <div class="media" id="[[+idprefix]][[+id]]">
        <div class="author-container pull-left">
        [[+gravatarUrl:notempty=`<img src="[[+gravatarUrl]]" class="media-object" />`]]
        <span class="desc">
            <span class="author_name">[[+authorName]]</span>
        </span>
        </div>
        <div class="media-body">
            <span class="small pull-right"><i>Posted On: [[+createdon]]</i></span><br />
            <p>[[+body]]</p>
            [[+replyUrl:notempty=`<a role="button" href="[[+replyUrl]]" data-pid="[[+id]]" class="replyTo btn btn-warning btn-sm"><span class="glyphicon glyphicon-share-alt"></span>Reply</a>`]][[+approved:if=`[[+approved]]`:is=`1`:then=``:else=`- <em>[[%quip.unapproved? &namespace=`quip` &topic=`default`]]</em>`]]<span class="quip-comment-options">[[+report]][[+options]]</span>
            [[+children:notempty=`[[+children]]`]]
        </div>
    </div>
                        
  4. addCommentTpl chunk

    Set this your Reply Form Chunk in the Articles>Comments>Display tab:
    <div id="preview-div" style="display:none;">
        <div class="media">
            <div class="pull-left">
                  <img id="gravpreview" src="" class="media-object" />
                  <span class="desc">
                      <span class="author_name"><span id="authorName"></span></span>
                  </span>
            </div>
            <div class="media-body">
                <p><span id="comment"></span></p>
                <br class="clear" />
            </div>
        </div>
    </div>
    <div class="alert alert-success" role="alert" style="display:none;">
        <button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">x</span></button>
         [[+successMsg]]
    </div>
     
    <form id="quip-add-comment- [[+idprefix]]" action=" [[+url]]#quip-comment-preview-box- [[+idprefix]]" method="post" role="form" class="comment-form">
    <div class="row">
        <input id="idprefix" type="hidden" name="idprefix" value=" [[+idprefix]]" />
        <input type="hidden" name="nospam" value="" />
        <input type="hidden" name="thread" value=" [[+thread]]" />
        <input type="hidden" name="parent" value=" [[+parent]]" />
        <input type="hidden" name="auth_nonce" value=" [[+auth_nonce]]" />
        <input type="hidden" name="preview_mode" value=" [[+preview_mode]]" />
     
         <div class="form-group col-sm-12">
            <input class="form-control" type="text" name="name" placeholder="Name" id="quip-comment-name- [[+idprefix]]" value=" [[+name]]" />
            <span class="help-block" style="display: none;">Please enter a valid name.</span><span class="error"> [[+error.name]]</span>
        </div>
        <div class="form-group col-md-12">
            <input class="form-control" type="text" name="email" placeholder="Email" id="quip-comment-email- [[+idprefix]]" value=" [[+email]]" />
            <span class="help-block" style="display: none;">Please enter a valid e-mail address.</span><span class="error"> [[+error.email]]</span>
        </div>
        <div class="form-group col-md-12">
            <input class="form-control" type="text" name="website" placeholder="Website" id="quip-comment-website- [[+idprefix]]" value=" [[+website]]" />
            <span class="help-block" style="display: none;">Please enter a valid website address.</span><span class="error"> [[+error.website]]</span>
        </div>
     </div>
    <div class="row">
        <div class="col-md-12 form-group ">
            <div class="checkbox col-lg-4 col-md-4 col-sm-12">
                <label for="quip-comment-notify- [[+idprefix]]">
                     <input type="checkbox" value="1" name="notify" id="quip-comment-notify- [[+idprefix]]"  [[+notify:if=` [[+notify]]`:eq=`1`:then=`checked="checked"`]] />
                      [[%quip.notify_me]]                
                </label>
                <span class="error"> [[+error.notify]]</span>
            </div>
            
            <div class="col-lg-8 col-md-8 col-sm-12 recaptcha pull-right">
                 [[+quip.recaptcha_html]]
                <span class="error"> [[+error.recaptcha]]</span>
            </div>
        </div>
    </div>
    <div class="row">
        <div class="col-md-4 col-md-offset-8 help-block"><span class="float-right small"> [[%quip.allowed_tags? &tags=` [[++quip.allowed_tags:htmlent]]`]]</span></div>
    </div>
    <div class="row">
        <div class="form-group col-md-12">
            <textarea name="comment" class="form-control" id="quip-comment-box- [[+idprefix]]" rows="5" placeholder=" [[%quip.comment_add_new]]" > [[+comment]]</textarea>
            <span class="help-block" style="display: none;">Please see allowable tabs above.</span><span class="error"> [[+error.comment]]</span>
        </div>
    </div>
    <div class="row">
         <div class="form-group col-md-12">
            <button id="preview-btn" type="submit" name=" [[+preview_action]]" value="1" class="btn btn-warning btn-sm" style="display: inline-block; margin-top: 10px;"> [[%quip.preview]]</button>
            [[+can_post:is=`1`:then=`<button type="submit" name=" [[+post_action]]" value="1" class="btn btn-success btn-sm" style="display: inline-block; block; margin-top: 10px;"> [[%quip.post]]</button>`]]
            <button type="button" class="reply-cancel btn btn-danger btn-sm pull-right" style="display: inline-block; margin-top: 10px;">Cancel</button>
         </div>
    </div>
                        
  5. CommentOptionsTpl chunk

    Set this as your Comments Options Chunk in the Articles>Comments>Display tab:
    [[+allowRemove:notempty=` <a href="[[+removeUrl]]" id="remove-btn" class="btn btn-warning btn-xs pull-right" style="display: inline-block; margin-right: 5px; font-size:10px;"><span class="glyphicon glyphicon-remove-sign"></span>&nbsp;[[%quip.remove]]</a>`]]
                        
  6. ReportasSpamTpl chunk

    Set this as either an option &tplReport on your QuipQ snippet call, or (what I did) create a new Property set to hold all my settings:
    <span class="pull-right">
        [[+reported:empty=`<a href="[[+reportUrl]]" id="report-btn" class="btn btn-danger btn-xs pull-right" style="display: inline-block; padding-left:5px; font-size:10px;"><i class="fa fa-comment"></i>&nbsp;Report Spam</a>`]]
        [[+reported:notempty=`<span class="bg-danger small text-danger" style="padding:2px;">[[%quip.reported_as_spam]]</span>`]]
        </span>
                        
  7. SideBar chunk

    Used on article template pages:
    <div class="well">
        <h4>Latest Posts</h4>
        <div class="row">
            <div class="col-lg-12">
                <ul class="list-unstyled icon-list article">
                  [[getResources?
                    &parents=`110`
                    &depth=`2`
                    &limit=`5`
                    &sortby=`{"createdon":"DESC"}`
                    &tpl=`latestArticlesTpl`
                  ]]
                </ul>
            </div>
        </div>
    </div>
    <div class="well">
        <h4>Latest Comments</h4>
        <ul class="list-unstyled icon-list comment">
            [[!QuipLatestComments? &tpl=`latestCommentsTpl` &limit=`5`]]
        </ul>
    </div>
                        
  8. latestArticles chunk

    Set this as the &tpl option on the getResources snippet call. See (Sidebar chunk above):
    <!-- blog entry [[+idx]] -->
        [[!getAuthorInfo? &userId=`[[+createdby]]` &prefix=`user.`]]
        <li class="blog-list br-gray"><a href="[[~[[+id]]]]">[[+pagetitle]]</a>
            <span class="small">-<a class="sm-blog-author" href="mailto:[[+user.email]]">[[+user.fullname]]</a><br />
            <span class="small glyphicon glyphicon-time"></span> Posted on [[+publishedon:strtotime:date=`%a %b %e, %Y`]]</span>
    
        </li>
    <!-- end blog item -->
                        
  9. latestComments chunk

    Set this as the &tpl option on the QuipLatestComments snippet call. See (Sidebar chunk above):
    <!-- comment entry [[+idx]] -->
        <li class="comment-list br-gray"><span class="glyphicons glyphicons-comments"></span>&nbsp;<a href="[[+url]]">[[+body:ellipsis=`20`]]</a><br />
        <span class="small glyphicon glyphicon-time"></span> Posted on [[+ago:strtotime:date=`%a %b %e, %Y`]]</span>
        </li>
        <!-- end comment -->
                        

Snippets and Code Changes

Custom Snippets and code changes are detailed below:
  1. getAuthor snippet

    Custom snippet used to get author details for sidebar (see call in Sidebar chunk above):
    
        $userId = $modx->getOption('userId',$scriptProperties,false);
        if (empty($userId)) return '';
         
        /* get user and profile by user id */
        $user = $modx->getObject('modUser',$userId);
        if (!$user) return '';
        $profile = $user->getOne('Profile');
        if (!$profile) return '';
         
        $userArray = array_merge($user->toArray(),$profile->toArray());
         
        $modx->toPlaceholders($userArray,'user');
        return '';
                        
  2. recaptcha.class.php

    Customization to make recaptcha widget responsive. I did this in lieu of the recaptchv2 integration though I may get to that sooner or later. Changes to the getHtml function are described below. The file is located at core/components/quip/model/recaptcha/recaptcha.class.php. Before changes:
    
        public function getHtml($theme = 'clean',$error = null) {
            if (empty($this->config[reCaptcha::OPT_PUBLIC_KEY])) {
                return $this->error($this->modx->lexicon('recaptcha.no_api_key'));
            }
    
            /* use ssl or not */
            $server = !empty($this->config[reCaptcha::OPT_USE_SSL]) ? $this->config[reCaptcha::OPT_API_SECURE_SERVER] : $this->config[reCaptcha::OPT_API_SERVER];
    
            $errorpart = '';
            if ($error) {
               $errorpart = "&error=" . $error;
            }
            $opt = array(
            // changes will start here
                'theme' => $theme,
                'lang' => $this->modx->getOption('cultureKey',null,'en'),
            );
            return '
            //changes will end here
     <noscript>
            <iframe src="'. $server . 'noscript?k=' . $this->config[reCaptcha::OPT_PUBLIC_KEY] . $errorpart . '" height="300" width="500" frameborder="0"></iframe><br/>
            <textarea name="recaptcha_challenge_field" rows="3" cols="40"></textarea>
            <input type="hidden" name="recaptcha_response_field" value="manual_challenge"/>
    </noscript>';
    }
                        
    After changes:
    
        public function getHtml($theme = 'clean',$error = null) {
            if (empty($this->config[reCaptcha::OPT_PUBLIC_KEY])) {
                return $this->error($this->modx->lexicon('recaptcha.no_api_key'));
            }
    
            /* use ssl or not */
            $server = !empty($this->config[reCaptcha::OPT_USE_SSL]) ? $this->config[reCaptcha::OPT_API_SECURE_SERVER] : $this->config[reCaptcha::OPT_API_SERVER];
    
            $errorpart = '';
            if ($error) {
               $errorpart = "&error=" . $error;
            }
            $opt = array(
            // start of changes to theme
                'theme' => 'custom',
                'custom_theme_widget' => 'recaptcha_widget',
                'lang' => $this->modx->getOption('cultureKey',null,'en'),
            );
            return '<script type="text/javascript">var RecaptchaOptions = '.$this->modx->toJSON($opt).';</script>
             <div id="recaptcha_widget" style="display:none">
                <div class="control-group">
                    <div class="controls">
                        <a id="recaptcha_image" href="#" class="thumbnail"></a>
                        <div class="recaptcha_only_if_incorrect_sol" style="color:red">Incorrect please try again</div>
                    </div>
                </div>
             
                <div class="control-group">
                    <label class="recaptcha_only_if_image control-label">Enter the words above:</label>
                    <label class="recaptcha_only_if_audio control-label">Enter the numbers you hear:</label>
             
                    <div class="input-group">
                        <input type="text" id="recaptcha_response_field" class="input-recaptcha form-control" name="recaptcha_response_field" />
                        <span class="input-group-btn">
                            <a class="btn btn-warning" href="javascript:Recaptcha.reload()"><span title="Refresh Image" class="glyphicon glyphicon-refresh"></span></a>
                        </span>
                        <span class="input-group-btn">
                            <a class="btn btn-warning recaptcha_only_if_image" href="javascript:Recaptcha.switch_type(\'audio\')"><span title="Get an audio CAPTCHA"class="glyphicon glyphicon-headphones"></span></a>
                        </span>
                        <span class="input-group-btn">
                            <a class="btn btn-warning recaptcha_only_if_audio" href="javascript:Recaptcha.switch_type(\'image\')"><span title="Get an image CAPTCHA" class="glyphicon glyphicon-picture"></span></a>
                        </span>
                        <span class="input-group-btn">  
                        <a class="btn btn-warning" href="javascript:Recaptcha.showhelp()"><span class="glyphicon glyphicon-question-sign"></span></a>
                        </span>
                    </div>
                </div>
            </div>
            // end of changes to theme
            <script type="text/javascript" src="'. $server . 'challenge?k=' . $this->config[reCaptcha::OPT_PUBLIC_KEY] . $errorpart . '"></script>
            
            <noscript>
                    <iframe src="'. $server . 'noscript?k=' . $this->config[reCaptcha::OPT_PUBLIC_KEY] . $errorpart . '" height="300" width="500" frameborder="0"></iframe><br/>
                    <textarea name="recaptcha_challenge_field" rows="3" cols="40"></textarea>
                    <input type="hidden" name="recaptcha_response_field" value="manual_challenge"/>
            </noscript>';
        }
    
                        

Javascript Functions

As one of the primary goals was to make fewer posts to the server, I decided to inline both the reply to comment and preview functions. The Reply function leverages a single form which is moved within the page as requested by the user. The original inline reply inspiration came from DesignCouch's site design, and was further improved by the LEague OF Extraordinary Catholics.

All of the functions below should be placed in the document ready function to be invoked after the page is fully rendered.

Requirements: Bootstrap Collapse and Popovers are implemented jQuery MD5 Plugin 1.2.1 used for gravatar preview fetching.
  1. Toggle Main Comment Form Click Event

    This function toggles the main comment form which is originally positioned at the bottom of the page under the Add a Comment heading:
    
        $("#post-a-comment").click(function(event){
            event.preventDefault();
            $("#comment-form").collapse('toggle');
        });
                
  2. Reply to Comment Form Click Event

    This is the magic function that moves the single comment form and preview section inline within the threaded comments.
    
        $('.replyTo').on('click', function(event){
            event.preventDefault();
            var idPrefix = $('#idprefix').val();
            var commentParent = $(this).attr('data-pid');
            $('#preview-div').hide();
            $('.replyTo').removeClass('hide');
            replyurl = $(this).attr('href');
            $("#quip-add-comment-qcom").parent().insertAfter(this).collapse("show");
            $("#quip-add-comment-qcom input[name=parent]").val(commentParent);
            $('#quip-comment-box-' + idPrefix ).val('');
            $("#remove-btn, #report-btn").hide();
            $("#preview-div").popover('hide');
            if (!$(this).hasClass('main-comment-form')) {$(this).addClass('hide');}
            return false;
        });
                
  3. Reply Cancel Form Click Event

    This function remove the reply form on cancel and return it to the default position at the bottom of the page. It also hides any currently visible preview and clears the comment textarea.
    $('.reply-cancel').on('click', function(event){
             event.preventDefault();
             var idPrefix = $('#idprefix').val();
             $('#preview-div').hide();
             $('#quip-add-comment-qcom').parent().insertBefore('.main-comment-form').collapse('hide');
             $('.replyTo').removeClass('hide');
             $('#remove-btn, #report-btn').show();
             $("#preview-div").popover('hide');
             $('#quip-comment-box-' + idPrefix ).val('');
             return false;
        });
                
  4. Preview Form Click Event

    This function takes current form values and populates them in a preview div that emulates the comment tpl without having to postback to the server.
    $('#preview-btn').on('click', function(event){
         event.preventDefault();
         var idPrefix = $('#idprefix').val();
         var emailID = #quip-comment-email-' + idPrefix;
         var emailAddy = $(emailID).val();
         $('#gravpreview').attr('src', 'http://www.gravatar.com/avatar/' + $.md5(emailAddy));
         var author = $('#quip-comment-name-' + idPrefix ).val();
         $('#authorName').html(author);
         var comment = $('#quip-comment-box-' + idPrefix ).val();
         $('#comment').html(comment);
         $('#preview-div').fadeIn(2200);
         $("#preview-div").popover('show');
    });
                
  5. Popover Functions for Preview

    The below function are optional but clearly show the user where their preview is in the tree. It is impemented with javascript only (no data hooks on html elements) in case you want to omit it entirely. These also go in document ready function.
    
        $('#preview-div').popover({
            placement: 'right',
            animation: true,
            html: true,
            title : ' Here\'s your preview!',
            content: 'To change something just modify the form and click the preview button again.',
            viewport: { selector: '.container', padding: 0 },
            delay: '3000'
        });
    
        $('#preview-btn').on('click', function(event){
             event.preventDefault();
             console.log("Firing preview");
        
             var idPrefix = $('#idprefix').val();
             var emailID = "#quip-comment-email-" + idPrefix;
             var emailAddy = $(emailID).val();
             $('#gravpreview').attr('src', 'http://www.gravatar.com/avatar/' + $.md5(emailAddy));
             var author = $('#quip-comment-name-' + idPrefix ).val();
             $('#authorName').html(author);
             var comment = $('#quip-comment-box-' + idPrefix ).val();
             $('#comment').html(comment);
             $('#preview-div').fadeIn(2200);
             $("#preview-div").popover('show');
             return false;
        });
                

Styling

All of the formatting is using Bootstrap 3.0 css and components to enable a responsive layout. The additional css for the layout also bears responsiveness in mind.
  1. Required Additional CSS

    Load these styles after your Bootstrap CSS and any plugin CSS required. They really should be the last stylesheet loaded:
    
        .media-body p{
          min-height: 60px;
          background-color: #f0f0f0;
          padding: 5px 10px;
        }
        
        span.desc{
          background-color: #cecece;
          bottom: -5px;
          color: #fff;
          left: 0;
          position: relative;
          width: 80px;
          overflow: visible;
          text-align: center;
          height: 44px;
          display: table-cell;
          vertical-align: middle;
          -webkit-border-bottom-left-radius: 10px;
          -moz-border-radius-bottomleft: 10px;
          border-bottom-left-radius: 10px;
        }
        
        span.author_name{
          color: #fff;
          font-size: .8em;
        }
        
        #post-a-comment {
          margin-bottom:20px;
        }
                    
  2. Optional Additional CSS

    These styles are optional and for the buttons in the comment section as well as the list styles for the Preview Popver, Latest Articles and Latest Comments listed in the Sidebar. I included them only for completeness.
    
        /* -------------- POPOVER STYLES ----------------- */
        .popover {
          width:100%;
        }
        .popover-title {
          font-weight: 700;
          color: #fff;
          background-color: #000;
        }
        
        .popover-content {
          font-size: .9em
        }
    
        /* -------------- OPTIONAL BUTTONS ----------------- */
        .btn {
            -webkit-transition: all 0.4s;
            transition: all 0.4s;
            border-radius: 0;
            font-family: 'open_sanslight', sans-serif;
        }
        
        .btn-outline-inverse {
          color: #000;
          background-color: transparent;
          border-color: #000 !important;
          text-decoration:none !important;
        }
        .btn-outline-inverse:hover,
        .btn-outline-inverse:focus,
        .btn-outline-inverse:active {
          color: #fff;
          text-shadow: none;
          background-color: #000;
          border-color: #000;
          text-decoration:none;
        }
        .btn-outline-inverse:hover a,
        .btn-outline-inverse:focus a,
        .btn-outline-inverse:active a {
           text-decoration:none !important;
        }
        
        .btn-outline {
          color: #000;
          background-color: transparent;
          border-color: #000 !important;
          text-decoration:none !important;
        }
        .btn-outline:hover,
        .btn-outline:focus,
        .btn-outline:active {
          color: #fff;
          text-shadow: none;
          background-color: #000;
          border-color: #000;
          text-decoration:none;
        }
        .btn-outline:hover a,
        .btn-outline:focus a,
        .btn-outline:active a {
           text-decoration:none !important;
        }
        
        /* -------------- OPTIONAL LAYOUT FOR SIDEBAR ----------------- */
        .icon-list li {
          padding: 0 0 15px 30px;
          display: block;
          position: relative;
        }
        .icon-list li:before {
          font-family: 'Glyphicons Halflings';
          position: absolute;
          left: 0px;
          top: -2px;
          font-size: 120%;
        }
        .comment li:before {
         content: '\e111';
        }
        
        .article li:before {
         content: '\e055';
        }
                    

Comments (0)




Allowed tags: <b><i><br>