I have a DT table in shiny app that have background color set to match certain values. I'm also using the selected rows in table to control other part of the app. Now my problem is to make it obvious which rows are selected.
Usually selected rows in table will have background color changed, but I don't have this option since I set the background color already and don't want to change it. Changing foreground color (font color) for selected rows is not optimal as this is not obvious and intuitive.
Right now I'm making selected rows have different opacity with unselected rows, which works to some degree but still not optimal.
One approach can be add some checked icon to the selected rows. Note I don't want real checkbox input because that will lead user to click the checkbox, while I think it's easier just to click row to select.
There are some examples to show html content in DT table, however that will mean dynamically change table content by row selection, which is not acceptable to my app, since each table content change will trigger table refresh, which reset the row selection and come into a loop.
I think it should be possible to use js to change selected rows css class and thus add a checked icon to them. I saw this question which is kind of similar, however the example is hard to understand to me.
Update: This answer by #Stéphane Laurent solved my problem exactly. I searched SO extensively before but didn't find this.
Update 2: My use cases is more complex, and I'm having problems adapting this approach. I need 2 control tables, and I'm switching them based on a radio button control. With dynamic rendering of the table, the excluded status get reset in every switch. Previously I used DT row selection which don't have this problem.
See example below, exclude some rows in table 1, switch to table 2, then switch back, the exclude status is restored.
library(shiny)
library(DT)
# DT checked js ----
rowNames <- FALSE # whether to show row names in the table
colIndex <- as.integer(rowNames)
# making variants since we have two table. not worth a function since only two instances. main changes are function name and shiny input id excludedRows
callback1 <- c(
sprintf("table.on('click', 'td:nth-child(%d)', function(){", colIndex+1),
" var td = this;",
" var cell = table.cell(td);",
" if(cell.data() === 'ok'){",
" cell.data('remove');",
" } else {",
" cell.data('ok');",
" }",
" var $row = $(td).closest('tr');",
" $row.toggleClass('excluded');",
" var excludedRows = [];",
" table.$('tr').each(function(i, row){",
" if($(this).hasClass('excluded')){",
" excludedRows.push(parseInt($(row).attr('id').split('_')[1]));",
" }",
" });",
" Shiny.setInputValue('excludedRows1', excludedRows);",
"})"
)
callback2 <- c(
sprintf("table.on('click', 'td:nth-child(%d)', function(){", colIndex+1),
" var td = this;",
" var cell = table.cell(td);",
" if(cell.data() === 'ok'){",
" cell.data('remove');",
" } else {",
" cell.data('ok');",
" }",
" var $row = $(td).closest('tr');",
" $row.toggleClass('excluded');",
" var excludedRows = [];",
" table.$('tr').each(function(i, row){",
" if($(this).hasClass('excluded')){",
" excludedRows.push(parseInt($(row).attr('id').split('_')[1]));",
" }",
" });",
" Shiny.setInputValue('excludedRows2', excludedRows);",
"})"
)
# for select all, not using it now
# restore <- c(
# "function(e, table, node, config) {",
# " table.$('tr').removeClass('excluded').each(function(){",
# sprintf(" var td = $(this).find('td').eq(%d)[0];", colIndex),
# " var cell = table.cell(td);",
# " cell.data('ok');",
# " });",
# " Shiny.setInputValue('excludedRows', null);",
# "}"
# )
render <- c(
'function(data, type, row, meta){',
' if(type === "display"){',
' var color = data === "ok" ? "#027eac" : "gray";',
' return "<span style=\\\"color:" + color +',
' "; font-size:18px\\\"><i class=\\\"glyphicon glyphicon-" +',
' data + "\\\"></i></span>";',
' } else {',
' return data;',
' }',
'}'
)
# test app ----
ui <- fluidPage(
tags$head(
tags$style(HTML(
".excluded { color: gray; font-style: italic; }"
))
),
fluidRow(
column(
6,
tags$label("Excluded rows Table 1"),
verbatimTextOutput("excludedRows1"),
tags$label("Excluded rows Table 2"),
verbatimTextOutput("excludedRows2")
),
column(
6,
tags$label("Included rows"),
verbatimTextOutput("includedRows1"),
verbatimTextOutput("includedRows2")
)
),
br(),
radioButtons("select_table", label = "Select table", choices = c("1", "2"), inline = TRUE),
uiOutput("control_table_ui")
# tabBox(tabPanel("1", DTOutput("mytable1")),
# tabPanel("2", DTOutput("mytable2")))
)
server <- function(input, output,session) {
output$control_table_ui <- renderUI({
if (input$select_table == "1") {
column(12, offset = 0, DTOutput("mytable1"))
} else {
column(12, offset = 0, DTOutput("mytable2"))
}
})
dt <- cbind(On = "ok", mtcars[1:6,], id = paste0("row_",1:6))
row_colors <- rep(c("red", "blue", "green"), 2)
names(row_colors) <- dt$id
output[["mytable1"]] <- renderDT({
datatable(dt, caption = "table 1",
rownames = rowNames, extensions = c("Select"),
selection = "none", callback = JS(callback1),
options = list(
# pageLength = 3,
sort = FALSE,
rowId = JS(sprintf("function(data){return data[%d];}",
ncol(dt)-1+colIndex)),
columnDefs = list(
list(visible = FALSE, targets = ncol(dt)-1+colIndex),
list(className = "dt-center", targets = "_all"),
list(className = "notselectable", targets = colIndex),
list(targets = colIndex, render = JS(render))
),
dom = "t",
# buttons = list(list(
# extend = "collection",
# text = 'Select All',
# action = JS(restore)
# )
# ),
select = list(style = "single", selector = "td:not(.notselectable)")
# select = list(style = 'os', # set 'os' select style so that ctrl/shift + click in enabled
# items = 'row') # items can be cell, row or column
)
) %>%
formatStyle("id", target = "row",
backgroundColor = styleEqual(dt$id, row_colors))
}, server = FALSE)
output[["mytable2"]] <- renderDT({
datatable(dt, caption = "table 2",
rownames = rowNames, extensions = c("Select"),
selection = "none", callback = JS(callback2),
options = list(
# pageLength = 3,
rowId = JS(sprintf("function(data){return data[%d];}",
ncol(dt)-1+colIndex)),
columnDefs = list(
list(visible = FALSE, targets = ncol(dt)-1+colIndex),
list(className = "dt-center", targets = "_all"),
list(className = "notselectable", targets = colIndex),
list(targets = colIndex, render = JS(render))
),
dom = "t",
# buttons = list(list(
# extend = "collection",
# text = 'Select All',
# action = JS(restore)
# )
# ),
select = list(style = "single", selector = "td:not(.notselectable)")
)
) %>%
formatStyle("id", target = "row",
backgroundColor = styleEqual(dt$id, row_colors))
}, server = FALSE)
output$excludedRows1 <- renderPrint({
input[["excludedRows1"]]
})
output$excludedRows2 <- renderPrint({
input[["excludedRows2"]]
})
output$includedRows1 <- renderPrint({
setdiff(1:nrow(dt), input[["excludedRows1"]])
})
}
shinyApp(ui, server)
Update 3: Per #Stéphane Laurent 's suggestion, using conditionalPanel solved the problem. Although it's a little bit slower than renderUI, but it's working.
Thanks to #StéphaneLaurent 's answer which is a great js based solution and solved my 95% needs. However I need a button to clear all selection and cannot write that one because of my limited js skills. I also forgot the important server=FALSE parameter so met problem of sorting lost selection. Thus I switched back to my original row selection mechanism.
I used to try to modify the table by row selection, but that will trigger reactive event loop. Later I realized I only need to change the view, not the underlying data, and changing view is possible by purely css rules.
Checking the great example here, the more icons example can show different icon depend on checkbox selection. By inspecting the css rules, I found both icons are there all the time, just the css rule is different depend on selection status.
Thus I came up with this solution, which used the builtin row selection in DT and some css rules, this way you still have all the feature of row selection control in DT without needs of js code, and everything is implemented by css.
library(shiny)
library(DT)
library(data.table)
ui <- fluidPage(
tags$head(
tags$style(HTML("
.selected .table-icon-yes {
opacity: 1;
display: inline-block;
color: #3c763d;
}
.table-icon-yes {
opacity: 0;
display: none;
}
.selected .table-icon-no {
opacity: 0;
display: none;
}
.table-icon-no {
opacity: 1;
display: inline-block;
color: #999;
}
"))
),
DTOutput("table")
)
icon_col <- tagList(span(class = "table-icon-yes", icon("ok", lib = "glyphicon")),
span(class = "table-icon-no", icon("remove", lib = "glyphicon")))
server <- function(input, output, session) {
output$table <- renderDT({
dt <- data.table(iris)
dt[, Selected := as.character(icon_col)]
setcolorder(dt, c(ncol(dt), 1:(ncol(dt) - 1)))
datatable(dt, escape = FALSE)
})
}
shinyApp(ui = ui, server = server)
Related
The below reproducible code renders a user input matrix grid inside a modal dialog box (click on the single action button at the top when invoking the App), with the user input matrix: (a) automatically expanding to the right as the user inputs data into the columns, (b) automatically generating sequentially numbered column headers, and (c) rendered inside a scrollable well panel so that it expands without "scrunching" as it grows.
However, I would like to "freeze" the left-most column of the matrix input grid (the row headers), so the user doesn't lose the meanings of individual rows as the grid grows. The images at the bottom better illustrate. How can this be done?
I assume this requires some gyrations with shinyjs, an area I am very weak in. This is a follow-up evolution of earlier post In R shiny, how to incorporate scrolling into modal dialog box?
Reproducible code:
library(shiny)
library(shinyMatrix)
library(shinyjs)
library(shinyWidgets)
library(magrittr)
library(DT)
mat1DefaultRownames <- c('Item A','Item B','Item C','Item D')
mat1Default <- data.frame('Series 1'=c(1,24,0,100), row.names = mat1DefaultRownames) %>% as.matrix()
mat1Input <- function(inputId, mat1Default) {
matrixInput(
inputId = inputId,
label = "Input terms:",
value = mat1Default,
rows = list(extend = FALSE, names = TRUE),
cols = list(extend = TRUE, names = TRUE, editableNames = FALSE, delete = TRUE),
class = "numeric"
)
}
ui <-
fluidPage(
shinyjs::useShinyjs(),
div(style = "margin-top: 10px"),
actionButton('modInputs','Input terms', style = 'width:25%'),
DTOutput("table1")
)
server <- function(input,output,session)({
rv1 <- reactiveValues(
mat1 = mat1Input("mat1",mat1Default),
input = mat1Default,
colHeader = colnames(input)
)
mat1Inputs <- reactive({
if (isTruthy(input$modInputs)) {
req(input$mat1)
df <- input$mat1
rv1$mat1 <- mat1Input("mat1", df)
colnames(df) <- paste("Series",rep(1:ncol(df),each=1,length.out=ncol(df)))
rownames(df) <- mat1DefaultRownames
rv1$input <- df
updateMatrixInput(session, inputId = "mat1", value = rv1$input)
} else {
df <- mat1Default
colnames(df) <- paste("Series", 1:ncol(df))
}
df[3:4, ] <- sprintf('%.2f%%', df[3:4, ])
df
})
output$table1 <-
renderDT(server = TRUE, {
datatable(
data = mat1Inputs(),
options = list(
scrollX = T,
dom = 'ft',
autoWidth = FALSE,
info = FALSE,
searching = FALSE,
columnDefs =
list(
list(className = 'dt-left', targets = 0),
list(className = 'dt-right', targets = seq_len(ncol(mat1Inputs())))
)
),
class = "display nowrap"
)
})
observeEvent(input$modInputs, {
showModal(
modalDialog(
wellPanel(div(style = "overflow-x: auto;", rv1$mat1)),
footer = tagList(actionButton("resetInputs", "Reset"),modalButton("Close"))
)
)
})
observeEvent(c(input$modInputs, input$mat1), {
runjs(
paste0(
'$("#mat1").css("width","calc(100% + ', (dim(input$mat1)[2]-1 + dim(input$mat1)[2]%%2)*115, 'px")'
) # close paste0
) # close runjs
})
observeEvent(input$resetInputs, {updateMatrixInput(session,'mat1', mat1Default)})
})
shinyApp(ui, server)
I have a datatable in Shiny where I use the filter option. When I want to filter a boolean column, I can choose true and false. I would like to rename these values. So instead of true and false, it should show yes and no.
The displayed column entries are not TRUE or FALSE but checkboxes. This is important, that's why I add it to the code example.
I had the idea to convert the booleans to factors. Then the filter shows yes and no. However, in this case all checkboxes are checked, so this doesn't work. I commented out the line that does the converting in the code below. If there is a solution without this converting step it would be nice.
As I didn't find any option to change the values, I guess, this can only be accomplished with JavaScript code. Unfortunately, I am not really familiar with JavaScript. I hopse someone call help me here.
library(shiny)
library(DT)
library(dplyr)
library(forcats)
set.seed(43)
data <- data.frame(A = rnorm(10),
B = letters[1:10],
C = sample(c(TRUE, FALSE), 10, TRUE))
ui <- fluidPage(
DTOutput("table_id")
)
server <- function(input, output, session) {
output$table_id <- renderDT(
data %>%
# mutate(C = as_factor(if_else(C == TRUE, "yes", "no"))) %>%
datatable(filter = "top",
options = list(
columnDefs = list(
list(
targets = c(3),
render = JS(
"function(data, type, row, meta) {",
" if(type === 'display'){",
" return data ? '<input type=\"checkbox\" disabled checked/>' : '<input type=\"checkbox\" disabled/>';",
" }",
" return data;",
"}"
)
))
)
)
)
}
shinyApp(ui, server)
Your factor C in your data.frame will pass values of either "yes" or "no"; however, your JS function is checking data to see if it is TRUE or FALSE. If you compare data with "yes", then the "yes" will be considered TRUE and "no" will be considered FALSE. Let me know if this produces the desired behavior.
library(shiny)
library(DT)
set.seed(43)
data <- data.frame(A = rnorm(10),
B = letters[1:10],
C = sample(c(TRUE, FALSE), 10, TRUE))
ui <- fluidPage(
DTOutput("table_id")
)
server <- function(input, output, session) {
output$table_id <- renderDT(
data %>%
mutate(C = as_factor(if_else(C == TRUE, "yes", "no"))) %>%
datatable(filter = "top",
options = list(
columnDefs = list(
list(
targets = c(3),
render = JS(
"function(data, type, row, meta) {",
" if(type === 'display'){",
" return data == \"yes\" ? '<input type=\"checkbox\" disabled checked/>' : '<input type=\"checkbox\" disabled/>';",
" }",
" return data;",
"}"
)
))
)
)
)
}
shinyApp(ui, server)
Take this example from the reactable documentation (interactive Shiny example provided at link):
data <- cbind(
MASS::Cars93[1:5, c("Manufacturer", "Model", "Type", "Price")],
details = NA
)
reactable(
data,
columns = list(
# Render a "show details" button in the last column of the table.
# This button won't do anything by itself, but will trigger the custom
# click action on the column.
details = colDef(
name = "",
sortable = FALSE,
cell = function() htmltools::tags$button("Show details")
)
),
onClick = JS("function(rowInfo, colInfo) {
// Only handle click events on the 'details' column
if (colInfo.id !== 'details') {
return
}
// Display an alert dialog with details for the row
window.alert('Details for row ' + rowInfo.index + ':\\n' + JSON.stringify(rowInfo.row, null, 2))
// Send the click event to Shiny, which will be available in input$show_details
// Note that the row index starts at 0 in JavaScript, so we add 1
if (window.Shiny) {
Shiny.setInputValue('show_details', { index: rowInfo.index + 1 }, { priority: 'event' })
}
}")
)
I want to include 2 buttons per details column cell, which I can do by changing the cell definition to:
cell = function() {
a <- htmltools::tags$button("Approve")
b <- htmltools::tags$button("Decline")
return(list(a,b))
}
But then how to differentiate between the Approve/Decline button within the JS() onClick() function? Is there another parameter I can pass that will give me this ability? I console.log'd both rowInfo and colInfo and could not find anything that seemed helpful to identify the two buttons. I'd like to have it so that I can return both:
Shiny.setInputValue('approve_button_click', ...)
and
Shiny.setInputValue('decline_button_click',...)
from the JS side so I can handle them separately in R. Any help is appreciated!
If you want to get just the row index you can do:
library(htmltools)
details = colDef(
name = "",
sortable = FALSE,
cell = function(value, rowIndex, colName){
as.character(tags$div(
tags$button("Approve", onclick=sprintf('alert("approve - %d")', rowIndex)),
tags$button("Decline", onclick=sprintf('alert("decline - %d")', rowIndex))
))
},
html = TRUE
)
In Shiny:
reactable(
data,
columns = list(
details = colDef(
name = "",
sortable = FALSE,
cell = function(value, rowIndex, colName){
as.character(tags$div(
tags$button(
"Approve",
onclick =
sprintf(
'Shiny.setInputValue("approve", %d, {priority: "event"})',
rowIndex
)
),
tags$button(
"Decline",
onclick =
sprintf(
'Shiny.setInputValue("decline", %d, {priority: "event"})',
rowIndex
)
)
))
},
html = TRUE
)
)
)
The app below contains a selectInput input$set1 with two options (download and upload) and a radio button group input$set2 that is rendered using renderUI(). I am using a custom message handler that disables a radio button in the radio button group depending on the value of the selectInput (if input$set1 == 'download' then disable the upload radio button and enable it otherwise).
Here is the app:
library(shiny)
library(shinyWidgets)
library(shinydashboard)
toggleRadioOpt <- function(session, inputId, choice, enable = TRUE, selected) {
session$sendCustomMessage(
type = 'toggleRadioOpt',
message = list(id = inputId, choice = choice, enable = enable, selected = selected)
)
}
modUI <- function(id) {
ns <- NS(id)
tagList(
tags$head(
tags$script("
Shiny.addCustomMessageHandler('toggleRadioOpt',
function(data) {
$('#' + data.id + ' input[value = \"' + data.choice + '\"]').attr('disabled', !data.enable).prop('checked', false);
if(data.selected !== null) {
$('#' + data.id + ' input[value = \"' + data.selected + '\"]').prop('checked', true);
}
}
);
")
),
prettyRadioButtons(ns('set1'), label = 'Select', choices = c('download', 'upload', 'abb')),
# selectInput(ns('set1'), label = 'Select', choices = c('download', 'upload', 'sql')),
uiOutput(ns('taskUI'))
)
}
modServer <- shinyServer(function(input, output, session) {
ns = session$ns
output$taskUI <- renderUI({
switch(input$set1,
'download' = selectInput(ns('data'), '', state.area),
'upload' = selectInput(ns('data'), '', state.center),
'abb' = tagList(
selectInput(ns('data'), '', state.abb),
actionButton(ns('upload'), 'Upload')
)
)
prettyRadioButtons(ns('set2'), label = '', choices = c('split', 'upload'))
})
observe({
if(!is.null(input$set2)) {
if(input$set1 %in% c('download', 'abb')) {
toggleRadioOpt(session = session, inputId = ns('set2'), choice = 'upload', enable = F, selected = 'split')
} else {
toggleRadioOpt(session = session, inputId = ns('set2'), choice = 'upload', enable = T, selected = 'split')
}
}
})
})
# UI ----------------------------------------------------------------------
ui = fluidPage(modUI('first'))
# SERVER ------------------------------------------------------------------
server <- shinyServer(function(input, output, session) {
callModule(modServer, 'first')
})
shinyApp(ui, server)
The handler works as expected if the radio button group is created in the UI but fails if the buttons are created using renderUI(). Specifically, it fails on startup but then works if input$set1 is changed subsequently. I am new to using custom message handlers so I'm not sure what's going on here or how to fix it. I thought it may have something to do with the jQuery being triggered before the UI has loaded but I can't be sure.
I thought it may have something to do with the jQuery being triggered
before the UI has loaded but I can't be sure.
I think so. Before the UI has loaded, input$set2 is NULL. You can do:
observe({
if(!is.null(input$set2)){
if(input$set1 == 'download') {
toggleRadioOpt(session = session, inputId = ns('set2'), choice = 'upload',
enable = F, selected = 'split')
} else {
toggleRadioOpt(session = session, inputId = ns('set2'), choice = 'upload',
enable = T, selected = 'split')
}
}
})
I have a first DT table oTable with cell selection enabled. When the user click (select) a cell, that will generate another DT table nTable.
Then, in nTable I want to insert a selectInput. The code below is a working example. Mostly adapted from this post.
Problem:
When nTable is regenerated, the connection (binding?) with shinyValue is somehow broken.
Step to reproduce the problem:
launch the app.
select top left cell (e.g. Sepal.Length=5.1). In fact, select any cell will also work.
In the second DT generated below, change the selectInput in col from A to something else, say, B. Check that this change is detected in the TableOutput below.
De-select the selected cell
Re-select the same cell.
Now, you can change the selectInput again but no changes will be detected.
Also, I am not sure how to use session$sendCustomMessage("unbind-DT", "oTable"), I tried changing oTable to nTable but that didn't work.
library(shiny)
library(DT)
runApp(list(
ui = basicPage(
tags$script(
HTML(
"Shiny.addCustomMessageHandler('unbind-DT', function(id) {
Shiny.unbindAll($('#'+id).find('table').DataTable().table().node());
})"
)
),
h2('The data'),
DT::dataTableOutput("oTable"),
DT::dataTableOutput("nTable"),
h2("Selected"),
tableOutput("checked")
),
server = function(input, output, session) {
# helper function for making checkbox
shinyInput = function(FUN, len, id, ...) {
inputs = character(len)
for (i in seq_len(len)) {
inputs[i] = as.character(FUN(paste0(id, i),label=NULL, ...))
}
inputs
}
mydata=reactive({
session$sendCustomMessage("unbind-DT", "oTable")
input$oTable_cells_selected
})
output$nTable=renderDataTable({
req(mydata())
dd=as.data.frame(mydata())
dd$col=shinyInput(selectInput,nrow(dd),"selecter_",choices=LETTERS[1:3])
dd
},selection='none',server=FALSE,escape=FALSE,rownames=FALSE,
options=list(
preDrawCallback = JS(
'function() {
Shiny.unbindAll(this.api().table().node()); }'
),
drawCallback = JS('function() {
Shiny.bindAll(this.api().table().node()); } ')
))
output$oTable=renderDataTable(DT::datatable(iris,selection=list(mode="multiple",target='cell')))
# helper function for reading select input
shinyValue = function(id, len) {
unlist(lapply(seq_len(len), function(i) {
value = input[[paste0(id, i)]]
if (is.null(value))
NA
else
value
}))
}
# output read selectInput
output$checked <- renderTable({
req(mydata())
data.frame(selected = shinyValue("selecter_", nrow(mydata())))
})
}
))
You have to run Shiny.unbindAll on nTable (the table which contains the inputs). But only after the table has been created a first time.
library(shiny)
library(DT)
runApp(list(
ui = basicPage(
tags$head(tags$script(
HTML(
"Shiny.addCustomMessageHandler('unbindDT', function(id) {
var $table = $('#'+id).find('table');
if($table.length > 0){
Shiny.unbindAll($table.DataTable().table().node());
}
})"
))
),
h2('The data'),
DT::dataTableOutput("oTable"),
DT::dataTableOutput("nTable"),
h2("Selected"),
tableOutput("checked")
),
server = function(input, output, session) {
# helper function for making checkbox
shinyInput = function(FUN, len, id, ...) {
inputs = character(len)
for (i in seq_len(len)) {
inputs[i] = as.character(FUN(paste0(id, i),label=NULL, ...))
}
inputs
}
observeEvent(input$oTable_cells_selected, {
session$sendCustomMessage("unbindDT", "nTable")
})
mydata = eventReactive(input$oTable_cells_selected, {
if(length(input$oTable_cells_selected)){
input$oTable_cells_selected
}
})
output$nTable=DT::renderDataTable({
req(mydata())
dd=as.data.frame(mydata())
dd$col=shinyInput(selectInput,nrow(dd),"selecter_",choices=LETTERS[1:3])
datatable(dd, selection='none', escape=FALSE,rownames=FALSE,
options=list(
preDrawCallback = JS(
'function() {
Shiny.unbindAll(this.api().table().node()); }'
),
drawCallback = JS('function() {
Shiny.bindAll(this.api().table().node()); } ')
))
},server=FALSE)
output$oTable=DT::renderDataTable(
DT::datatable(iris,selection=list(mode="multiple",target='cell'),
options=list(pageLength = 5)))
# helper function for reading select input
shinyValue = function(id, len) {
unlist(lapply(seq_len(len), function(i) {
value = input[[paste0(id, i)]]
if (is.null(value))
NA
else
value
}))
}
# output read selectInput
output$checked <- renderTable({
req(mydata())
data.frame(selected = shinyValue("selecter_", nrow(mydata())))
})
}
))