{distill} Comments
Following on from a post on including a contact form on a {distill} site hosted on RStudio Connect, here's a post on how to include comments in blog posts.
Commenting consists of two parts - a way to retrieve comments that belong to a page and a form to enter new comments.
In this implementation I connect a {distill} blog to a {pins} data frame via a {plumber} API. The pinned data frame holds the comments and comments can be added or retrieved through the API. The distill blog posts call javascript functions to post and retrieve comments. The blog, pin board and plumber API all sit on the same RStudio Connect instance.
New Comment Form
The function comment_form
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
and page_id
to a plumber API. The plumber API updates a pinned data frame 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
function adds a div with the id rtncomments
which is a placeholder to display comments.
The comment_form
R function along with the javascript eventListener are shown below. In the code,
1library(htmltools)
2
3comment_form <- 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" 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
30 <span class="button-container">
31 <input type="submit" value="Comment">
32 </span>
33 </div>
34 </form>
35 </div>
36 <div id="rtncomments">
37 </div>
38 </div>
39 ')
40 htmltools::HTML(comment_html)
41}
1window.addEventListener("load", function() {
2
3 document.getElementById("my-form").addEventListener("submit", formsubmit);
4
5 async function formsubmit(e) {
6
7 e.preventDefault();
8
9 // get event-handler element
10 const form = e.currentTarget;
11
12 // get form url
13 const url = form.action;
14
15 // get form data as json string
16 formdata = new FormData(form);
17 const plainFormData = Object.fromEntries(formdata.entries());
18 const jsonFormData = JSON.stringify(plainFormData);
19
20 // send request and capture output
21 out = await fetch(form.action, {
22 method: 'POST',
23 body: jsonFormData,
24 headers: {
25 "Content-Type": "application/json",
26 "Accept": "application/json"
27 }
28 })
29 .then(response => response.json());
30
31 // update comments
32 update_comments(plainFormData.page_id, plainFormData.site_id);
33
34 };
35
36});
Existing Comments
To retrieve existing comments we use a javascript function to build an HTML output. The function takes site_id
and page_id
arguments and calls a plumber API which returns comments belonging to the page in json form.
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.
Once the json-formatted response is returned, the function loops through the comments building an HTML response and updates #rtncomments
. If a comment does not have an author it defaults to anonymous user.
1function update_comments(page_id, site_id) {
2
3 const url = "<rsconnect URL>/page_comments?"
4
5 fetch(url + new URLSearchParams({
6 site: site_id,
7 page: page_id,
8 }))
9 .then(response => response.json())
10 .then(data => {
11
12 // outer_div - placeholder for comments
13 div_outer = $('<div/>').attr('id', 'div_outer');
14
15 // add comments count
16 div_outer.append('<h3>' + data.length + ' Comments</h3>');
17
18 // loop through returned comments, adding each one to an unordered list
19 ul_list_comments = $('<ul/>', {id: 'list_comments', class: 'comment-list'});
20
21 $.each(data, function(i, obj) {
22
23 user_name = obj.user_name == "null" ? "anonymous user" : obj.user_name
24
25 ul_list_comments.append(
26 $('<li/>', {class: 'comment-item'}).append([
27 $('<div/>', {class: 'comment-top'}).append([
28 $('<h3/>', {class: 'comment-name', text: user_name}),
29 $('<span/>', {class: 'date-holder'}).append([
30 $('<i/>', {class: 'far fa-clock'}),
31 $('<h3/>', {class: 'comment-date', text: obj.date})
32 ])
33 ]),
34 $('<p/>', {class: 'comment-text', text: obj.comment})
35 ])
36 );
37 });
38
39 div_outer.append(ul_list_comments);
40
41 $("#rtncomments").html(div_outer);
42
43 })
44 .catch((err) => console.log("Can’t access " + url + " response. Blocked by browser?" + err));
45
46};
plumber API
The {distill} blog pages are connected to the comments via a plumber API. The API contains two endpoints, a POST endpoint, addcomment
which is used to add a new comment and a GET endpoint, page_comments
which is used to retrieve comments for a specific page. The comments themselves are stored in a data frame which is accessible via the {pins} package. This allows mutiple sites to use the same data frame.
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
. Once again,
blog_comment_table
is a data frame with columns for site_id, page_id, user_id, date and comment. The date is a timestamp set when the comment is added.
1library(plumber)
2library(jsonlite)
3library(pins)
4library(tibble)
5library(lubridate)
6library(dplyr)
7
8#* @apiTitle Comments
9
10#* Add a comment to the table
11#* @param req request body
12#* @post /addcomment
13function(req) {
14
15 ## get the message body
16 body <- jsonlite::fromJSON(req$postBody)
17
18 ## RSConnect API Key
19 connectAPIKey <- Sys.getenv("CONNECT_API_KEY")
20
21 ## register rsconnect pin board
22 board_register("rsconnect",
23 server = "<rsconnect URL>,
24 account = "<account id>",
25 key = connectAPIKey)
26
27 ## check for comments table and create if not present
28 if (nrow(pins::pin_find("blog_comment_table", board = "rsconnect")) == 0) {
29 comments <- tibble(
30 site_id = body$site_id,
31 page_id = body$page_id,
32 user_id = body$user_id,
33 date = lubridate::now(),
34 comment = body$comment
35 )
36 } else {
37 comments <- pins::pin_get(name = "blog_comment_table", board = "rsconnect") %>%
38 add_row(
39 site_id = body$site_id,
40 page_id = body$page_id,
41 user_id = body$user_id,
42 date = lubridate::now(),
43 comment = body$comment
44 )
45 }
46 pins::pin(comments, name = "blog_comment_table", board = "rsconnect")
47
48}
49
50#* Retrieve all comments for a page
51#* @param site site id
52#* @param page page id
53#* @serializer unboxedJSON
54#* @get /page_comments
55function(site = 0, page = 0) {
56
57 ## RSConnect API Key
58 connectAPIKey <- Sys.getenv("CONNECT_API_KEY")
59
60 ## register rsconnect pin board
61 board_register("rsconnect",
62 server = "<rsconnect URL>,
63 account = "<account id>",
64 key = connectAPIKey)
65
66 ## get table and filter
67 pins::pin_get(name = "blog_comment_table", board = "rsconnect") %>%
68 dplyr::filter(site_id == site & page_id == page) %>%
69 dplyr::arrange(desc(date))
70
71}
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
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: "test article number 1"
3 description: |
4 A first test article with comments.
5 author:
6 - name: Harvey Lieberman
7 date: 01-10-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 page_id <- 1
20 site_id <- 101
21 source(here::here("comment.R"))
22 htmltools::includeCSS(here::here("style.css"))
23 htmltools::includeScript(here::here("comments.js"))
24 ```
25
26 This is a first blog post with comments.
27
28 ```{r}
29 comment_form(page_id = page_id, site_id = site_id)
30 ```
31
32 <script>
33 update_comments(page_id = `r page_id`, site_id = `r site_id`)
34 </script>
35
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-contents {
7 padding: 10px;
8 margin: 10px;
9 display: flex;
10 flex-direction: row;
11 align-items: center;
12}
13
14.form-details {
15 display: flex;
16 flex-direction: column;
17 flex: 2 1 auto;
18}
19
20.form-details input[type=text] {
21 border-top: 0px;
22 border-bottom: 1px solid #ccc;
23 border-left: 0px;
24 border-right: 0px;
25 outline: 0px;
26 padding: 0;
27 margin-top: 20px;
28 margin-left: 20px;
29 font-weight: normal;
30}
31
32.form-details input[type=text]:focus {
33 border-color: #04AA6D;
34 border-width: 2px;
35}
36
37.form-contents .comment-pic {
38 display: flex;
39 font-size: 3em;
40 align-self: flex-end;
41}
42
43.button-container {
44 display: flex;
45 align-self: flex-end;
46}
47
48.comment-comments input[type=text]{
49 width: 90%;
50}
51
52.comment-short {
53 width: 50%;
54}
55
56.comment-short input[type=text]{
57 width: 80%;
58}
59
60.comment-user {
61 display: flex;
62 flex-direction: row;
63}
64
65.form-container input[type=submit] {
66 background-color: #04AA6D;
67 color: white;
68 padding: 12px 20px;
69 border: none;
70 border-radius: 4px;
71 cursor: pointer;
72}
73
74.button-container input[type=submit] {
75 margin: 2px 5px;
76 float: right;
77}
78
79.form-container input[type=submit]:hover {
80 background-color: #45a049;
81}
82
83.comment-header {
84 font-size: 1.5em;
85 line-height: 1.5em;
86 font-weight: 400;
87}
88
89.comment-holder {
90 margin-top: 50px;
91}
92
93ul.comment-list {
94 list-style: none;
95 position: relative;
96 padding: 0;
97}
98
99li.comment-item {
100 padding: 20px 10px;
101 margin: 20px 0;
102 position: relative;
103 width: 100%;
104 background-color: #efefef;
105}
106
107.comment-top {
108 display: flex;
109 flex-direction: row;
110 justify-content: space-between;
111}
112
113.comment-name {
114 font-size: 1.5em;
115 font-weight: 400;
116 margin: 5px 0;
117 color: #5d5d5d;
118 align-self: flex-start;
119}
120
121.date-holder {
122 color: #5d5d5d;
123 align-self: flex-end;
124 display: inline-flex;
125 align-items: baseline;
126}
127
128.comment-date {
129 font-size: 1em;
130 font-weight: 400;
131 margin: 5px 0 5px 10px;
132}
133
134.comment-text {
135 display: block;
136 margin: 0 0 10px 0;
137}
Output
After adding a couple of sample blog posts and a few comments the output is shown below. Here, I've added two comments to the first blog post and one to the second. The data frame retrieved from {pins} appears as follows:
site_id | page_id | user_name | date | comment |
---|---|---|---|---|
<chr> | <chr> | <chr> | <dttm> | <chr> |
101 | 1 | Harvey | 2022-01-10 22:24:00 | This is my first blog comment! |
101 | 1 | Harvey | 2022-01-10 22:24:34 | Blog comments can take a little time to appear once entered - possibly an artefact of RStudio Connect or {pins} |
101 | 2 | Harvey | 2022-01-10 22:25:17 | Here's a comment for blog entry #2 |
Issues to Resolve
The refresh process is a little slow, sometimes taking a several seconds to load comments. At this point I'm not sure if it is related to the use of fetch or plumber.
This is a first proof-of-concept and certainly requires some more work but the principle works well.