{distill} Comments With Replies
This post expands upon the post on {distill} Comments. It includes a method to reply to comments and store comments and replies in an hierarchical manner.
In the previous post I covered how we could use RStudio Connect to manage commenting on a static blog. Here we extend it, adding a way to reply to comments and store comments plus replies in a hiersrchical data structure.
The concept is essentially the same as the earlier version: a {distill} blog is connected to a {pins} data source via plumber. Here, however, the data source is a data.tree as opposed to a data frame. data.tree is an R package that manages hierarchical data and tree structures. Page comments with replies lends itself nicely to a hierarchical data structure where each node is a comment or reply to a comment. The pinned data.tree holds the comments and replies which can be added or retrieved through the API. Comments are retrieved through javascript functions in the distill blog. The blog, pin board and plumber API all sit on the same RStudio Connect instance.
New Comment Form
New comment form is very similar to the original version. The function comment_form_dt
takes site_id
and page_id
arguments and returns an HTML form. site_id
is a unique identifier for a website and page_id
is a unique identifier for a page on that site.
The form captures a comment and optional user name and passes each of these, along with site_id
, page_id
and parent_ref
to a plumber API. Each comment or reply is given a unique reference number and parent_ref
is the reference number of the parent. For page comments parent_ref
is simply the page_id
but for replies parent_ref
is the reference to a comment or a reply. The plumber API updates a pinned data.tree with the new comment. In fact, a javascript function intercepts the submit button triggering an update of the page comments after adding the new one. This allows a new comment to be added without having to refresh the page manually.
In addition, the comment_form_dt
function adds a div with the id rtncomments
which is a placeholder to display comments.
The comment_form_dt
R function along with the javascript eventListener are shown below. In the code,
The javascript function formsubmit is essentially the same as the earlier function.
1library(htmltools)
2
3comment_form_dt <- function(page_id = 0, site_id = 0) {
4
5 comment_html <- paste0('
6 <div class="comments">
7 <div class="form-container">
8 <h3 class="comment-header">Post a Comment</h3>
9 <form action="<rsconnect URL>/addcomment_dt" id="my-form">
10
11 <div class="form-contents">
12 <span class="comment-pic">
13 <i class="far fa-user"></i>
14 </span>
15
16 <div class="form-details">
17 <div class="comment-comments">
18 <input type="text" id="comment" name="comment" placeholder="Your comment"></textarea>
19 </div>
20 <div class="comment-user">
21 <span class="comment-short">
22 <input type="text" id="user_name" name="user_name" placeholder="Your name (optional)" />
23 </span>
24 </div>
25 </div>
26
27 <input type="hidden" name="site_id" value="', site_id, '" />
28 <input type="hidden" name="page_id" value="', page_id, '" />
29 <input type="hidden" name="parent_ref" value="', page_id, '" />
30
31 <span class="button-container">
32 <input type="submit" value="Comment">
33 </span>
34 </div>
35 </form>
36 </div>
37 <div id="rtncomments">
38 </div>
39 </div>
40 ')
41 htmltools::HTML(comment_html)
42}
1window.addEventListener("load", function() {
2 // add eventlistener to new comment submit button
3 document.getElementById("my-form").addEventListener("submit", formsubmit);
4});
5
6// Intercept submit button and run fetch code
7async function formsubmit(e) {
8
9 e.preventDefault();
10
11 // get event-handler element
12 const form = e.currentTarget;
13
14 // get form url
15 const url = form.action;
16
17 // get form data as json string
18 formdata = new FormData(form);
19 const plainFormData = Object.fromEntries(formdata.entries());
20 const jsonFormData = JSON.stringify(plainFormData);
21
22 // send request and capture output
23 out = await fetch(url, {
24 method: 'POST',
25 body: jsonFormData,
26 headers: {
27 "Content-Type": "application/json",
28 "Accept": "application/json"
29 }
30 })
31 .then(response => response.json());
32
33 // update comments
34 update_comments_dt(plainFormData.page_id, plainFormData.site_id);
35
36};
37
Existing Comments
Retrieving existing comments introduces a new function to build replies and a reply box for each comment/reply. The main function takes site_id
and page_id
arguments and calls a plumber API which returns comments belonging to the page in json form. A recursive function then builds comments and any replies, terminating each tree branch with a reply box.
Here, site_id
and page_id
are appended to the url so that we can limit the returning data to a specific page on a specific site. Since we are using fetch, the webpage and API must live on the same RStudio Connect instance.
1// build and populate comment reply box
2function reply_comment_box(page_id, site_id, parent_ref) {
3 var out = $('<div/>', {class: 'form-container'}).append([
4 $('<h5/>', {class: 'comment-header comment-header-margin-narrow', text: 'Post a reply'}),
5 $('<form/>', {action: 'https://rsconnect-prod.dit.eu.novartis.net/content/1200/addcomment_dt', method: 'POST', class: 'reply-form'}).append([
6 $('<div/>', {class: 'form-contents'}).append(
7 $('<span/>', {class: 'comment-pic'}).append($('<i/>', {class: 'far fa-user'})),
8 $('<div/>', {class: 'form-details'}).append(
9 $('<div/>', {class: 'comment-comments'}).append(
10 $('<input/>', {type: 'text', name: 'comment', placeholder: 'Your reply'})
11 ),
12 $('<div/>', {class: 'comment-user'}).append(
13 $('<span/>', {class: 'comment-short'}).append(
14 $('<input/>', {type: 'text', name: 'user_name', placeholder: 'Your name (optional)'})
15 )
16 )
17 ),
18 $('<input/>', {type: 'hidden', name: 'site_id', value: site_id}),
19 $('<input/>', {type: 'hidden', name: 'page_id', value: page_id}),
20 $('<input/>', {type: 'hidden', name: 'parent_ref', value: parent_ref}),
21 $('<span/>', {class: 'button-container'}).append(
22 $('<input/>', {type: 'submit', value: 'submit'})
23 )
24 )
25
26 ])
27 ])
28 return(out)
29};
30
31
32// update comments on the page
33function update_comments_dt(page_id, site_id) {
34
35 const url = "<rsconnect URL>/page_comments_dt?"
36
37 fetch(url + new URLSearchParams({
38 site: site_id,
39 page: page_id,
40 }))
41 .then(response => response.json())
42 .then(data => {
43
44 // recursive function to print comments
45 function comment_recurse(d) {
46 if (d.hasOwnProperty('children')) {
47 const ul_list_comments = $('<ul/>', {class: 'comment-list'});
48
49 // loop over children (replies) and populate
50 $.each(d.children, function(i, x) {
51 user_name = x.user_name == "null" ? "anonymous user" : x.user_name;
52 style_txt = 'margin-left: 20px;'
53 ul_list_comments.append(
54 $('<li/>', {class: 'comment-item', style: style_txt}).append([
55 $('<div/>', {class: 'comment-top'}).append([
56 $('<h3/>', {class: 'comment-name', text: user_name}),
57 $('<span/>', {class: 'date-holder'}).append([
58 $('<i/>', {class: 'far fa-clock'}),
59 $('<h3/>', {class: 'comment-date', text: x.date})
60 ])
61 ]),
62 $('<p/>', {class: 'comment-text', text: x.comment}),
63 $('<details/>').append([
64 $('<summary/>', {class: 'text-reply', text: 'reply'}),
65 reply_comment_box(x.page_id, x.site_id, x.ref)
66 ]),
67 comment_recurse(x)
68 ]),
69
70 );
71
72 });
73 return(ul_list_comments)
74 } else {
75 return(null)
76 }
77
78 }
79
80 // outer_div - placeholder for comments
81 div_outer = $('<div/>').attr('id', 'div_outer');
82
83 // add comments if exist
84 if (data.children) {
85 // add comments count
86 div_outer.append('<h3>' + data.children.length + ' Comments</h3>');
87
88 // recursively loop through returned comments, building unordered lists
89 ul_list_comments = comment_recurse(data);
90
91 // add comments to outer div
92 div_outer.append(ul_list_comments);
93 }
94
95 // update comment holder
96 $("#rtncomments").html(div_outer);
97
98 // add event listener to class
99 const reply_forms = document.querySelectorAll('.reply-form');
100 reply_forms.forEach(item => item.addEventListener('submit', formsubmit));
101
102 })
103 .catch((err) => console.log("Can’t access " + url + " response. Blocked by browser?" + err));
104
105};
plumber API
As previously, the distill blog pages via a plumber API. The API contains two endpoints, a POST endpoint, addcomment_dt
which is used to add a new comment and a GET endpoint, page_comments_dt
which is used to retrieve comments for a specific page. The comments are stored in a hierarchical data.tree format which is accessible via the {pins} package.
In the code below, board_register("rsconnect", server = "<rsconnect URL>, account = "<account id>", key = connectAPIKey)
registers a pin board which holds a pin called blog_comment_table+dt
.
addcomment_dt
adds the comment to a parent id
which sits under a page id
, that is, in turn, under a site id
. Each comment is given a unique reference id, used as an identifier when comments or replies are added.
page_comments_dt
retrieves a hierarchy of comments and replies for a specified site id
and page id
. The data.tree obtained is returned as a list.
1library(plumber)
2library(jsonlite)
3library(pins)
4library(lubridate)
5library(data.tree)
6library(stringi)
7
8#* Add a comment to the comment table
9#*
10#* @param req request body
11#*
12#* @serializer unboxedJSON
13#*
14#* @post /addcomment_dt
15function(req) {
16
17 ## get the message body
18 body <- jsonlite::fromJSON(req$postBody)
19
20 ## RSConnect API Key
21 connectAPIKey <- Sys.getenv("CONNECT_API_KEY")
22
23 ## register rsconnect pin board
24 board_register("rsconnect",
25 server = "<rsconnect URL>",
26 account = "<account id>",
27 key = connectAPIKey)
28
29 ## generate a ref for the comment
30 comment_ref <- stringi::stri_rand_strings(n = 1, length = 12)
31
32 comment <- c(
33 body,
34 list(
35 ref = comment_ref,
36 date = lubridate::now()
37 )
38 )
39
40 ## check for comments table and create if not present
41 if (nrow(pins::pin_find("blog_comment_table_dt", board = "rsconnect")) == 0) {
42 comment_tree <- Node$new("comments")
43 } else {
44 comment_tree <- pins::pin_get(name = "blog_comment_table_dt", board = "rsconnect")
45 }
46
47 ## does site_id child node exist?
48 if (is.null(FindNode(comment_tree, comment$site_id))) {
49 comment_tree$AddChild(comment$site_id)
50 }
51
52 ## does page_id child node exist?
53 site_node <- FindNode(comment_tree, comment$site_id)
54 if (is.null(FindNode(site_node, comment$page_id))) {
55 site_node$AddChild(comment$page_id)
56 }
57
58 ## add new comment
59 if (!is.na(comment$parent_ref)) {
60 parent_node <- FindNode(site_node, comment$parent_ref)
61 } else {
62 parent_node <- FindNode(site_node, comment$page_id)
63 }
64 do.call(parent_node$AddChild, c(list(name = comment$ref), comment))
65
66 pins::pin(comment_tree, name = "blog_comment_table_dt", board = "rsconnect")
67
68 return(comment)
69}
70
71
72#* Retrieve all comments for a page
73#*
74#* @param site site id
75#* @param page page id
76#*
77#* @serializer unboxedJSON
78#*
79#* @get /page_comments_dt
80function(site = "site_01", page = "page_01") {
81
82 ## RSConnect API Key
83 connectAPIKey <- Sys.getenv("CONNECT_API_KEY")
84
85 ## register rsconnect pin board
86 board_register("rsconnect",
87 server = "https://rsconnect-prod.dit.eu.novartis.net",
88 account = "liebeha1",
89 key = connectAPIKey)
90
91 ## get table and filter
92 rtn_subtree <- list()
93 if (nrow(pins::pin_find("blog_comment_table_dt", board = "rsconnect")) > 0) {
94
95 ## get pinned comment tree
96 comment_tree <- pins::pin_get(name = "blog_comment_table_dt", board = "rsconnect")
97
98 ## is site in comment tree?
99 if (!is.null(FindNode(comment_tree, site))) {
100
101 ## is page in comment tree and does it have comments?
102 found_page_comments <- FindNode(comment_tree[[site]], page)
103 if (!is.null(found_page_comments)) {
104 rtn_subtree <- as.list(found_page_comments,
105 mode = "explicit", unname = TRUE)
106 }
107
108 }
109 }
110 return(rtn_subtree)
111}
Webpage / Blog Post with Comments
Any page with comments follows the same approach. The page includes the javascript functions listed above (comments.js), some css styling (style.css, see below) and the comment_form
function (sourced from comment.R).
There are a few things to note in the code below.
- The two variables,
site_id
andpage_id
, are needed to identify comments for the webpage. Ideally, we'd define them in the yaml header and use them as parameters in the markdown text. Unfortunately, when usingrender_site
, markdown parameters are not rendered (see open GitHub issue).site_id
andpage_id
are therefore defined within a chunk. - The javascript function
update_comments_dt
does not sit in a javascript chunk (you can include javascript in rmarkdown by including a chunk with js instead of r in the chunk header). Instead, the code is placed directly within a<script>
tag. When processed this way, we can access variables (site_id
andpage_id
) stored in r language chunks earlier in the document.
1 ---
2 title: "article 1"
3 description: |
4 Blog post #1.
5 author:
6 - name: Harvey Lieberman
7 date: 03-03-2022
8 output:
9 distill::distill_article:
10 self_contained: false
11 ---
12
13
14 ```{r setup, include=FALSE}
15 knitr::opts_chunk$set(echo = FALSE)
16 ```
17
18 ```{r}
19 ## define site and page
20 page_id <- "page_01"
21 site_id <- "site_02"
22
23 ## add function, css and js to page
24 source(here::here("comment_dt.R"))
25 htmltools::includeCSS(here::here("style.css"))
26 htmltools::includeScript(here::here("comment_dt.js"))
27 ```
28
29 This is a typical blog post but with a comment section added.
30 Comments include nested replies.
31
32
33 ```{r}
34 ## include comment form
35 comment_form_dt(page_id = page_id, site_id = site_id)
36
37 ## js below placed in script tags so that R variable can be included
38 ```
39
40 <script>
41 update_comments_dt(page_id = "`r page_id`", site_id = "`r site_id`")
42 </script>
43
css
The style.css file takes care of styling comments. The file is included below.
1.comments {
2 padding: 20px 10px;
3 margin: 0;
4}
5
6.form-container input[type=submit] {
7 background-color: #04AA6D;
8 color: white;
9 padding: 12px 20px;
10 border: none;
11 border-radius: 4px;
12 cursor: pointer;
13}
14
15.form-container input[type=submit]:hover {
16 background-color: #45a049;
17}
18
19.comment-header {
20 font-size: 1.5em;
21 line-height: 1.5em;
22 font-weight: 400;
23 margin-block-start: 1.5em;
24 margin-block-end: 1.5em;
25}
26
27.comment-header-margin-narrow {
28 margin-block-start: 0.5em;
29 margin-block-end: 0.5em;
30}
31
32.form-contents {
33 padding: 10px;
34 margin: 10px;
35 display: flex;
36 flex-direction: row;
37 align-items: center;
38}
39
40.form-contents .comment-pic {
41 display: flex;
42 font-size: 3em;
43 align-self: flex-end;
44}
45
46.form-details {
47 display: flex;
48 flex-direction: column;
49 flex: 2 1 auto;
50}
51
52.form-details input[type=text] {
53 border-top: 0px;
54 border-bottom: 1px solid #ccc;
55 border-left: 0px;
56 border-right: 0px;
57 outline: 0px;
58 padding: 0;
59 margin-top: 20px;
60 margin-left: 20px;
61 font-weight: normal;
62}
63
64.form-details input[type=text]:focus {
65 border-color: #04AA6D;
66 border-width: 2px;
67}
68
69.comment-comments input[type=text]{
70 width: 90%;
71}
72
73.comment-user {
74 display: flex;
75 flex-direction: row;
76}
77
78.comment-short {
79 width: 50%;
80}
81
82.comment-short input[type=text]{
83 width: 80%;
84}
85
86.button-container {
87 display: flex;
88 align-self: flex-end;
89}
90
91.button-container input[type=submit] {
92 margin: 2px 5px;
93 float: right;
94}
95
96.comment-holder {
97 margin-top: 50px;
98}
99
100ul.comment-list {
101 list-style: none;
102 position: relative;
103 padding: 0;
104 border: 1px solid #ccc;
105}
106
107li.comment-item {
108 padding: 20px 10px;
109 margin: 20px 0;
110 position: relative;
111}
112
113.comment-top {
114 display: flex;
115 flex-direction: row;
116 justify-content: space-between;
117}
118
119.comment-name {
120 font-size: 1.5em;
121 font-weight: 400;
122 margin: 5px 0;
123 color: #5d5d5d;
124 align-self: flex-start;
125}
126
127.date-holder {
128 color: #5d5d5d;
129 align-self: flex-end;
130 display: inline-flex;
131 align-items: baseline;
132}
133
134.comment-date {
135 font-size: 1em;
136 font-weight: 400;
137 margin: 5px 0 5px 10px;
138}
139
140.comment-text {
141 display: block;
142 margin: 0 0 10px 0;
143}
Output
The follow screen captures illustrate adding comments and replies.
First comment add to a blog post
First comment add to a blog post
Adding a reply added to comment #1
Clicking on the Reply dropdown opens a reply window
Once the reply is added it also includes a dropdown for nesting replies
Reply for comment #2 dropdown opened
Conclusion
RStudio Connect can be used with {pins} to hold nested comments for blog pages. This demonstrates the huge scope that RStudio Connect can play as a CMS.