This blogpost will cover how to solve a fairly common ask, how to add a symbol/character to the end of ONLY the first row of a column and maintain the alignment of the entire column. We’ll walk through how to accomplish this with gt only, creating our own function to do it more succinctly, and then how to further test our gt outputs with testthat!
No repeats
I’ve always been a fan of not having to repeat symbols/prefixes/suffixes inside tables. There’s some ongoing work here in gt to add this as a feature, but in the meantime I wanted to play around with a few ways to accomplish this with gt as it is, and/or a custom function as of today.
You can imagine a situation like below, where we want to label cells within a column as a percent, and want to indicate that it’s a percent ONLY on the first row.
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
mfr
model
year
trim
hp
hp_pct
Ford
GT
2017
Base Coupe
647
97.88%
Ferrari
458 Speciale
2015
Base Coupe
597
90.32
Ferrari
458 Spider
2015
Base
562
85.02
Ferrari
458 Italia
2014
Base Coupe
562
85.02
Ferrari
488 GTB
2016
Base Coupe
661
100.00
Ferrari
California
2015
Base Convertible
553
83.66
However, you can quickly see that this misaligned the first row from the remaining rows.
No repeats in gt
An alternative would be to convert those rows to text and apply specific changes.
There’s quite a bit going on here:
Must use a mono space font for the column of interest
Must be mono-spaced so that everything aligns properly
Align the now text column to be right-aligned
Align to right, so again the decimal places align (text default aligns to left otherwise)
Must use " ", which is the HTML code for nonbreaking space, as a raw space (eg " ") will not work
I want to pause here and say with the code below, we have officially accomplished our goal. However, this was fairly manual and can be repetitive for adding several of these transformations in a single table.
head(gtcars)%>%mutate(hp_pct =(hp/max(hp)*100))%>%dplyr::select(mfr, model, year, trim, hp, hp_pct)%>%gt()%>%# use a mono-spaced fonttab_style(
style =cell_text(font =google_font("Fira Mono")),
locations =cells_body(columns =vars(hp_pct)))%>%# align the column of interst to rightcols_align(align ="right", columns =vars(hp_pct))%>%# round and transform the first row to percenttext_transform(
locations =cells_body(vars(hp_pct), rows =1),
fn =function(x){fmt_val<-format(as.double(x), nsmall =1, digits =1)paste0(fmt_val, "%")%>%gt::html()})%>%text_transform(
locations =cells_body(vars(hp_pct), rows =2:6),
fn =function(x){# round remaining rows, add a non-breaking spacefmt_val<-format(as.double(x), nsmall =1, digits =1)lapply(fmt_val, function(x)paste0(x, ' ')%>%gt::html())})
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
mfr
model
year
trim
hp
hp_pct
Ford
GT
2017
Base Coupe
647
97.9%
Ferrari
458 Speciale
2015
Base Coupe
597
90.3 
Ferrari
458 Spider
2015
Base
562
85.0 
Ferrari
458 Italia
2014
Base Coupe
562
85.0 
Ferrari
488 GTB
2016
Base Coupe
661
100.0 
Ferrari
California
2015
Base Convertible
553
83.7 
Format symbol first Function
We can try to wrap some of the gt code into a function and apply these transformations in bulk at the location of our choosing! This is especially important for making it generally apply to other types of inputs instead of JUST %. The function of interest is actually two custom functions, some gt functions, and a good chunk of logic.
I’ve commented the individual sections as to their purpose, and included quite a bit of error-handling or protecting against various user inputs.
fmt_symbol_first<-function(gt_data,
column=NULL, # column of interest to apply tosymbol=NULL, # symbol to add, optionallysuffix="", # suffix to add, optionallydecimals=NULL, # number of decimal places to round tolast_row_n, # what's the last row in data?symbol_first=FALSE# symbol before or after suffix?){# Test and error out if mandatory columns are missingstopifnot("`symbol_first` argument must be a logical"=is.logical(symbol_first))stopifnot("`last_row_n` argument must be specified and numeric"=is.numeric(last_row_n))stopifnot("Input must be a gt table"=class(gt_data)[[1]]=="gt_tbl")# needs to type convert to double to play nicely with decimals and rounding# as it's converted to character by gt::text_transformadd_to_first<-function(x, suff=suffix, symb=symbol){if(!is.null(decimals)){x<-suppressWarnings(as.double(x))fmt_val<-format(x =x, nsmall =decimals, digits =decimals)}else{fmt_val<-x}# combine the value, passed suffix, symbol -> htmlif(isTRUE(symbol_first)){paste0(fmt_val, symb, suff)%>%gt::html()}else{paste0(fmt_val, suff, symb)%>%gt::html()}}# repeat non-breaking space for combined length of suffix + symbol# logic is based on is a NULL passed or notif(!is.null(symbol)|!identical(as.character(symbol), character(0))){suffix<-ifelse(identical(as.character(suffix), character(0)), "", suffix)length_nbsp<-c(" ", rep(" ", nchar(suffix)))%>%paste0(collapse ="")}else{suffix<-ifelse(identical(as.character(suffix), character(0)), "", suffix)length_nbsp<-rep(" ", nchar(suffix))%>%paste0(collapse ="")}# affect rows OTHER than the first rowadd_to_remainder<-function(x, length=length_nbsp){if(!is.null(decimals)){# if decimal not null, convert to doublex<-suppressWarnings(as.double(x))# then round and format ALL to force specific decimalsfmt_val<-format(x =x, nsmall =decimals, digits =decimals)}else{fmt_val<-x}paste0(fmt_val, length)%>%lapply(FUN =gt::html)}# pass gt object# align right to make sure the spacing is meaningfulgt_data%>%cols_align(align ="right", columns =vars({{column}}))%>%# convert to mono-font for column of interesttab_style(
style =cell_text(font =google_font("Fira Mono")),
locations =cells_body(columns =vars({{column}})))%>%# transform first rowstext_transform(
locations =cells_body(vars({{column}}), rows =1),
fn =add_to_first)%>%# transform remaining rowstext_transform(
locations =cells_body(vars({{column}}), rows =2:last_row_n),
fn =add_to_remainder)}
Use the function
We can now use that fmt_symbol_first() function, note that I’m testing a few different combinations of suffix/symbols, decimals, etc that may be a bit nonsensical in the table itself but are interactively testing that the results are what I expect. Specifically, I’m making sure that symbols/suffixes are added, and that the spacing is correct. While this is useful for sanity checking quickly, we can also take another step to apply some proper unit-testing in the next section.
gtcars%>%head()%>%dplyr::select(mfr, year, bdy_style, mpg_h, hp)%>%dplyr::mutate(mpg_h =rnorm(n =dplyr::n(), mean =22, sd =1))%>%gt()%>%opt_table_lines()%>%fmt_symbol_first(column =mfr, symbol ="$", suffix =" ", last_row_n =6)%>%fmt_symbol_first(column =year, symbol =NULL, suffix ="%", last_row_n =6)%>%fmt_symbol_first(column =mpg_h, symbol ="%", suffix =NULL, last_row_n =6, decimals =1)%>%fmt_symbol_first(column =hp, symbol ="°", suffix ="F", last_row_n =6, decimals =NULL, symbol_first =TRUE)
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
mfr
year
bdy_style
mpg_h
hp
Ford $
2017%
coupe
23.4%
647°F
Ferrari  
2015 
coupe
22.1 
597  
Ferrari  
2015 
convertible
21.4 
562  
Ferrari  
2014 
coupe
23.7 
562  
Ferrari  
2016 
coupe
23.1 
661  
Ferrari  
2015 
convertible
22.3 
553  
Unit testing
At this point, we’ve created a custom gt wrapper function, added some relatively robust checks into the function, but are still manually checking the output confirms to our expectations. We can perform proper unit testing with the {testthat} package.
Testing your code can be painful and tedious, but it greatly increases the quality of your code. testthat tries to make testing as fun as possible, so that you get a visceral satisfaction from writing tests.
While an in-depth run through of testhat is beyond the scope of this post, I have included an expandable section with a minimal example below, expanded from the “R Packages” book chapter on testing:
So str_length() counts the length of a string, fairly straightforward!
We can convert this to a logical confirmation, which means that a computer can understand if the output was as expected, rather than just printing and reading which is mainly for our interactive use. I have included one FALSE output just as an example.
While this testing is useful, we can make it even easier with testhat, by using expect_equal(). Now, these functions will not return anything if they pass. If they fail, then they will print an error, and a helpful statement saying what the failure was.
Error: str_length("a") not equal to 3.
1/1 mismatches
[1] 1 - 3 == -2
The last step, is wrapping our various tests into test_that structure. Here, while the individual tests return no visible output, we can get a friendly message saying they have all passed!
── Failure (<text>:7:5): str_length is number of characters ────────────────────
str_length("abcd") not equal to 3.
1/1 mismatches
[1] 4 - 3 == 1
Error:
! Test failed
These tests can be used interactively, but ultimately are even more useful when rolled into an R package. For that next step, I recommend reading through the “R Packages” book, specifically the Packages Chapter.
Testing gt
Now you may say, well those minimal example tests were easy, it’s just counting?! How do I test gt? We can treat gt exactly like what it is, a HTML table. Quick example below using our custom function (fmt_symbol_first()).
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
# example tableex_gt
mfr
year
bdy_style
mpg_h
hp
Ford $
2017%
coupe
20.2%
647°F
Ferrari  
2015 
coupe
22.0 
597  
Ferrari  
2015 
convertible
20.8 
562  
Ferrari  
2014 
coupe
21.2 
562  
Ferrari  
2016 
coupe
22.8 
661  
Ferrari  
2015 
convertible
22.7 
553  
# what is it?ex_gt%>%as_raw_html()%>%str(max.level =1)
That’s a relatively basic table, but if used interactively it will just print out the output. We can “capture” the raw HTML via gt::as_raw_html(), and then just treat it like another table to “webscrape” with rvest.
The following object is masked from 'package:readr':
guess_encoding
# create object as RAW htmlex_gt_raw<-ex_gt%>%as_raw_html()# read into rvest, and grab the table bodyex_html_tab<-read_html(ex_gt_raw)%>%html_node("table > tbody")# 6 row table!ex_html_tab
Now, rather than using the whole string, I’m going to focus on testing the 1st row and then the remainder. Mainly because our function should do different things to the first row versus the remaining rows!
col1_extract[1]
[1] "Ford $"
So this should be relatively straightforward, we want to do an exact match expecting "Ford $".
Error: col1_extract\[1\] does not match "Ford $".
Actual value: "Ford \$"
BUT oh no we get a failure??? This is because the $ is a special character in regex, so we need to “escape” it with \\. This tells regex to parse it as a literal “dollar sign”. After passing the escape, we now get a silent pass!
We can run it with test_that() as well, and since it passes we get our friendly little message!
test_that(
desc ="First word is Ford $",
code =testthat::expect_match(col1_extract[1], "Ford \\$"))
Test passed 🥇
Testing function
Now, I want to test the individual columns for different things, so I’m going to write a test expectation function.
I’m interested in:
The column number
The row number (ie first or remaining)
A specific expectation
All using the same HTML input
test_gt_by_col<-function(col_n, row_first=TRUE, expectation){# if row_first = TRUE, then just get the 1st row# otherwise select the remainderif(isTRUE(row_first)){row_sel<-1}else{row_sel<-2:6}# use our example html# grab the column by number# get the rows by selection# test the expectationex_html_tab%>%html_nodes(paste0("td:nth-child(",col_n , ")"))%>%html_text()%>%.[row_sel]%>%testthat::expect_match(expectation)}
We can then use our function and avoid having to copy-paste much at all!
test_that(
desc ="First word is Ford $",
code =test_gt_by_col(1, row_first =TRUE, expectation ="Ford \\$"))
Test passed 🎊
Just a quick reminder, if it fails (I’m intentionally failing). We can see that the expectation doesn’t match the remainder.
test_that(
desc ="First word is Ford $",
code =test_gt_by_col(1, row_first =FALSE, expectation ="Ford \\$"))
── Failure (<text>:15:3): First word is Ford $ ─────────────────────────────────
`\.` does not match "Ford \\$".
Actual values:
* Ferrari  
* Ferrari  
* Ferrari  
* Ferrari  
* Ferrari  
Backtrace:
1. global test_gt_by_col(1, row_first = FALSE, expectation = "Ford \\$")
3. testthat::expect_match(., expectation)
4. testthat:::expect_match_(...)
Error:
! Test failed
Put it all together
We can put it all together now, and test all of our columns of interest, with testthat using our custom testing function ON the output of the custom function we wrote earlier.
Create HTML table, extract w/ rvest, define test function
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
Warning: `columns = vars(...)` has been deprecated in gt 0.3.0:
* please use `columns = c(...)` instead
ex_gt_raw<-ex_gt%>%as_raw_html()# read into rvest, and grab the table bodyex_html_tab<-read_html(ex_gt_raw)%>%html_node("table > tbody")test_gt_by_col<-function(col_n, row_first=TRUE, expectation){# if row_first = TRUE, then just get the 1st row# otherwise select the remainderif(isTRUE(row_first)){row_sel<-1}else{row_sel<-2:6}# use our example html# grab the column by number# get the rows by selection# test the expectationex_html_tab%>%html_nodes(paste0("td:nth-child(",col_n , ")"))%>%html_text()%>%.[row_sel]%>%testthat::expect_match(expectation)}
Now we can run our tests on the specific columns and get a lot of “praise”! That’s it for now, but maybe we’ll explore putting these tests into a package down the line.
# Test for escaped characters ---------------------------------------------# check that a suffix + symbol worked, and that escaped characters can be testedtestthat::test_that("Escaped characters work",
{test_gt_by_col(1, expectation ="Ford \\$")test_gt_by_col(1, row_first =FALSE, expectation ="Ferrari  ")})
Test passed 😀
# Test for raw percent ----------------------------------------------------# on this column we used the literal string of %testthat::test_that("Raw percent character works",
{test_gt_by_col(2, expectation ="2017%")test_gt_by_col(2, row_first =FALSE, expectation ="201[4-7] ")})
Test passed 🎉
# Test for symbolic percent -----------------------------------------------# on this column we used the HTML code for percenttestthat::test_that("HTML symbol for percent works",
{test_gt_by_col(4, expectation ="20.2%")test_gt_by_col(4, row_first =FALSE, expectation ="[0-9]+ ")})
Test passed 🥇
# Test for suffix + symbol ------------------------------------------------# test for case where the symbol is in front of suffixtestthat::test_that("A combined suffix + symbol work",
{test_gt_by_col(5, expectation ="647°F")test_gt_by_col(5, row_first =FALSE, expectation ="[0-9]+  ")})