From c8fd06a1117d3edbfe25b5b9f9490a34896df9da Mon Sep 17 00:00:00 2001 From: Edouard Date: Tue, 24 Oct 2023 16:27:08 -0500 Subject: [PATCH] add ridl connection --- NAMESPACE | 1 + R/app_server.R | 4 +- R/body.R | 1 + R/header.R | 11 +- R/mod_configure.R | 187 +++++++++++++++++++++++++++++--- R/mod_crunch.R | 85 ++++++++------- R/mod_document.R | 270 +++++++++++++++++++++++++++++++++++++--------- R/mod_home.R | 2 +- 8 files changed, 453 insertions(+), 108 deletions(-) diff --git a/NAMESPACE b/NAMESPACE index 0bbb6ff..a06d604 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -28,6 +28,7 @@ export(template_1_exploration) import(dplyr) import(ggplot2) import(golem) +import(kobocruncher) import(riddle) import(shiny) import(shinydashboard) diff --git a/R/app_server.R b/R/app_server.R index 41ccb23..55cf53a 100644 --- a/R/app_server.R +++ b/R/app_server.R @@ -9,7 +9,9 @@ app_server <- function(input, output, session) { ## add a reactive value object to pass by elements between objects - AppReactiveValue <- reactiveValues() + AppReactiveValue <- reactiveValues( + showridl = FALSE + ) # pins::board_register() # connect to pin board if needed callModule(mod_home_server, "home_ui_1") callModule(mod_document_server, "document_ui_1", AppReactiveValue) diff --git a/R/body.R b/R/body.R index 3fb5ba7..4664163 100755 --- a/R/body.R +++ b/R/body.R @@ -11,6 +11,7 @@ body <- function() { shinydashboard::dashboardBody( unhcrshiny::theme_shinydashboard_unhcr(), + golem::activate_js(), tags$head( tags$script(src = "custom.js") ), diff --git a/R/header.R b/R/header.R index 5944fe4..a51be1b 100644 --- a/R/header.R +++ b/R/header.R @@ -1,6 +1,9 @@ header <- function() { - shinydashboard::dashboardHeader( - title = tagList( - span(class = 'logo-lg',a("kobocruncher",style="color:white !important",href='https://rstudio.unhcr.org/kobocruncher')), - ) ) + + shinydashboard::dashboardHeader( title = "KoboCruncher" ) + + # shinydashboard::dashboardHeader( + # title = tagList( + # span(class = 'logo-lg',a("kobocruncher",style="color:white !important",href='https://rstudio.unhcr.org/kobocruncher')), + # ) ) } diff --git a/R/mod_configure.R b/R/mod_configure.R index 74e0089..c86a518 100644 --- a/R/mod_configure.R +++ b/R/mod_configure.R @@ -21,7 +21,7 @@ mod_configure_ui <- function(id) { width = 12, br(), p("First upload your form and set up the language you would like to use - for the analysis from within your form") + for the analysis from within your form. Then add the data") ) ) , @@ -30,7 +30,7 @@ mod_configure_ui <- function(id) { shinydashboard::box( - title = "Iterate ", + title = "Initial Setting ", # status = "primary", status = "info", solidHeader = FALSE, @@ -40,10 +40,26 @@ mod_configure_ui <- function(id) { fluidRow( column( width = 6, + h3("Form"), + div( + id = ns("show_ridl2"), + selectInput( inputId = ns("ridlform"), + label = "Confirm the attachment that contains the form + or an already extended version of the form", + choice = c("Waiting for selected project..."=".."), + width = "100%" ), - fileInput(inputId = ns("xlsform"), - label = "Load your XlsForm", - multiple = F), + actionButton( inputId = ns("pull3"), + label = " Pull in session!", + icon = icon("upload"), + width = "100%" ) + ), + div( + id = ns("noshow_ridl2"), + fileInput(inputId = ns("xlsform"), + label = "Load your XlsForm", + multiple = F) + ), selectInput(inputId = ns("language"), label = " Select Language to use from within the form", @@ -54,18 +70,31 @@ mod_configure_ui <- function(id) { "Arabic (ar)" = "Arabic (ar)", "Portuguese (pt)"= "Portuguese (pt)"), selected = NULL, - width = '400px'), - - + width = "100%" ), ), column( width = 6, - downloadButton(ns("downloadform"), - "Download back your extended form"), - hr(), - "You can work offline on the extended form and re-upload it to - regenerate a new exploration report" + h3("Data"), + div( + id = ns("show_ridl3"), + + selectInput( inputId = ns("ridldata"), + label = "Confirm the attachment that contains the right data version", + choice = c("Waiting for selected project..."=".."), + width = "100%" ) , + actionButton( inputId = ns("pull4"), + label = " Pull in session!", + icon = icon("upload"), + width = "100%" ) + ), + div( + id = ns("noshow_ridl3"), + fileInput(inputId = ns("dataupload"), + label = "Load your data", + multiple = F, + width = "100%" ) + ) ) ) ) @@ -76,6 +105,7 @@ mod_configure_ui <- function(id) { #' Module Server #' @noRd #' @import shiny +#' @import golem #' @import tidyverse #' @importFrom XlsFormUtil fct_xlsfrom_language #' @keywords internal @@ -83,6 +113,86 @@ mod_configure_ui <- function(id) { mod_configure_server <- function(input, output, session, AppReactiveValue) { ns <- session$ns + ## Manage visibility for RIDL mode.... + observeEvent(AppReactiveValue$showridl, { + if(isTRUE(AppReactiveValue$showridl)) { + golem::invoke_js("show", paste0("#", ns("show_ridl2"))) + golem::invoke_js("hide", paste0("#", ns("noshow_ridl2"))) + golem::invoke_js("show", paste0("#", ns("show_ridl3"))) + golem::invoke_js("hide", paste0("#", ns("noshow_ridl3"))) + } else { + golem::invoke_js("hide", paste0("#", ns("show_ridl2"))) + golem::invoke_js("show", paste0("#", ns("noshow_ridl2"))) + golem::invoke_js("hide", paste0("#", ns("show_ridl3"))) + golem::invoke_js("show", paste0("#", ns("noshow_ridl3"))) + } + }) + + + + + observeEvent(input$dataupload,{ + req(input$dataupload) + message("Please upload a file") + AppReactiveValue$datauploadpath <- input$dataupload$datapath + AppReactiveValue$thistempfolder <- dirname(AppReactiveValue$datauploadpath) + AppReactiveValue$datauploadname <- input$dataupload$name + + ## Create a sub folder data-raw and paste data there + dir.create(file.path(AppReactiveValue$thistempfolder, "data-raw"), showWarnings = FALSE) + file.copy( AppReactiveValue$datauploadpath, + paste0(AppReactiveValue$thistempfolder, + "/data-raw/", + # fs::path_file(AppReactiveValue$datauploadpath)), + AppReactiveValue$datauploadname), + overwrite = TRUE) + }) + + + + observeEvent(input$ridldata, { + AppReactiveValue$ridldata <- input$ridldata + }) + + observeEvent(input$pull4, { + ## some message for user... + data_message <- utils::capture.output({ + ### So let's fetch the resource and create the corresponding reactive objects + # for the rest of the flow... + + showModal(modalDialog("Please wait, pulling all the files from the server at the moment...", footer=NULL)) + + ## now the data + req(AppReactiveValue$ridldata) + ridldata <- tempfile() + resource_fetch(url = AppReactiveValue$ridldata, + path = ridldata) + AppReactiveValue$datalist <- kobocruncher::kobo_data(datapath = ridldata) + + removeModal() + + }, type = "message") + + if(is.null(AppReactiveValue$datalist )){ + # not successful + shinyWidgets::sendSweetAlert( + session = session, + title = "Problem with Data", + text = "Please check your access rights...", + type = "warning" + ) + } else { + + shinyWidgets::sendSweetAlert( + session = session, + title = "You are done!", + text = paste0("Session loaded: the dataset includes ", + nrow(AppReactiveValue$datalist$main), + " records"), + type = "success" ) + } + }) + observeEvent(input$language, { AppReactiveValue$language <- input$language @@ -133,6 +243,57 @@ mod_configure_server <- function(input, output, session, AppReactiveValue) { content <- function(file) { file.copy( AppReactiveValue$expandedform , file)} ) + observeEvent(input$ridlform, { + AppReactiveValue$ridlform <- input$ridlform + }) + + observeEvent(input$pull3, { + ## some message for user... + data_message <- utils::capture.output({ + ### So let's fetch the resource and create the corresponding reactive objects + # for the rest of the flow... + + showModal(modalDialog("Please wait, pulling all the files from the server at the moment...", footer=NULL)) + + req(AppReactiveValue$ridlform) + ridlformfile <- tempfile() + riddle::resource_fetch(url = AppReactiveValue$ridlform, + path = ridlformfile) + + ## Now let's load with koboloader + kobocruncher::kobo_prepare_form(xlsformpath = ridlformfile, + label_language = NULL, + xlsformpathout = ridlformfile ) + + ## Let's extract the analysis plan from the xlsform - or extend the current one + AppReactiveValue$dico <- kobocruncher::kobo_dico(xlsformpath = ridlformfile) + + + removeModal() + + }, type = "message") + + if(is.null(AppReactiveValue$dico )){ + # not successful + shinyWidgets::sendSweetAlert( + session = session, + title = "Problem with Form", + text = "Please check your access rights...", + type = "warning" + ) + } else { + + shinyWidgets::sendSweetAlert( + session = session, + title = "You are done!", + text = paste0("Session loaded: the form includes ", + nrow(AppReactiveValue$dico$variables), + " questions"), + type = "success" ) + } + }) + + } ## copy to body.R diff --git a/R/mod_crunch.R b/R/mod_crunch.R index e0fb233..d9ec3e2 100644 --- a/R/mod_crunch.R +++ b/R/mod_crunch.R @@ -20,9 +20,11 @@ mod_crunch_ui <- function(id) { column( width = 12, br(), - p("Upload your data and obtain an initial exploration report. - You can then iterate: download back your extended form and regenerate your report - until you get what you need.") + p("Survey analysis is an iterative process involving multiple rounds of + data exploration, and refinement. + Each new round can help uncover insights, validate findings, and refine + the understanding of the surveyed population, allowing for a dynamic + and evolving analysis.") ) ) , @@ -31,7 +33,7 @@ mod_crunch_ui <- function(id) { shinydashboard::box( - title = "Iterate ", + title = "Iterative Exploration ", # status = "primary", status = "info", solidHeader = FALSE, @@ -40,30 +42,51 @@ mod_crunch_ui <- function(id) { width = 12, fluidRow( column( - width = 6, - - fileInput(inputId = ns("dataupload"), - label = "Load your data", - multiple = F), - + width = 4, + h3("Generate your report... "), + downloadButton(ns("downloadreport"), + "Get your exploration report", + style="color: #fff; background-color: #672D53"), + ## If yes to ridlyes - - ), + div( + id = ns("show_ridl3"), + br(), + p("All this analysis is fully reproducible and therefore re-usable. + In order to keep track of your work, record it within RIDL with predefined + attachment ressources metadata"), - column( - width = 6, + actionButton( inputId = ns("ridlpublish"), + label = " Record in RIDL your analysis", + icon = icon("upload"), + width = "100%" ) + ) - ## If yes to ridlyes - ## Ask a few question about the final report ## # publish = Do you want to publish the report in RIDL, # visibility= visibility, # stage = stage, + ), - downloadButton(ns("downloadreport"), - "Get your exploration report", - style="color: #fff; background-color: #672D53") - + column( + width = 8, + h3(" ... and iterate"), + p("You can work offline directly with Excel on the extended form to + amend your analysis plan"), + p("This can include: Adjusting label, grouping questions, + setting crosstabulation, adding indicator calculation, etc."), + br(), + downloadButton(ns("downloadform"), + "Download back your extended form"), + hr(), + fileInput(inputId = ns("xlsform"), + label = "Reload your extended XlsForm ", + multiple = F), + p("Once done, reload it and click on the + 'Get your exploration report' button on the left to + generate new versions of the exploration report") ) ) @@ -85,28 +108,16 @@ mod_crunch_server <- function(input, output, session, AppReactiveValue) { - ## Load Data input$dataupload - observeEvent(input$dataupload,{ - req(input$dataupload) - message("Please upload a file") - AppReactiveValue$datauploadpath <- input$dataupload$datapath - AppReactiveValue$thistempfolder <- dirname(AppReactiveValue$datauploadpath) - AppReactiveValue$datauploadname <- input$dataupload$name - - ## Create a sub folder data-raw and paste data there - dir.create(file.path(AppReactiveValue$thistempfolder, "data-raw"), showWarnings = FALSE) - file.copy( AppReactiveValue$datauploadpath, - paste0(AppReactiveValue$thistempfolder, - "/data-raw/", - # fs::path_file(AppReactiveValue$datauploadpath)), - AppReactiveValue$datauploadname), - overwrite = TRUE) + ## Manage visibility for RIDL mode.... + observeEvent(AppReactiveValue$showridl, { + if(isTRUE(AppReactiveValue$showridl)) { + golem::invoke_js("show", paste0("#", ns("show_ridl3"))) + } else { + golem::invoke_js("hide", paste0("#", ns("show_ridl3"))) + } }) - - - output$downloadreport <- downloadHandler( filename = "exploration_report.html", content = function(file) { diff --git a/R/mod_document.R b/R/mod_document.R index 8dc91c7..324be35 100644 --- a/R/mod_document.R +++ b/R/mod_document.R @@ -14,33 +14,30 @@ mod_document_ui <- function(id) { ns <- NS(id) tabItem( tabName = "document", - fluidRow( column( width = 12, - br(), - h2("Analysis always starts with documentation!"), - br(), + h2('Analysis always starts with documentation!'), p("Within UNHCR, we have distinct servers for data collection (", tags$a(href="https://kobo.unhcr.org", "KobotoolBox"), ") and for data documentation (", tags$a(href="https://ridl.unhcr.org", "RIDL"), "). This allows for check and balance in the survey data lifecyle (", tags$a(href="http://im.unhcr.org/ridl", "See RIDL Manual"), - "). Systematicaly Recording and documenting all operational data-sets in - there allows to: "), - p(" * Stop Data Loss: RIDL is a corporate system to store well-documented + "). Systematicaly Recording and documenting all operational data-sets allows to: "), + tags$ul( + tags$li( strong("Stop Data Loss"), ": RIDL is a corporate system to store well-documented data so that we can reuse what we have and ensure proper archiving of data investment."), - p("* Enhance Data Discovery: Available data-sets are searchable for + tags$li( strong("Enhance Data Discovery"), ": Available data-sets are searchable for the whole organization - allowing to showcase all data collection initiatives."), - p("* Comply with Data Sharing Standards: Providing access is essential + tags$li( strong("Comply with Data Sharing Standards"), ": Providing access is essential to benefit from remote statistical analysis support, including data curation services for anonymization and further external publication in ", - tags$a(href="http://microdata.unhcr.org", "UNHCR Micro-data Library"), - "."), + tags$a(href="http://microdata.unhcr.org", "UNHCR Micro-data Library"), + ".")) , br() - ) + ) , @@ -60,38 +57,53 @@ mod_document_ui <- function(id) { width = 12, selectInput(inputId = ns("ridlyes"), - label = " Have you already synchronised and documented - your kobo dataset in RIDL?", - choices = list("Yes", - "No"), - width = '400px'), - textInput(inputId = ns("ridl"), - label = " Enter the RIDL Dataset Name", - width = '400px'), - p( "Typically the - shortname ... - from the url after https://ridl.unhcr.org/dataset/...", - style = "font-size: 12px" ), - textInput(inputId = ns("ridltoken"), - label = " Please, paste here your personal RIDL token", - width = '400px'), - p( " so that we can get automatically the metdata, form and data from RIDL", - style = "font-size: 12px" ) - ## If yes - ask for RIDL key - and RIDL dataset ID + label = " Is your kobo dataset configured in RIDL?", + choice = c("I Have already documented my kobo dataset in RIDL" = "TRUE", + "Not yet... I will upload the files manually and + pay attention to upload the right data format" = "FALSE"), + selected = TRUE), + ## If yes - ask for RIDL key - and RIDL dataset ID + div( + id = ns("show_ridl"), + passwordInput(inputId = ns("token"), + label = "Paste below your personal RIDL API token - Note that it will be kept only for your current session"), + + verbatimTextOutput(outputId = ns("validation")), + + textInput(inputId = ns("search"), label = "Use a key word to search among all your dataset!"), + hr(), + + actionButton( inputId = ns("pull"), + label = " 1- Find", + width = "400px" , + icon = icon("magnifying-glass") ), + + selectInput( inputId = ns("ridlprojects"), + label = "Select wich RIDL Project to work in", + choice = c("Waiting for token..." = "" ), + width = "100%" ), + + actionButton( inputId = ns("pull2"), + label = " 2- Pull selected RIDL Dataset", + icon = icon("filter"), + width = "400px" ) ## Then pull metadata and display them in in a Verbatim- + ), + ## If no ask for at least the datasource name to reference in the graph + div( + id = ns("noshow_ridl"), + textInput( inputId = ns("datasource"), + label = "Provide a short name for your survey to be added + in each chart caption" ), + + ), - ## If no ask for at least the datasource name to reference in the graph - ) ) ) ) - - - - - + ) ) } @@ -100,38 +112,192 @@ mod_document_ui <- function(id) { #' @import shiny #' @import tidyverse #' @import riddle +#' @import golem +#' @import kobocruncher +#' @import dplyr #' @keywords internal mod_document_server <- function(input, output, session, AppReactiveValue) { ns <- session$ns - observeEvent(input$ridlyes, { AppReactiveValue$ridlyes <- input$ridlyes + ## No need to show budget if the population is reg and trceable is know + #print(AppReactiveValue$ridlyes) + if( AppReactiveValue$ridlyes == TRUE) { + AppReactiveValue$showridl <- TRUE + } else if( AppReactiveValue$ridlyes == FALSE) { + AppReactiveValue$showridl <- FALSE } + }) + + ## Manage visibility for RIDL mode.... + observeEvent(AppReactiveValue$showridl, { + #print( paste0( "showrild: ", AppReactiveValue$showridl, " --", ns("show_ridl"))) + if(isTRUE(AppReactiveValue$showridl)) { + golem::invoke_js("show", paste0("#", ns("show_ridl"))) + golem::invoke_js("hide", paste0("#", ns("noshow_ridl"))) + } else { + golem::invoke_js("hide", paste0("#", ns("show_ridl"))) + golem::invoke_js("show", paste0("#", ns("noshow_ridl"))) + } }) - observeEvent(input$ridl, { - AppReactiveValue$ridl <- input$ridl + observeEvent(input$token, { + AppReactiveValue$token <- input$token }) + output$validation <- renderPrint({ + validate( + need( isTruthy(input$token), # input$token != "" + message ="Token should not be empty"), + need( nchar(input$token) == 224, + message ="Token should be 224 characters" ), + need( grepl(pattern = "[a-z]", x = input$token), + message = "Token should contain at least one lower-case letter"), + # need( grepl(pattern = "[A-Z]", x = input$token), + # "Token should contain at least one upper-case letter" + # ), + need( grepl(pattern = "[:digit:]", x = input$token), + message = "Token should contain a number" ) + ) + "Token valid: Can now connect to the server...." + AppReactiveValue$token2 <- AppReactiveValue$token + }) - observeEvent(input$ridltoken, { - AppReactiveValue$ridltoken <- input$ridltoken + observeEvent(input$search, { + AppReactiveValue$search <- input$search + }) -# AppReactiveValue$dataset <- riddle::dataset_show(AppReactiveValue$ridl) -# ## ## Let's get the fifth resource within this dataset -# # test_ressources <- p[["resources"]][[1]] |> dplyr::slice(5) -# -# ## let's get again the details of the dataset we want to add the resource in -# # based on a search... -# AppReactiveValue$dataset2 <- riddle::dataset_search(AppReactiveValue$ridl) -# -# ## and now can search for it - checking it is correctly there... -# AppReactiveValue$dataset3 <-riddle::resource_search(AppReactiveValue$ridl) + + ## filtering a list of ridl datasets ########### + observeEvent(input$pull, { + ## We wait till users get to search to inject the token value.. + req(AppReactiveValue$token2) + Sys.setenv("RIDL_API_TOKEN" = AppReactiveValue$token2) + #print( Sys.getenv("RIDL_API_TOKEN")) + # print(Sys.getenv("RIDL_API_TOKEN")) + # print(AppReactiveValue$token2) + ## Check we have something in the search + req(AppReactiveValue$search) + # print(AppReactiveValue$search) + #query <- dplyr::last(AppReactiveValue$search) + AppReactiveValue$query <- dplyr::last(AppReactiveValue$search) + req(AppReactiveValue$query) + + ## some message for user... + data_message <- utils::capture.output({ + showModal(modalDialog("Working on it...", footer=NULL)) + + AppReactiveValue$dataset0 <<- riddle::dataset_search( + q = AppReactiveValue$query, + rows = 40) + }, type = "message") + removeModal() + + if(is.null(AppReactiveValue$dataset0)){ + # not successful + shinyWidgets::sendSweetAlert( + session = session, + title = "Problem with Token", + text = "Please check you used the correct one...", + type = "warning" + ) + } else { + shinyWidgets::sendSweetAlert( + session = session, + title = "Done", + text = "List of dataset is retrieved.", + type = "success" ) + } + + req(AppReactiveValue$dataset0) + AppReactiveValue$dataset <- as.data.frame(AppReactiveValue$dataset0 |> + dplyr::select(id, kobo_asset_id, + title, operational_purpose_of_data, + geographies, type)) |> + dplyr::filter( !(is.null(kobo_asset_id))) |> + dplyr::filter( !(is.na(kobo_asset_id))) |> + dplyr::filter( !(kobo_asset_id == "")) |> + dplyr::filter( type == "dataset") |> + dplyr::mutate (label = glue::glue('{title} (purpose: {operational_purpose_of_data}, {geographies}) ')) + + ## Create dropdown content with summary from ridlprojects... only when there's a linkedkoboAsset + req(AppReactiveValue$dataset) + AppReactiveValue$groupName <- AppReactiveValue$dataset |> + dplyr::pull( id) |> + purrr::set_names(AppReactiveValue$dataset |> + dplyr::pull(label) ) + ## update dropdown + updateSelectInput(session, + "ridlprojects", + choices = AppReactiveValue$groupName ) + }) + + + ## Getting the selected ridl dataset ########### + observeEvent(input$ridlprojects, { + AppReactiveValue$ridlprojects <- input$ridlprojects + }) + + + + ## filtering a list of ridl datasets ########### + observeEvent(input$pull2, { + # browser() + req(AppReactiveValue$dataset0) + AppReactiveValue$thisdataset <- AppReactiveValue$dataset0 |> + dplyr::filter( id == AppReactiveValue$ridlprojects) + # + AppReactiveValue$resources <- AppReactiveValue$thisdataset[["resources"]][[1]] + # c("cache_last_updated", "cache_url", "created", "datastore_active", + # "description", "file_type", "format", "hash", "id", "kobo_details", + # "kobo_type", "last_modified", "metadata_modified", "mimetype", + # "mimetype_inner", "name", "package_id", "position", "resource_type", + # "size", "state", "type", "url", "url_type", "visibility", "date_range_end", + # "date_range_start", "identifiability", "process_status", "version" ) + + AppReactiveValue$form <- AppReactiveValue$resources |> + dplyr::filter( file_type == "questionnaire") |> + dplyr::filter( format == "XLS") |> + dplyr::pull( url) |> + purrr::set_names(AppReactiveValue$resources |> + dplyr::filter( file_type == "questionnaire") |> + dplyr::filter( format == "XLS") |> + dplyr::mutate (label = glue::glue('{file_type} ({format}) '))|> + dplyr::pull(label) ) + # print(AppReactiveValue$form) + # ## update dropdown + updateSelectInput(session, + "ridlform", + choices = AppReactiveValue$form ) + + + AppReactiveValue$data <- AppReactiveValue$resources |> + dplyr::filter( file_type == "microdata") |> + dplyr::filter( format == "XLSX") |> + dplyr::pull( url) |> + purrr::set_names(AppReactiveValue$resources |> + dplyr::filter( file_type == "microdata") |> + dplyr::filter( format == "XLSX") |> + dplyr::mutate (label = glue::glue('{file_type} ({format}, {process_status}) '))|> + dplyr::pull(label) ) + # print(AppReactiveValue$data) + # ## update dropdown + updateSelectInput(session, + "ridldata", + choices = AppReactiveValue$data ) }) + + + + + + + + } ## copy to body.R diff --git a/R/mod_home.R b/R/mod_home.R index fb21268..a5596c1 100644 --- a/R/mod_home.R +++ b/R/mod_home.R @@ -28,7 +28,7 @@ mod_home_ui <- function(id) { " compliant data collection platform.", style = "font-size: 20px"), br(), - p( "This ",tags$span("companion app", style = "color:#00B398"), " helps configuring your data analysis plan within the + p( "This ",tags$span("companion app", style = "color:#00B398"), " helps configuring a data analysis plan within the original XlsForm that has been used to collect your dataset. The original xlsform is extended with additional columns to record your analysis settings. The advantage of this approach is that most processing is de-facto documented.