Création d'un plugin avancé

Création d’un plugin plus complexe en utilisant le template de développement d’InterHop

Template de développement

La création de plugins peuvent rapidement s’avérer complexe, en étant composé de nombreux scripts.

C’est pourquoi InterHop a créé un plugin servant de template de développement, que vous pouvez télécharger sur le catalogue de contenus d’InterHop, dans la rubrique “Plugins”.

Une fois le template téléchargé, créez un nouveau plugin à partir du template.

Ceci va créer un nouveau plugin en copiant l’ensemble des fichiers du template.

Sélectionnez ce nouveau plugin puis rendez-vous sur l’onglet “Code”. Vous devriez avoir ces scripts.

Nous retrouvons donc les trois fichiers de base composant n’importe quel plugin (qu’il n’est d’ailleurs pas possible de renommer ou supprimer) : ui.R, server.R et translations.csv.

Nous avons en plus de ceux-ci des fichiers préfixés par ui_* et server_*. Ces scripts permettent de fragmenter le code exécuté dans ui.R et server.R. Ils sont chacun importés avec la balise %import_script()%.

Lorsque le plugin sera exécuté, ne seront exécutés que les trois fichiers de base. Ces fichiers doivent donc appeler les autres fichiers composant le plugin.

Voici une partie du fichier ui.R.

%import_script('ui_load_general_settings.R')%

tagList(
    # Code retiré pour l'exemple...
    shinyjs::hidden(
        div(
            id = ns("general_settings_div_%widget_id%"),
            div(
                id = ns("general_settings_sidenav_%widget_id%"),
                save_general_settings_button,
                class = "widget_icon",
                style = "border-right: solid grey 0.5px; width: 25px;"
            ),
            %import_script('ui_general_settings.R')%,
            style = "display: flex; height: calc(100% - 40px);"
        )
    ),
    shinyjs::hidden(
        div(
            id = ns("settings_files_div_%widget_id%"),
            %import_script('ui_settings_files.R')%,
            style = "display: flex; height: calc(100% - 40px);"
        )
    )
)

Nous voyons ici que nous utilisons plusieurs fois la balise %import_script()% pour intégrer les autres scripts de l’UI. La syntaxe est simple, il suffit de mettre le nom du script avec son extension à l’intérieur d’apostrophes, tel que %import_script('ui_load_general_settings.R')% pour importer le script ui_load_general_settings.R.

Ces balises seront remplacées à l’exécution du plugin par un copier-coller du code contenu dans le script.

Pour les fichiers ui_*.R, les scripts doivent contenir du code R uniquement. Cela peut être du code R standard comme nous le verrons dans ui_load_general_settings.R, ou du code R - Shiny. Nous verrons que nous pouvons également importer des scripts Python dans server.R.

Si vous exécutez le code du plugin, vous devriez obtenir le résultat suivant.

Cette structure doit vous rappeler celle détaillée dans Créer un projet > Créer des onglets et des widgets.

Nous retrouvons les trois ou quatre icônes en haut de l’écran (selon les paramètres généraux du widget) :

  • Figure : permet d’afficher la figure ou plus globalement le résultat que le plugin est censé afficher
  • Paramètres de la figure : permet de configurer la figure à l’aide d’une interface graphique
  • Code de la figure : permet d’éditer le code R ou Python qui permet d’afficher la figure
  • Paramètres généraux : ce sont les paramètres généraux du widget, permettant par exemple d’afficher ou de masquer certains éléments

Par défaut, la page affichée est celle des paramètres de la figure côte à côte avec la figure.

ID des input

Pensez à nommer les ID de vos inputs de cette façon : ns(le_nom_de_mon_input_%widget_id%).

L'ID est contenu dans la fonction ns() et qu'il contient la balise %widget_id% en suffixe, qui sera remplacée par l'ID du widget, ce qui permet d'assurer le caractère unique de chaque éléménet de l'UI, sans quoi la page ne s'afficherait pas.

UI - interfance utilisateur / frontend

Voici le contenu du fichier ui.R en entier (susceptible de changer en fonction des versions du template de développement).

# UI - main file

## Hide save buttons if the user does not have access

if ("projects_console_access" %in% user_accesses){
    code_button <- shiny.fluent::IconButton.shinyInput(
        ns("code_button_%widget_id%"), iconProps = list(iconName = "Code"), title = i18np$t("show_code_editor"),
        onClick = htmlwidgets::JS(paste0("item => {",
            "Shiny.setInputValue('", id, "-current_tab_trigger_%widget_id%', Math.random());",
            "Shiny.setInputValue('", id, "-current_tab_%widget_id%', 'code');",
        "}"))
    )
} else code_button <- ""

if ("projects_widgets_settings" %in% user_accesses){
    save_figure_settings_buttons <- shiny.fluent::IconButton.shinyInput(ns("save_params_and_code_%widget_id%"), iconProps = list(iconName = "Save"), title = i18np$t("save_figure_settings_and_code"), style = "margin: 0")
    save_general_settings_button <- shiny.fluent::IconButton.shinyInput(ns("save_general_settings_button_%widget_id%"), iconProps = list(iconName = "Save"), title = i18np$t("save_general_settings"), style = "margin: 0")
} else {
    save_figure_settings_buttons <- ""
    save_general_settings_button <- ""   
}

%import_script('ui_load_general_settings.R')%

tagList(
    div(
        shinyjs::hidden(
            div(
                id = ns("figure_button_div_%widget_id%"),
                shiny.fluent::IconButton.shinyInput(
                    ns("figure_button_%widget_id%"), iconProps = list(iconName = "BarChart4"), title = i18np$t("show_figure"),
                    onClick = htmlwidgets::JS(paste0("item => {",
                        "Shiny.setInputValue('", id, "-current_tab_trigger_%widget_id%', Math.random());",
                        "Shiny.setInputValue('", id, "-current_tab_%widget_id%', 'figure');",
                    "}"))
                )
            )
        ),
        shiny.fluent::IconButton.shinyInput(
            ns("figure_settings_button_%widget_id%"), iconProps = list(iconName = "AllApps"), title = i18np$t("show_figure_settings"),
            onClick = htmlwidgets::JS(paste0("item => {",
                "Shiny.setInputValue('", id, "-current_tab_trigger_%widget_id%', Math.random());",
                "Shiny.setInputValue('", id, "-current_tab_%widget_id%', 'figure_settings');",
            "}"))
        ),
        code_button,
        shiny.fluent::IconButton.shinyInput(
            ns("general_settings_button_%widget_id%"), iconProps = list(iconName = "Settings"), title = i18np$t("show_general_settings"),
            onClick = htmlwidgets::JS(paste0("item => {",
                "Shiny.setInputValue('", id, "-current_tab_trigger_%widget_id%', Math.random());",
                "Shiny.setInputValue('", id, "-current_tab_%widget_id%', 'general_settings');",
            "}"))
        ),
        uiOutput(
            ns("settings_files_ui_%widget_id%"),
            onclick = paste0("Shiny.setInputValue('", id, "-show_settings_files_tab_%widget_id%', Math.random())")
        ),
        class = "widget_icon",
        style = "display: flex; color: #808080; border-bottom: solid grey 0.5px; height: 28px; padding: 5px 0 0 5px; font-size: 12px;"
    ),
    div(
        id = ns("figure_settings_code_div_%widget_id%"),
        div(
            id = ns("figure_settings_code_sidenav_%widget_id%"),
            shiny.fluent::IconButton.shinyInput(ns("display_figure_%widget_id%"), iconProps = list(iconName = "Play"), title = i18np$t("display_figure"), style = "margin: 0"),
            save_figure_settings_buttons,
            class = "widget_icon",
            style = "border-right: solid grey 0.5px; width: 25px; padding-left: 5px;"
        ),
        div(
            id = ns("figure_div_%widget_id%"),
            %import_script('ui_figure.R')%,
            style = paste0("height: 100%; flex-basis: ", div_width, "; flex: 1; box-sizing: border-box; min-width: 50px;"),
            class = "left-panel"
        ),
        div(
            id = ns("resizer_%widget_id%"),
            style = "width: 5px; cursor: col-resize; background-color: #ccc;",
            class = "resizer"
        ),
        div(
            id = ns("figure_settings_div_%widget_id%"),
            %import_script('ui_figure_settings.R')%,
            style = paste0("height: 100%; flex-basis: ", div_width, "%; padding: 5px; overflow: auto; flex: 1; box-sizing: border-box;")
        ),
        shinyjs::hidden(
            div(
                id = ns("code_div_%widget_id%"),
                %import_script('ui_code.R')%,
                style = paste0("height: 100%; flex-basis: ", div_width, "%; overflow: auto; flex: 1; box-sizing: border-box;"),
                class = "right-panel"
            )
        ),
        style = "display: flex; height: calc(100% - 34px);"
    ),
    shinyjs::hidden(
        div(
            id = ns("general_settings_div_%widget_id%"),
            %import_script('ui_general_settings.R')%,
            style = "height: calc(100% - 40px);"
        )
    ),
    shinyjs::hidden(
        div(
            id = ns("settings_files_div_%widget_id%"),
            %import_script('ui_settings_files.R')%,
            style = "display: flex; height: calc(100% - 40px);"
        )
    )
)

Prenons ces parties une par une.

## Hide save buttons if the user does not have access

if ("projects_console_access" %in% user_accesses){
    code_button <- shiny.fluent::IconButton.shinyInput(
        ns("code_button_%widget_id%"), iconProps = list(iconName = "Code"), title = i18np$t("show_code_editor"),
        onClick = htmlwidgets::JS(paste0("item => {",
            "Shiny.setInputValue('", id, "-current_tab_trigger_%widget_id%', Math.random());",
            "Shiny.setInputValue('", id, "-current_tab_%widget_id%', 'code');",
        "}"))
    )
} else code_button <- ""

if ("projects_widgets_settings" %in% user_accesses){
    save_figure_settings_buttons <- shiny.fluent::IconButton.shinyInput(ns("save_params_and_code_%widget_id%"), iconProps = list(iconName = "Save"), title = i18np$t("save_figure_settings_and_code"), style = "margin: 0")
    save_general_settings_button <- shiny.fluent::IconButton.shinyInput(ns("save_general_settings_button_%widget_id%"), iconProps = list(iconName = "Save"), title = i18np$t("save_general_settings"), style = "margin: 0")
} else {
    save_figure_settings_buttons <- ""
    save_general_settings_button <- ""   
}

Les droits de l’utilisateur sont disponibles dans la variable user_accesses. Ceci nous permet de :

  • masquer le bouton d’édition du code si l’utilisateur n’a pas accès à la console dans les projets
  • masquer les boutons d’édition des widgets si l’utilisateur n’a pas ce droit

Ensuite nous importons le fichier ui_load_general_settings.R avec

%import_script('ui_load_general_settings.R')%

Nous y reviendrons plus tard.

Détaillons les différents éléments du tagList :

UI du header

div(
    shinyjs::hidden(
        div(
            id = ns("figure_button_div_%widget_id%"),
            shiny.fluent::IconButton.shinyInput(
                ns("figure_button_%widget_id%"), iconProps = list(iconName = "BarChart4"), title = i18np$t("show_figure"),
                onClick = htmlwidgets::JS(paste0("item => {",
                    "Shiny.setInputValue('", id, "-current_tab_trigger_%widget_id%', Math.random());",
                    "Shiny.setInputValue('", id, "-current_tab_%widget_id%', 'figure');",
                "}"))
            )
        )
    ),
    shiny.fluent::IconButton.shinyInput(
        ns("figure_settings_button_%widget_id%"), iconProps = list(iconName = "AllApps"), title = i18np$t("show_figure_settings"),
        onClick = htmlwidgets::JS(paste0("item => {",
            "Shiny.setInputValue('", id, "-current_tab_trigger_%widget_id%', Math.random());",
            "Shiny.setInputValue('", id, "-current_tab_%widget_id%', 'figure_settings');",
        "}"))
    ),
    code_button,
    shiny.fluent::IconButton.shinyInput(
        ns("general_settings_button_%widget_id%"), iconProps = list(iconName = "Settings"), title = i18np$t("show_general_settings"),
        onClick = htmlwidgets::JS(paste0("item => {",
            "Shiny.setInputValue('", id, "-current_tab_trigger_%widget_id%', Math.random());",
            "Shiny.setInputValue('", id, "-current_tab_%widget_id%', 'general_settings');",
        "}"))
    ),
    uiOutput(
        ns("settings_files_ui_%widget_id%"),
        onclick = paste0("Shiny.setInputValue('", id, "-show_settings_files_tab_%widget_id%', Math.random())")
    ),
    class = "widget_icon",
    style = "display: flex; color: #808080; border-bottom: solid grey 0.5px; height: 28px; padding: 5px 0 0 5px; font-size: 12px;"
)

Il s’agit du code permettant d’afficher les icônes du header du plugin. Notez que l’on utilise la variable code_button définie plue haut, afin de masquer ce bouton si l’utilisateur n’a pas accès. Nous utilisons la classe widget_icon pour le CSS des icônes.

Nous utilisons des triggers avec onclick (pour les fonctions Shiny) ou OnClick (pour les fonctions shiny.fluent) qui vont permettre de modifier la valeur de input$current_tab_%widget_id% afin de changer l’onglet du widget affiché.

input$show_settings_files_tab_%widget_id% permettra d’afficher la page des fichiers de sauvegarde lorsque l’on cliquera sur le nom du fichier de sauvegarde dans le header (en cliquant sur “Pas de fichier de sauvegarde sélectionné” sur la capture d’écran ci-dessus).

UI de la figure, des paramètres et du code de la figure

div(
    id = ns("figure_settings_code_div_%widget_id%"),
    div(
        id = ns("figure_settings_code_sidenav_%widget_id%"),
        shiny.fluent::IconButton.shinyInput(ns("display_figure_%widget_id%"), iconProps = list(iconName = "Play"), title = i18np$t("display_figure"), style = "margin: 0"),
        save_figure_settings_buttons,
        class = "widget_icon",
        style = "border-right: solid grey 0.5px; width: 25px; padding-left: 5px;"
    ),
    div(
        id = ns("figure_div_%widget_id%"),
        %import_script('ui_figure.R')%,
        style = paste0("height: 100%; flex-basis: ", div_width, "; flex: 1; box-sizing: border-box; min-width: 50px;"),
        class = "left-panel"
    ),
    div(
        id = ns("resizer_%widget_id%"),
        style = "width: 5px; cursor: col-resize; background-color: #ccc;",
        class = "resizer"
    ),
    div(
        id = ns("figure_settings_div_%widget_id%"),
        %import_script('ui_figure_settings.R')%,
        style = paste0("height: 100%; flex-basis: ", div_width, "%; padding: 5px; overflow: auto; flex: 1; box-sizing: border-box;")
    ),
    shinyjs::hidden(
        div(
            id = ns("code_div_%widget_id%"),
            %import_script('ui_code.R')%,
            style = paste0("height: 100%; flex-basis: ", div_width, "%; overflow: auto; flex: 1; box-sizing: border-box;"),
            class = "right-panel"
        )
    ),
    style = "display: flex; height: calc(100% - 34px);"
)

Le premier div permet d’afficher les boutons de la barre latérale gauche, avec le bouton pour afficher la figure et le bouton pour sauvegarder les paramètres et le code de la figure.

div(
    id = ns("figure_settings_code_sidenav_%widget_id%"),
    shiny.fluent::IconButton.shinyInput(ns("display_figure_%widget_id%"), iconProps = list(iconName = "Play"), title = i18np$t("display_figure"), style = "margin: 0"),
    save_figure_settings_buttons,
    class = "widget_icon",
    style = "border-right: solid grey 0.5px; width: 25px; padding-left: 5px;"
)

Notez que l’on utilise save_figure_settings_buttons afin de masquer le bouton de sauvegarde si l’utilisateur n’a pas l’accès.

  1. UI de la figure

Le script de l’UI de la figure est importé dans ce code.

div(
    id = ns("figure_div_%widget_id%"),
    %import_script('ui_figure.R')%,
    style = paste0("height: 100%; flex-basis: ", div_width, "; flex: 1; box-sizing: border-box; min-width: 50px;"),
    class = "left-panel"
)

Regardons le fichier ui_figure.R.

# UI - Figure page
#
# Insert the UI components for the figure in this section.

div(
    shinyjs::hidden(
        div(
            id = ns("error_message_div_%widget_id%"),
            uiOutput(ns("error_message_%widget_id%")),
            style = "padding-top: 10px;"
        )
    ),
    # Div containing the figure output
    style = "width: 100%; height: 100%;"
)

On a un uiOutput pour afficher un message d’erreur, que nous verrons dans le script backend correspondant.

C’est à vous d’ajouter l’UI le code pour l’output comme vous l’entendez, avec une fonction d’output Shiny.

Ce tableau rassemble les fonctions d’output proposées par Shiny, mais il en existe d’autres, proposées par des librairies de visualisation, telle que plotlyOutput.

  1. UI des paramètres de la figure

Le script de l’UI des paramètres de la figure est importé dans ce code.

div(
    id = ns("figure_settings_div_%widget_id%"),
    %import_script('ui_figure_settings.R')%,
    style = paste0("height: 100%; flex-basis: ", div_width, "%; margin: 5px 10px; overflow: auto; flex: 1; box-sizing: border-box;")
)

Notez que nous avons entre le code de la figure et le code des paramètres de la figure un resizer, qui permettra d’ajuster la taille des deux blocs lorsqu’ils seront affichés côte à côte.

div(
    id = ns("resizer_%widget_id%"),
    style = "width: 5px; cursor: col-resize; background-color: #ccc;",
    class = "resizer"
)

Voici le contenu du fichier ui_figure_settings.R.

# UI - Figure settings
#
# Insert the UI components for configuring the figure settings in this section.

div(
    
)

Cette UI est donc vide, à vous d’y ajouter les éléments permettant de configurer la figure, avec les fonctions d’input Shiny ou celles de shiny.fluent.

  1. UI du code de la figure

Et finalement, voici le code permettant d’importer l’UI du code de la figure.

shinyjs::hidden(
    div(
        id = ns("code_div_%widget_id%"),
        %import_script('ui_code.R')%,
        style = paste0("height: 100%; flex-basis: ", div_width, "%; overflow: auto; flex: 1; box-sizing: border-box;"),
        class = "right-panel"
    )
)

On voit que le code est encapsulé dans une fonction shiny.hidden, ce qui lui attribura un style avec display: none par défaut. Ceci permet au chargement du widget d’afficher la figure et les paramètres de la figure côte à côte en masquant le bloc avec l’éditeur de code.

Voici le contenu de ui_code.R.

# UI - Code page

shinyAce::aceEditor(
    ns("code_%widget_id%"), value = "", mode = "r",
    hotkeys = list(
        save = list(win = "CTRL-S", mac = "CTRL-S|CMD-S"),
        run_all = list(win = "CTRL-SHIFT-ENTER", mac = "CTRL-SHIFT-ENTER|CMD-SHIFT-ENTER"),
        comment = list(win = "CTRL-SHIFT-C", mac = "CTRL-SHIFT-C|CMD-SHIFT-C")
    ),
    autoScrollEditorIntoView = TRUE, height = "100%", debounce = 100, fontSize = 11, showPrintMargin = FALSE
)

Comme partout dans LinkR, nous utilisons les éditeurs de texte Ace avec la fonction shinyAce.

On utilise ici les mêmes raccourcis qu’ailleurs sur LinkR, pour sauvegarder le code, le commenter et l’exécuter. Le debounce à 100 permet que l’input correspondant à l’éditeur soit mis à jour fréquemment.

UI des paramètres généraux du widget

L’UI des paramètres généraux est généré avec ce code.

shinyjs::hidden(
    div(
        id = ns("general_settings_div_%widget_id%"),
        %import_script('ui_general_settings.R')%,
        style = "height: calc(100% - 40px);"
    )
)

Voici le contenu de ui_general_settings.R.

# UI - General settings
#
# Include the UI components for the general settings in this section.
# If you add any fields here, ensure that you also update server_general_settings.R and ui_general_settings.R accordingly.

div(
    div(
        tags$strong(i18np$t("display")), br(),
        div(
            shiny.fluent::Toggle.shinyInput(
                ns("show_settings_file_%widget_id%"), value = toggle_values$show_settings_file,
                onClick = htmlwidgets::JS(paste0("item => {Shiny.setInputValue('", id, "-save_general_settings_%widget_id%', Math.random());}"))
            ),
            tags$label(i18np$t("show_settings_file"), `for` = ns("show_settings_file_%widget_id%"), style = "margin-left: 5px;"),
            style = "display: flex; margin-top: 8px;" 
        ),
        div(
            shiny.fluent::Toggle.shinyInput(
                ns("figure_and_settings_side_by_side_%widget_id%"), value = toggle_values$figure_and_settings_side_by_side,
                onClick = htmlwidgets::JS(paste0("item => {Shiny.setInputValue('", id, "-save_general_settings_%widget_id%', Math.random());}"))
            ),
            tags$label(i18np$t("figure_and_settings_side_by_side"), `for` = ns("figure_and_settings_side_by_side_%widget_id%"), style = "margin-left: 5px;"),
            style = "display: flex; margin-top: 5px;" 
        )
    ),
#     div(
#         tags$strong(i18np$t("code_execution")), br(),
#         div(
#             shiny.fluent::Toggle.shinyInput(
#                 ns("run_code_at_settings_file_load_%widget_id%"), value = toggle_values$run_code_at_settings_file_load,
#                 onClick = htmlwidgets::JS(paste0("item => {Shiny.setInputValue('", id, "-save_general_settings_%widget_id%', Math.random());}"))
#             ),
#             tags$label(i18np$t("run_code_at_settings_file_load"), `for` = ns("run_code_at_settings_file_load_%widget_id%"), style = "margin-left: 5px;"),
#             style = "display: flex; margin-top: 8px;" 
#         ),
#         div(
#             shiny.fluent::Toggle.shinyInput(
#                 ns("run_code_on_data_update_%widget_id%"), value = toggle_values$run_code_on_data_update,
#                 onClick = htmlwidgets::JS(paste0("item => {Shiny.setInputValue('", id, "-save_general_settings_%widget_id%', Math.random());}"))
#             ),
#             tags$label(i18np$t("run_code_on_data_update"), `for` = ns("run_code_on_data_update_%widget_id%"), style = "margin-left: 5px;"),
#             style = "display: flex; margin-top: 5px;" 
#         ),
#         style = "margin-top: 10px;"
#     ),
    style = "padding: 10px;"
)

Nous affichons par défaut les toggles permettant de gérer l’affichage. Les toggles permettant de gérer l’exécution du code sont par défaut mis en commentaires.

Nous voyons que les valeurs sont définies par la variable toggle_values, qui est déclarée dans ui_load_general_settings.R, que voici :

# UI - Load general settings
#
# Insert the UI components for configuring the figure settings here.

sql <- glue::glue_sql("SELECT link_id, name, value, value_num FROM widgets_options WHERE widget_id = %widget_id% AND category = 'general_settings'", .con = m$db)
general_settings <- DBI::dbGetQuery(m$db, sql)

toggle_values <- list()

if (nrow(general_settings) == 0){

    toggle_values$show_settings_file <- TRUE
    toggle_values$figure_and_settings_side_by_side <- TRUE
    toggle_values$run_code_on_data_update <- FALSE
    toggle_values$run_code_at_settings_file_load <- FALSE
    
    dropdown_options <- list()
    selected_file <- NULL
    
} else if (nrow(general_settings) > 0){

    # Toggles values
    
    # general_settings_vec <- c("show_saved_file", "figure_and_settings_side_by_side", "run_code_on_data_update", "run_code_at_settings_file_load")
    general_settings_vec <- c("show_settings_file", "figure_and_settings_side_by_side")
    
    sapply(general_settings_vec, function(name){
        
        toggle_value <- FALSE
        
        row <- general_settings %>% dplyr::filter(name == !!name)
        if (nrow(row) > 0){
            if (is.na(row %>% dplyr::pull(value_num))) toggle_value <- FALSE
            else (toggle_value <- as.logical(row %>% dplyr::pull(value_num)))
        }
        
        toggle_values[[name]] <<- toggle_value
    })
    
    # Selected settings file
    
    sql <- glue::glue_sql("SELECT id, value AS name FROM widgets_options WHERE widget_id = %widget_id% AND category = 'settings_files' AND name = 'file_name'", .con = m$db)
    m$settings_filenames_%widget_id% <- DBI::dbGetQuery(m$db, sql)
    dropdown_options <- convert_tibble_to_list(m$settings_filenames_%widget_id%, key_col = "id", text_col = "name")
    selected_file <- general_settings %>% dplyr::filter(name == "selected_file_id") %>% dplyr::pull(value_num)
}

if (toggle_values$figure_and_settings_side_by_side) div_width <- "50%" else div_width <- "100%"

On requête la base de données de l’application pour aller chercher les valeurs enregistrées des paramètres généraux.

UI des fichiers de sauvegarde

L’UI des fichiers de sauvegarde est généré avec ce code.

shinyjs::hidden(
    div(
        id = ns("settings_files_div_%widget_id%"),
        %import_script('ui_settings_files.R')%,
        style = "display: flex; height: calc(100% - 40px);"
    )
)

Voici le contenu de ui_settings_files.R.

# UI - Settings files
#
# This code corresponds to the page that appears when you click on the file icon at the top of the widget.
# The UI allows you to select, add, and delete settings files.

tagList(

    # Add settings file modal
    shinyjs::hidden(
      div(
        id = ns("add_settings_file_modal_%widget_id%"),
        div(
            div(
                tags$h1(i18np$t("create_settings_file"), style = "font-size: 14px;"),
                shiny.fluent::IconButton.shinyInput(ns("close_add_settings_file_modal_%widget_id%"), iconProps = list(iconName = "ChromeClose")),
                style = "display: flex; justify-content: space-between;",
                class = "small_close_button"
            ),
            div(shiny.fluent::TextField.shinyInput(ns("settings_file_name_%widget_id%"), label = i18np$t("file_name")), style = "width: 200px;"),
            div(
                shiny.fluent::PrimaryButton.shinyInput(ns("add_settings_file_%widget_id%"), i18np$t("add")),
                style = "position: absolute; right: 10px; bottom: 8px;"
            ),
            style = "background: #fff; padding: 5px 10px 10px 15px; position: relative; width: 400px; height: 120px;"
        ),
        style = "display: flex; align-items: center; justify-content: center; position: absolute; left: 0; top: 0;  width: 100%; height: 100%; background-color: rgba(0,0,0,0.2); z-index: 1000;"
      )
    ),
    
    # Delete settings file modal
    shinyjs::hidden(
      div(
        id = ns("delete_settings_file_modal_%widget_id%"),
        div(
            tags$h1(i18np$t("delete_settings_file_title"), style = "font-size: 14px;"),
            tags$p(i18np$t("delete_settings_file_text")),
            div(
                shiny.fluent::DefaultButton.shinyInput(ns("close_file_deletion_modal_%widget_id%"), i18np$t("dont_delete")),
                div(shiny.fluent::PrimaryButton.shinyInput(ns("confirm_file_deletion_%widget_id%"), i18np$t("delete")), class = "delete_button"),
                style = "position: absolute; right: 10px; bottom: 8px; display: flex; gap: 5px;"
            ),
            style = "background: #fff; padding: 5px 10px 10px 15px; position: relative; width: 400px; height: 120px;"
        ),
        style = "display: flex; align-items: center; justify-content: center; position: absolute; left: 0; top: 0;  width: 100%; height: 100%; background-color: rgba(0,0,0,0.2); z-index: 1000;"
      )
    ),
    
    # Sidenav
    div(
        id = ns("settings_files_sidenav_%widget_id%"),
        shiny.fluent::IconButton.shinyInput(ns("create_settings_file_%widget_id%"), iconProps = list(iconName = "Add"), title = i18np$t("create_settings_file"), style = "margin: 0"),
        class = "widget_icon",
        style = "border-right: solid grey 0.5px; padding-left: 5px;"
    ),
    
    # Dropdown and delete button
    div(
        div(shiny.fluent::Dropdown.shinyInput(ns("settings_file_%widget_id%"), label = i18np$t("file"), options = dropdown_options, value = selected_file), style = "width: 200px"),
        shinyjs::hidden(
            div(
                id = ns("delete_settings_file_div_%widget_id%"),
                shiny.fluent::IconButton.shinyInput(ns("delete_settings_file_%widget_id%"), iconProps = list(iconName = "Delete")),
                style = "margin-top: 26px;", class = "widget_icon"
            )
        ),
        style = "display: flex; gap: 5px; margin: 5px 10px;"
    )
)

Prenons d’abord les deux derniers blocs, affichés par défaut.

# Sidenav
div(
    id = ns("settings_files_sidenav_%widget_id%"),
    shiny.fluent::IconButton.shinyInput(ns("create_settings_file_%widget_id%"), iconProps = list(iconName = "Add"), title = i18np$t("create_settings_file"), style = "margin: 0"),
    class = "widget_icon",
    style = "border-right: solid grey 0.5px; padding-left: 5px;"
),

# Dropdown and delete button
div(
    div(shiny.fluent::Dropdown.shinyInput(ns("settings_file_%widget_id%"), label = i18np$t("file"), options = dropdown_options, value = selected_file), style = "width: 200px"),
    shinyjs::hidden(
        div(
            id = ns("delete_settings_file_div_%widget_id%"),
            shiny.fluent::IconButton.shinyInput(ns("delete_settings_file_%widget_id%"), iconProps = list(iconName = "Delete")),
            style = "margin-top: 26px;", class = "widget_icon"
        )
    ),
    style = "display: flex; gap: 5px; margin: 5px 10px;"
)

Ce code nous permet d’afficher le bouton “+” latéralement pour afficher un fichier et le menu déroulant permettant de choisir le fichier.

Nous avons ensuite le modal permettant de créer un fichier, avec le bouton permettant de fermer le modal, l’input de texte et le bouton permettant de valider l’ajout du fichier.

# Add settings file modal
shinyjs::hidden(
    div(
    id = ns("add_settings_file_modal_%widget_id%"),
    div(
        div(
            tags$h1(i18np$t("create_settings_file"), style = "font-size: 14px;"),
            shiny.fluent::IconButton.shinyInput(ns("close_add_settings_file_modal_%widget_id%"), iconProps = list(iconName = "ChromeClose")),
            style = "display: flex; justify-content: space-between;",
            class = "small_close_button"
        ),
        div(shiny.fluent::TextField.shinyInput(ns("settings_file_name_%widget_id%"), label = i18np$t("file_name")), style = "width: 200px;"),
        div(
            shiny.fluent::PrimaryButton.shinyInput(ns("add_settings_file_%widget_id%"), i18np$t("add")),
            style = "position: absolute; right: 10px; bottom: 8px;"
        ),
        style = "background: #fff; padding: 5px 10px 10px 15px; position: relative; width: 400px; height: 120px;"
    ),
    style = "display: flex; align-items: center; justify-content: center; position: absolute; left: 0; top: 0;  width: 100%; height: 100%; background-color: rgba(0,0,0,0.2); z-index: 1000;"
    )
)

Et finalement, le code du modal permettant de confirmer la suppression du fichier.

# Delete settings file modal
shinyjs::hidden(
    div(
    id = ns("delete_settings_file_modal_%widget_id%"),
    div(
        tags$h1(i18np$t("delete_settings_file_title"), style = "font-size: 14px;"),
        tags$p(i18np$t("delete_settings_file_text")),
        div(
            shiny.fluent::DefaultButton.shinyInput(ns("close_file_deletion_modal_%widget_id%"), i18np$t("dont_delete")),
            div(shiny.fluent::PrimaryButton.shinyInput(ns("confirm_file_deletion_%widget_id%"), i18np$t("delete")), class = "delete_button"),
            style = "position: absolute; right: 10px; bottom: 8px; display: flex; gap: 5px;"
        ),
        style = "background: #fff; padding: 5px 10px 10px 15px; position: relative; width: 400px; height: 120px;"
    ),
    style = "display: flex; align-items: center; justify-content: center; position: absolute; left: 0; top: 0;  width: 100%; height: 100%; background-color: rgba(0,0,0,0.2); z-index: 1000;"
    )
)

Passons maintenant à la partie backend de notre plugin.

Serveur / backend

Invalidation des observers

Pensez à systématiquement ajouter une balise %req% au début des observers, ce qui permet d'invalider les anciens observers.
Pensez également à ajouter un message avec if (debug) ... cat(). Ceci permet de localiser les bugs à partir du fichier de log.


Voici le contenu du fichier server.R en entier.

# Server - Main file

# Tabs

## All tabs
tabs <- c("figure", "figure_settings", "code", "general_settings")

observeEvent(input$current_tab_trigger_%widget_id%, {
    %req%
    if (debug) cat(paste0("\n", now(), " - mod_", id, " - widget_id = %widget_id% - observer input$current_tab_trigger"))
    
    tab <- input$current_tab_%widget_id%
    
    tryCatch({
        if (tab == "general_settings") shinyjs::hide("figure_settings_code_div_%widget_id%")
        else shinyjs::show("figure_settings_code_div_%widget_id%")
        
        sapply(paste0(setdiff(c("figure_settings", "code", "general_settings"), tab), "_div_%widget_id%"), shinyjs::hide)
        shinyjs::hide("settings_files_div_%widget_id%")
        
        if (tab != "code" | (tab == "code" & "projects_console_access" %in% user_accesses)) shinyjs::show(paste0(tab, "_div_%widget_id%"))
        
        if (tab %in% c("figure_settings", "code")){
            if (length(input$figure_and_settings_side_by_side_%widget_id%) > 0) if (input$figure_and_settings_side_by_side_%widget_id%) shinyjs::show("figure_div_%widget_id%")
            else shinyjs::hide("figure_div_%widget_id%")
            
            shinyjs::show("figure_settings_code_sidenav_%widget_id%")
            
            if (tab == "figure_settings") anti_tab <- "code"
            else anti_tab <- "figure_settings"
            
            shinyjs::runjs(paste0("
                var figureSettingsDiv = document.getElementById('", id, "-figure_settings_div_%widget_id%');
                var codeDiv = document.getElementById('", id, "-code_div_%widget_id%');
                
                if ('", tab, "' === 'figure_settings') {
                    figureSettingsDiv.style.flexBasis = codeDiv.style.flexBasis;
                } else {
                    codeDiv.style.flexBasis = figureSettingsDiv.style.flexBasis;
                }
            "))
        }
        else {
            shinyjs::hide("figure_settings_code_sidenav_%widget_id%")
            if (tab != "figure") shinyjs::hide("figure_div_%widget_id%")
        }
        
        # Prevent a bug with scroll into ace editor
        shinyjs::runjs("var event = new Event('resize'); window.dispatchEvent(event);")
        
    }, error = function(e) cat(paste0("\n", now(), " - widget %widget_id% - error = ", toString(e))))
})

# Figure settings
%import_script('server_figure_settings.R')%

# Code
%import_script('server_code.R')%

# Settings files
%import_script('server_settings_files.R')%

# General settings
%import_script('server_general_settings.R')%

Backend des onglets

Le code permettant d’afficher ou de masquer chaque onglet est le suivant :

## All tabs
tabs <- c("figure", "figure_settings", "code", "general_settings")

observeEvent(input$current_tab_trigger_%widget_id%, {
    %req%
    if (debug) cat(paste0("\n", now(), " - mod_", id, " - widget_id = %widget_id% - observer input$current_tab_trigger"))
    
    tab <- input$current_tab_%widget_id%
    
    tryCatch({
        if (tab == "general_settings") shinyjs::hide("figure_settings_code_div_%widget_id%")
        else shinyjs::show("figure_settings_code_div_%widget_id%")
        
        sapply(paste0(setdiff(c("figure_settings", "code", "general_settings"), tab), "_div_%widget_id%"), shinyjs::hide)
        shinyjs::hide("settings_files_div_%widget_id%")
        
        if (tab != "code" | (tab == "code" & "projects_console_access" %in% user_accesses)) shinyjs::show(paste0(tab, "_div_%widget_id%"))
        
        if (tab %in% c("figure_settings", "code")){
            if (length(input$figure_and_settings_side_by_side_%widget_id%) > 0) if (input$figure_and_settings_side_by_side_%widget_id%) shinyjs::show("figure_div_%widget_id%")
            else shinyjs::hide("figure_div_%widget_id%")
            
            shinyjs::show("figure_settings_code_sidenav_%widget_id%")
            
            if (tab == "figure_settings") anti_tab <- "code"
            else anti_tab <- "figure_settings"
            
            shinyjs::runjs(paste0("
                var figureSettingsDiv = document.getElementById('", id, "-figure_settings_div_%widget_id%');
                var codeDiv = document.getElementById('", id, "-code_div_%widget_id%');
                
                if ('", tab, "' === 'figure_settings') {
                    figureSettingsDiv.style.flexBasis = codeDiv.style.flexBasis;
                } else {
                    codeDiv.style.flexBasis = figureSettingsDiv.style.flexBasis;
                }
            "))
        }
        else {
            shinyjs::hide("figure_settings_code_sidenav_%widget_id%")
            if (tab != "figure") shinyjs::hide("figure_div_%widget_id%")
        }
        
        # Prevent a bug with scroll into ace editor
        shinyjs::runjs("var event = new Event('resize'); window.dispatchEvent(event);")
        
    }, error = function(e) cat(paste0("\n", now(), " - widget %widget_id% - error = ", toString(e))))
})

Ce code correspond à l’UI de l’header, où il existe un bouton par onglet : pour afficher l’onglet de la figure, celui des paramètres de la figure, du code et des paramètres généraux du plugin.

Les noms des onglets sont contenus dans la variable tabs. Un observer sur input$current_tab_trigger_%widget_id% permet de déclencher le code lorsque l’on clique sur une icône du header. L’affichage des onglets du widget sera réalisé avec shinyjs::show() ou shinyjs::hide() pour afficher ou masquer les éléments de l’UI.

Il est prévu également d’afficher l’onglet figure si l’option pour afficher la figure et les paramètres / le code de la figure côte à côte est cochée, et que l’onglet paramètres de la figure ou code de la figure est sélectionné.

Le code Javascript exécuté dans shinyjs::runjs permet, lorsque la barre, qui sépare le div de la figure et celui soit du code soit des paramètres de la figure, est changée de position, d’appliquer la nouvelle largeur à l’autre élément (au div figure_settings si on a changé la largeur du div code et inversement).

Backend des paramètres de la figure

Le code de server_figure_settings.R est le suivant.

# Server - Figure settings

# Load figure settings

observeEvent(input$load_figure_settings_%widget_id%, {
    %req%
    if (debug) cat(paste0("\\n", now(), " - mod_", id, " - widget_id = %widget_id% - observer input$load_figure_settings"))
    
    # Update figure settings UI
    
    link_id <- input$settings_file_%widget_id%
    sql <- glue::glue_sql("SELECT name, value, value_num FROM widgets_options WHERE widget_id = %widget_id% AND category = 'figure_settings' AND link_id = {link_id}", .con = m$db)
    figure_settings <- DBI::dbGetQuery(m$db, sql)
    
    if (nrow(figure_settings) > 0){
        sapply(figure_settings$name, function(name){
        
            value <- figure_settings %>% dplyr::filter(name == !!name) %>% dplyr::pull(value)
            value_num <- figure_settings %>% dplyr::filter(name == !!name) %>% dplyr::pull(value_num)
            
            # Update figure settings UI here with loaded figure settings
        })
    }
    
    # Run code if toggle is activated
    # if (length(input$run_code_at_settings_file_load_%widget_id%) > 0){
    #     if (input$run_code_at_settings_file_load_%widget_id%){
    #        shinyjs::delay(500, shinyjs::runjs(paste0("Shiny.setInputValue('", id, "-display_figure_%widget_id%', Math.random());")))
    #    }
    # }
})

# Save current settings

observeEvent(input$save_params_and_code_%widget_id%, {
    %req%
    if (debug) cat(paste0("\\n", now(), " - mod_", id, " - widget_id = %widget_id% - observer input$save_params_and_code"))
    
    tryCatch({
    
        # If no settings file is selected, go to settings files management page
        if (length(input$settings_file_%widget_id%) == 0) shinyjs::runjs(paste0("Shiny.setInputValue('", id, "-show_settings_files_tab_%widget_id%', Math.random());"))
        
        if (length(input$settings_file_%widget_id%) > 0){
            
            link_id <- input$settings_file_%widget_id%
        
            # Delete old settings
            sql_send_statement(m$db, glue::glue_sql("DELETE FROM widgets_options WHERE widget_id = %widget_id% AND category = 'figure_settings' AND link_id = {link_id}", .con = m$db))
            
            # Add new settings in db
            
            # new_data <- tibble::tribble(
            #     ~name, ~value, ~value_num,
            #     ...
            # )
            
            # new_data <-
            #     new_data %>%
            #     dplyr::transmute(
            #         id = get_last_row(m$db, "widgets_options") + 1:nrow(new_data), widget_id = %widget_id%, person_id = NA_integer_, link_id = link_id,
            #         category = "figure_settings", name, value, value_num, creator_id = m$user_id, datetime = now(), deleted = FALSE
            #     )
            
            # DBI::dbAppendTable(m$db, "widgets_options", new_data)
            
            # Notify user
            show_message_bar(output, "modif_saved", "success", i18n = i18n, ns = ns)
        }
        
    }, error = function(e) cat(paste0("\\n", now(), " - widget %widget_id% - error = ", toString(e))))
})

Commençons par la première partie du code qui permet de charger les paramètres de la figure sauvegardés.

observeEvent(input$load_figure_settings_%widget_id%, {
    %req%
    if (debug) cat(paste0("\\n", now(), " - mod_", id, " - widget_id = %widget_id% - observer input$load_figure_settings"))
    
    # Update figure settings UI
    
    link_id <- input$settings_file_%widget_id%
    sql <- glue::glue_sql("SELECT name, value, value_num FROM widgets_options WHERE widget_id = %widget_id% AND category = 'figure_settings' AND link_id = {link_id}", .con = m$db)
    figure_settings <- DBI::dbGetQuery(m$db, sql)
    
    if (nrow(figure_settings) > 0){
        sapply(figure_settings$name, function(name){
        
            value <- figure_settings %>% dplyr::filter(name == !!name) %>% dplyr::pull(value)
            value_num <- figure_settings %>% dplyr::filter(name == !!name) %>% dplyr::pull(value_num)
            
            # Update figure settings UI here with loaded figure settings
        })
    }
    
    # Run code if toggle is activated
    # if (length(input$run_code_at_settings_file_load_%widget_id%) > 0){
    #     if (input$run_code_at_settings_file_load_%widget_id%){
    #        shinyjs::delay(500, shinyjs::runjs(paste0("Shiny.setInputValue('", id, "-display_figure_%widget_id%', Math.random());")))
    #    }
    # }
})

Le code se déclenche lorsqu’un nouvelle valeur est sélectionnée dans le dropdown load_figure_settings_%widget_id%. Nous pouvons récupérer l’ID du fichier de paramètres avec la variable input$load_figure_settings_%widget_id%.

Pour sauvegarder n’importe quelle information sur un widget (que ce soit dans les paramètres de la figure ou dans les paramètres généraux du plugin), nous enregistrons les données dans la table widgets_options, accessible avec la connexion m$db.

Regardons la structure de cette table :

Nom de la colonne Type Description
id Entier Identifiant unique pour chaque enregistrement
widget_id Entier Identifiant associé au widget
person_id Entier Identifiant du patient
link_id Entier Identifiant pour faire le lien entre deux enregistrements
category Texte Catégorie associée à l’enregistrement
name Texte Nom ou libellé de l’enregistrement
value Texte Valeur enregistrée sous forme de texte
value_num Numérique Valeur enregistrée sous forme numérique
creator_id Entier Identifiant du créateur de l’enregistrement
datetime Texte/Date Date et heure associées à l’enregistrement
deleted Entier Indicateur si l’enregistrement est supprimé : n’est plus utilisé

Vous pouvez utiliser cette table comme vous l’entendez, dans le sens où c’est vous qui choisissez les valeurs de category, de name, de value et de value_num.

La colonne link_id permet de rattacher un enregistrement à d’autres enregistrements.

Nous utilisons donc le code suivant pour charger les fichiers de sauvegarde :

SELECT name, value, value_num 
    FROM widgets_options 
    WHERE widget_id = %widget_id% AND category = 'figure_settings' AND link_id = {link_id}

%widget_id% est remplacé par l’ID du widget, link_id contient l’ID du fichier de sauvegardé sélectionné. Nous allons donc récupérer toutes les entrées de la table de la catégorie figure_settings rattachés au fichier de sauvegade sélectionné.

Ensuite, chaque valeur des paramètres sera chargée avec la boucle sapply, les valeurs textuelles et numériques seront récupérées avec value et value_num, et nous allons pouvoir modifier les input avec ces nouvelles valeurs.

Le code commenté permet d’exécuter le code après le chargement d’un fichier de sauvegardé, si l’option est sélectionnée dans les paramètres généraux du plugin.

Voyons maintenant le code pour sauvegarder les paramètres.

observeEvent(input$save_params_and_code_%widget_id%, {
    %req%
    if (debug) cat(paste0("\\n", now(), " - mod_", id, " - widget_id = %widget_id% - observer input$save_params_and_code"))
    
    tryCatch({
    
        # If no settings file is selected, go to settings files management page
        if (length(input$settings_file_%widget_id%) == 0) shinyjs::runjs(paste0("Shiny.setInputValue('", id, "-show_settings_files_tab_%widget_id%', Math.random());"))
        
        if (length(input$settings_file_%widget_id%) > 0){
            
            link_id <- input$settings_file_%widget_id%
        
            # Delete old settings
            sql_send_statement(m$db, glue::glue_sql("DELETE FROM widgets_options WHERE widget_id = %widget_id% AND category = 'figure_settings' AND link_id = {link_id}", .con = m$db))
            
            # Add new settings in db
            
            # new_data <- tibble::tribble(
            #     ~name, ~value, ~value_num,
            #     ...
            # )
            
            # new_data <-
            #     new_data %>%
            #     dplyr::transmute(
            #         id = get_last_row(m$db, "widgets_options") + 1:nrow(new_data), widget_id = %widget_id%, person_id = NA_integer_, link_id = link_id,
            #         category = "figure_settings", name, value, value_num, creator_id = m$user_id, datetime = now(), deleted = FALSE
            #     )
            
            # DBI::dbAppendTable(m$db, "widgets_options", new_data)
            
            # Notify user
            show_message_bar(output, "modif_saved", "success", i18n = i18n, ns = ns)
        }
        
    }, error = function(e) cat(paste0("\\n", now(), " - widget %widget_id% - error = ", toString(e))))
})

Si aucun fichier de sauvegarde n’est sélectionné, l’utilisateur sera renvoyé à la page des fichiers de sauvegarde afin d’en créer un.

Les anciennes entrées correspond au fichier de sauvegarde sont supprimées et remplacées avec les nouveaux paramètres.

Backend du code et de la figure

Voici le code de server_code.R.

Il n’y a pas de fichier server_figure.R : l’onglet des paramètres de la figure permet de générer le code, qui est ensuite exécuté. Ces deux aspects (génération du code à partir des paramètres + exécution du code) sont regroupés au sein du même fichier.

# Server - Code

# Init code var
m$code_%widget_id% <- ""

# Prevent a bug with scroll into ace editor
shinyjs::delay(300, shinyjs::runjs("var event = new Event('resize'); window.dispatchEvent(event);"))

# Comment code
observeEvent(input$code_%widget_id%_comment, {
    %req%
    if (debug) cat(paste0("\\n", now(), " - mod_", id, " - widget_id = %widget_id% - observer input$code_comment"))

    tryCatch({
        lines <- strsplit(input$code_%widget_id%, "\\n")[[1]]
        req(length(lines) > 0)
        
        start_row <- input$code_%widget_id%_comment$range$start$row + 1
        end_row <- input$code_%widget_id%_comment$range$end$row + 1
        
        for (i in start_row:end_row) if (startsWith(lines[i], "# ")) lines[i] <- substr(lines[i], 3, nchar(lines[i])) else lines[i] <- paste0("# ", lines[i])
        
        shinyAce::updateAceEditor(session, "code_%widget_id%", value = paste0(lines, collapse = "\\n"))
        
        shinyjs::runjs(sprintf("
            var editor = ace.edit('%s-rode');
            editor.moveCursorTo(%d, %d);
            editor.focus();
        ", id, input$code_%widget_id%_comment$range$end$row, input$code_%widget_id%_comment$range$end$column))
    }, error = function(e) cat(paste0("\\n", now(), " - widget %widget_id% - error = ", toString(e))))
})

# Run all code with shortcut
observeEvent(input$code_%widget_id%_run_all, {
    %req%
    if (debug) cat(paste0("\\n", now(), " - mod_", id, " - widget_id = %widget_id% - observer input$code_run_all"))

    m$code_%widget_id% <- input$code_%widget_id%
    shinyjs::runjs(paste0("Shiny.setInputValue('", id, "-run_code_%widget_id%', Math.random());"))
})

# Run code when button is clicked
observeEvent(input$display_figure_%widget_id%, {
    %req%
    if (debug) cat(paste0("\\n", now(), " - mod_", id, " - widget_id = %widget_id% - observer input$display_figure"))
    
    tryCatch({
    
        # If current selected tab is figure settings when run code button is clicked, generate code from these settings
        if (length(input$current_tab_%widget_id%) == 0) current_tab <- "figure_settings"
        else current_tab <- input$current_tab_%widget_id%
        
        if (current_tab == "figure_settings"){
            
            # Code to generate code from figure settings
            
            # ...
            code <- ""
            
            # Update ace editor with generated code
            shinyAce::updateAceEditor(session, "code_%widget_id%", value = code)
            
            m$code_%widget_id% <- code
        }
        
        # Check if user has access
        else if ("projects_console_access" %in% user_accesses) m$code_%widget_id% <- input$code_%widget_id%
        shinyjs::runjs(paste0("Shiny.setInputValue('", id, "-run_code_%widget_id%', Math.random());"))
        
    }, error = function(e){
        show_message_bar(output, "error_displaying_figure", "severeWarning", i18n = i18np, ns = ns)
        cat(paste0("\\n", now(), " - widget %widget_id% - input$display_figure - error = ", toString(e)))
    })
})

# Run code
observeEvent(input$run_code_%widget_id%, {
    %req%
    if (debug) cat(paste0("\\n", now(), " - mod_", id, " - widget_id = %widget_id% - observer input$run_code"))
    
    tryCatch({
        
        # Put here the code to execute when the "Run code" button is clicked
        
        # ...
        
        # Go to figure tab
        if (!input$figure_and_settings_side_by_side_%widget_id%) shinyjs::click("figure_button_%widget_id%")
        
    }, error = function(e){
        show_message_bar(output, "error_displaying_figure", "severeWarning", i18n = i18np, ns = ns)
        cat(paste0("\\n", now(), " - widget %widget_id% - input$display_figure - error = ", toString(e)))
    })
})

# Save code with shortcut
observeEvent(input$code_%widget_id%_save, {
    %req%
    if (debug) cat(paste0("\\n", now(), " - mod_", id, " - widget_id = %widget_id% - observer input$code_save"))
    
    shinyjs::runjs(paste0("Shiny.setInputValue('", id, "-save_params_and_code_%widget_id%', Math.random());"))
})

Le code dans l’observeur observeEvent(input$code_%widget_id%_comment, {}) permet de commenter le code dans l’éditeur avec le raccourci CTRL/CMD + SHIFT + C.

Le code peut être exécuté de deux façons :

  • avec le raccourci CTRL/CMD + SHIFT + C via l’observeur observeEvent(input$code_%widget_id%_run_all, {})
  • en cliquant sur le bouton “Afficher la figure” à gauche du widget, via l’observeur observeEvent(input$display_figure_%widget_id%, {})

Dans les deux cas, le code est copié dans la variable m$code_%widget_id% puis l’observeur observeEvent(input$run_code_%widget_id%, {}) est exécuté, permettant d’exécuter le code à l’origine de la figure et de basculer sur l’onglet de la figure.

Dans le cas où l’on se situe sur l’onglet des paramètres de la figure et que l’on clique sur le bouton “Afficher la figure”, le code est généré à partir des paramètres de la figure avant d’être exécuté. L’éditeur de code est mis à jour avec le code ainsi généré.

Enfin, nous avons un observeur pour sauvegarder le code depuis le raccourci (observeur observeEvent(input$code_%widget_id%_save, {})).

Backend des fichiers de sauvegarde

Le code de server_settings_files.R est le suivant.

# Server - Settings files management

settings_files_ui_style <- paste0(
    "display: inline-block; color: white; max-width: 250px; border-radius: 8px; padding: 1px 5px; align-items: center;",
    "height: 18px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; margin: 2px 5px;"
)

output$settings_files_ui_%widget_id% <- renderUI({
    %req%
    if (debug) cat(paste0("\\n", now(), " - mod_", id, " - widget_id = %widget_id% - observer output$settings_files"))
    
    div(i18np$t("no_settings_file_selected"), style = paste0(settings_files_ui_style, "background-color: #606060ab;"))
})

## Show / hide settings file
observeEvent(input$show_settings_file_%widget_id%, {
    %req%
    if (debug) cat(paste0("\\n", now(), " - mod_", id, " - widget_id = %widget_id% - observer input$show_settings_file"))
    
    if (input$show_settings_file_%widget_id%) shinyjs::show("settings_files_ui_%widget_id%")
    else shinyjs::hide("settings_files_ui_%widget_id%")
})

## Show / hide settings files div
observeEvent(input$show_settings_files_tab_%widget_id%, {
    %req%
    if (debug) cat(paste0("\\n", now(), " - mod_", id, " - widget_id = %widget_id% - observer input$show_settings_files_tab"))
    
    sapply(c(paste0(tabs, "_div_%widget_id%"), "figure_settings_code_div_%widget_id%"), shinyjs::hide)
    shinyjs::show("settings_files_div_%widget_id%")
})

## Show add settings file modal
observeEvent(input$create_settings_file_%widget_id%, {
    %req%
    if (debug) cat(paste0("\\n", now(), " - mod_", id, " - widget_id = %widget_id% - observer input$create_settings_file"))
    
    shinyjs::show("add_settings_file_modal_%widget_id%")
})

## Close add settings file modal
observeEvent(input$close_add_settings_file_modal_%widget_id%, {
    %req%
    if (debug) cat(paste0("\\n", now(), " - mod_", id, " - widget_id = %widget_id% - observer input$close_add_settings_file_modal"))
    
    shinyjs::hide("add_settings_file_modal_%widget_id%")
})

## Confirm creation of settings file
observeEvent(input$add_settings_file_%widget_id%, {
    %req%
    if (debug) cat(paste0("\\n", now(), " - mod_", id, " - widget_id = %widget_id% - observer input$add_settings_file"))
    
    tryCatch({
        file_name <- input$settings_file_name_%widget_id%
        
        # Check if name if empty
        empty_name <- TRUE
        if (length(file_name) > 0) if (!is.na(file_name) & file_name != "") empty_name <- FALSE
        if (empty_name) shiny.fluent::updateTextField.shinyInput(session, "settings_file_name_%widget_id%", errorMessage = i18np$t("provide_valid_name"))
        else {
        
            shiny.fluent::updateTextField.shinyInput(session, "settings_file_name_%widget_id%", errorMessage = NULL)
            
            # Check if name is already used
            sql <- glue::glue_sql("SELECT name FROM widgets_options WHERE widget_id = %widget_id% AND category = 'settings_files' AND name = 'file_name' AND LOWER(value) = {tolower(file_name)}", .con = m$db)
            name_already_used <- nrow(DBI::dbGetQuery(m$db, sql) > 0)
            
            if (name_already_used) shiny.fluent::updateTextField.shinyInput(session, "settings_file_name_%widget_id%", errorMessage = i18np$t("name_already_used"))
            else {
                
                new_id <- get_last_row(m$db, "widgets_options") + 1
                
                # Add settings file in database
                new_data <- tibble::tibble(
                    id = new_id, widget_id = %widget_id%, person_id = NA_integer_, link_id = NA_integer_,
                    category = "settings_files", name = "file_name", value = file_name, value_num = NA_real_, creator_id = m$user_id, datetime = now(), deleted = FALSE
                )
                DBI::dbAppendTable(m$db, "widgets_options", new_data)
                
                # Reset fields
                shiny.fluent::updateTextField.shinyInput(session, "settings_file_name_%widget_id%", value = "")
                
                # Update dropdown
                shiny.fluent::updateDropdown.shinyInput(session, "settings_file_%widget_id%", value = new_id)
                shinyjs::runjs(paste0("Shiny.setInputValue('", id, "-reload_dropdown_%widget_id%', Math.random());"))
                
                # Close modal
                shinyjs::hide("add_settings_file_modal_%widget_id%")
                
                # Notify user
                show_message_bar(output, "new_settings_file_added", "success", i18n = i18np, ns = ns)
            }
        }
    }, error = function(e) cat(paste0("\\n", now(), " - widget %widget_id% - error = ", toString(e))))
})

## Update dropdown
observeEvent(input$reload_dropdown_%widget_id%, {
    %req%
    if (debug) cat(paste0("\\n", now(), " - mod_", id, " - widget_id = %widget_id% - observer input$settings_file"))
    
    tryCatch({
        sql <- glue::glue_sql("SELECT id, value AS name FROM widgets_options WHERE widget_id = %widget_id% AND category = 'settings_files' AND name = 'file_name'", .con = m$db)
        m$settings_filenames_%widget_id% <- DBI::dbGetQuery(m$db, sql)
        
        dropdown_options <- convert_tibble_to_list(m$settings_filenames_%widget_id%, key_col = "id", text_col = "name")
        shiny.fluent::updateDropdown.shinyInput(session, "settings_file_%widget_id%", options = dropdown_options)
        
    }, error = function(e) cat(paste0("\\n", now(), " - widget %widget_id% - error = ", toString(e))))
})

## A settings file is selected
observeEvent(input$settings_file_%widget_id%, {
    %req%
    if (debug) cat(paste0("\\n", now(), " - mod_", id, " - widget_id = %widget_id% - observer input$settings_file"))
    
    tryCatch({
    
        # Show delete button
        shinyjs::show("delete_settings_file_div_%widget_id%")
    
        # Get file name
        file_id <- input$settings_file_%widget_id%
        filename <- m$settings_filenames_%widget_id% %>% dplyr::filter(id == file_id) %>% dplyr::pull(name)
        
        output$settings_files_ui_%widget_id% <- renderUI(div(filename, style = paste0(settings_files_ui_style, "background-color: #1d94ce;")))
        
        # Save that this file is selected
        sql_send_statement(m$db, glue::glue_sql("DELETE FROM widgets_options WHERE widget_id = %widget_id% AND category = 'general_settings' AND name = 'selected_file_id'", .con = m$db))
        new_data <- tibble::tibble(
            id = get_last_row(m$db, "widgets_options") + 1, widget_id = %widget_id%, person_id = NA_integer_, link_id = NA_integer_,
            category = "general_settings", name = "selected_file_id", value = NA_character_, value_num = file_id, creator_id = m$user_id, datetime = now(), deleted = FALSE
        )
        print(new_data)
        DBI::dbAppendTable(m$db, "widgets_options", new_data)
        
        # Load saved settings
        shinyjs::runjs(paste0("Shiny.setInputValue('", id, "-load_figure_settings_%widget_id%', Math.random());"))
        
    }, error = function(e) cat(paste0("\\n", now(), " - widget %widget_id% - error = ", toString(e))))
})

## Open delete a settings file modal
observeEvent(input$delete_settings_file_%widget_id%, {
    %req%
    req(length(input$settings_file_%widget_id%) > 0)
    if (debug) cat(paste0("\\n", now(), " - mod_", id, " - widget_id = %widget_id% - observer input$delete_settings_file"))
    
    shinyjs::show("delete_settings_file_modal_%widget_id%")
})

## Close delete a settings file modal
observeEvent(input$close_file_deletion_modal_%widget_id%, {
    %req%
    if (debug) cat(paste0("\\n", now(), " - mod_", id, " - widget_id = %widget_id% - observer input$close_file_deletion_modal"))
    
    shinyjs::hide("delete_settings_file_modal_%widget_id%")
})

## Confirm settings file deletion
observeEvent(input$confirm_file_deletion_%widget_id%, {
    %req%
    if (debug) cat(paste0("\\n", now(), " - mod_", id, " - widget_id = %widget_id% - observer input$confirm_file_deletion"))
    
    tryCatch({
        file_id <- input$settings_file_%widget_id%
        
        # Delete row in db
        sql_send_statement(m$db, glue::glue_sql("DELETE FROM widgets_options WHERE id = {file_id}", .con = m$db))
        
        # Update dropdown
        shinyjs::runjs(paste0("Shiny.setInputValue('", id, "-reload_dropdown_%widget_id%', Math.random());"))
        
        # Close modal
        shinyjs::hide("delete_settings_file_modal_%widget_id%")
        
        # Update selected settings file UI
        output$settings_files_ui_%widget_id% <- renderUI(div(i18np$t("no_settings_file_selected"), style = paste0(settings_files_ui_style, "background-color: #606060ab;")))
        
        # Hide delete button
        shinyjs::hide("delete_settings_file_div_%widget_id%")
        
        # Notify user
        show_message_bar(output, "settings_file_delete", "warning", i18n = i18np, ns = ns)
        
    }, error = function(e) cat(paste0("\\n", now(), " - widget %widget_id% - error = ", toString(e))))
})

Vous devriez normalement avoir avec les explications précédentes les clefs pour comprendre le code ci-dessus, avec les commentaires fournis dans le code.

Le bloc avec output$settings_files_ui_%widget_id% permet d’afficher par défaut qu’il n’y a pas de fichier chargé, dans le header.

Voici le détail des observeurs :

  • observeEvent(input$show_settings_file_%widget_id%, {}) permet d’afficher ou de masquer le div avec le nom du fichier sélectionné, selon la valeur du paramètre dans les paramètres généraux du widget.
  • observeEvent(input$show_settings_files_tab_%widget_id%, {}) permet d’afficher ou de masquer l’UI de l’onglet permettant de gérer les fichiers de sauvegarde.
  • observeEvent(input$create_settings_file_%widget_id%, {}) permet d’afficher le modal de création d’un fichier de sauvegarde lorsque l’on clique sur le bouton “+”.
  • observeEvent(input$close_add_settings_file_modal_%widget_id%, {}) permet de masquer le modal lorsque l’on clique sur la croix pour quitter le modal.
  • observeEvent(input$add_settings_file_%widget_id%, {}) permet d’ajouter les informations du fichier créé dans la base de données et de réinitialiser les champs
  • observeEvent(input$settings_file_%widget_id, {}) permet de charger le code et les paramètres de la figure associés au fichier de sauvegarde sélectionné
  • observeEvent(input$delete_settings_file_%widget_id%, {}) permet d’ouvrir le modal de suppression du fichier de sauvegarde sélectionné
  • observeEvent(input$confirm_file_deletion_%widget_id%, {}) permet de supprimer le fichier de savuegarde de la base de données

Backend des paramètres généraux

Voici le code de server_general_settings.R :

# Server - General settings

# Settings / editor side-by-side with figure

observeEvent(input$figure_and_settings_side_by_side_%widget_id%, {
    %req%
    if (debug) cat(paste0("\\n", now(), " - mod_", id, " - widget_id = %widget_id% - observer input$figure_and_settings_side_by_side"))
    
    tryCatch({
        if (input$figure_and_settings_side_by_side_%widget_id%){
            shinyjs::runjs(paste0("
                $('#", id, "-figure_div_%widget_id%').css('flex-basis', '50%');
                $('#", id, "-figure_settings_div_%widget_id%').css('flex-basis', '50%');
                $('#", id, "-code_div_%widget_id%').css('flex-basis', '50%');
                
                if (!window.resizingInitialized_%widget_id%) {
                    var container = document.getElementById('", id, "-figure_settings_code_div_%widget_id%');
                    
                    var isResizing = false;
                    var lastDownX = 0;
                    
                    var leftPanel = container.querySelector('.left-panel');
                    var figureSettingsPanel = document.getElementById('", id, "-figure_settings_div_%widget_id%');
                    var codePanel = document.getElementById('", id, "-code_div_%widget_id%');
                    var resizer = container.querySelector('.resizer');
                    
                    function triggerResizeEvent() {
                        var event = new Event('resize');
                        window.dispatchEvent(event);
                    }
                    
                    resizer.addEventListener('mousedown', function(e) {
                        isResizing = true;
                        lastDownX = e.clientX;
                
                        document.addEventListener('mousemove', resizePanels);
                        document.addEventListener('mouseup', stopResizing);
                    });
                    
                    function resizePanels(e) {
                        if (!isResizing) return;
                        
                        var offsetLeftPanel = leftPanel.offsetWidth;
                        var offsetFigureSettingsPanel = figureSettingsPanel.offsetWidth;
                        var offsetCodePanel = codePanel.offsetWidth;
                        var deltaX = e.clientX - lastDownX;
                        
                        leftPanel.style.flexBasis = (offsetLeftPanel + deltaX) + 'px';
                        
                        figureSettingsPanel.style.flexBasis = (offsetFigureSettingsPanel - deltaX) + 'px';
                        codePanel.style.flexBasis = (offsetCodePanel - deltaX) + 'px';
                
                        lastDownX = e.clientX;
                        triggerResizeEvent();
                    }
                    
                    function stopResizing(e) {
                        isResizing = false;
                        document.removeEventListener('mousemove', resizePanels);
                        document.removeEventListener('mouseup', stopResizing);
                        triggerResizeEvent();
                    }
                }
                
                window.resizingInitialized_%widget_id% = true;
            "))
            shinyjs::hide("figure_button_div_%widget_id%")
            shinyjs::show("resizer_%widget_id%")
        }
        else {
            shinyjs::runjs(paste0("
                $('#", id, "-figure_div_%widget_id%').css('flex-basis', '100%');
                $('#", id, "-figure_settings_div_%widget_id%').css('flex-basis', '100%');
                $('#", id, "-code_div_%widget_id%').css('flex-basis', '100%');
            "))
            
            shinyjs::show("figure_button_div_%widget_id%");
            shinyjs::hide("resizer_%widget_id%");
        }
        
    }, error = function(e) cat(paste0("\\n", now(), " - widget %widget_id% - error = ", toString(e))))
})

# Save general settings in db

observeEvent(input$save_general_settings_%widget_id%, {
    %req%
    if (debug) cat(paste0("\\n", now(), " - mod_", id, " - widget_id = %widget_id% - observer input$save_general_settings"))
    
    tryCatch({
    
        # Delete old rows
        sql_send_statement(m$db, glue::glue_sql("DELETE FROM widgets_options WHERE widget_id = %widget_id% AND category = 'general_settings' AND name != 'selected_file_id'", .con = m$db))
        
        file_id <- input$settings_file_%widget_id%
        # new_data <- tibble::tibble(name = "selected_file_id", value = NA_character_, value_num = NA_integer_, link_id = file_id)
        new_data <- tibble::tibble(name = character(), value = character(), value_num = integer())
        
        # general_settings_vec <- c("show_saved_file", "figure_and_settings_side_by_side", "run_code_on_data_update", "run_code_at_settings_file_load")
        general_settings_vec <- c("show_settings_file", "figure_and_settings_side_by_side")
        
        sapply(general_settings_vec, function(name){
            toggle_value <- 0L
            input_name <- paste0(name, "_%widget_id%")
            if (length(input[[input_name]]) > 0) if (input[[input_name]]) toggle_value <- 1L
            new_data <<- new_data %>% dplyr::bind_rows(tibble::tibble(name = name, value = NA_character_, value_num = toggle_value))
        })
        
        new_data <-
            new_data %>%
            dplyr::transmute(
                id = get_last_row(m$db, "widgets_options") + 1:(length(general_settings_vec)), widget_id = %widget_id%, person_id = NA_integer_, link_id = NA_integer_,
                category = "general_settings", name, value, value_num, creator_id = m$user_id, datetime = now(), deleted = FALSE
            )
        
        DBI::dbAppendTable(m$db, "widgets_options", new_data)
        
    }, error = function(e) cat(paste0("\\n", now(), " - widget %widget_id% - error = ", toString(e))))
})

Le premier observeur, observeEvent(input$figure_and_settings_side_by_side_%widget_id%, {}) contient le code Javascript qui permettra de gérer la taille des div côte à côte lorsque l’utilisateur cliquera sur la barre de séparation entre ces deux div.

L’observeur observeEvent(input$save_general_settings_%widget_id%, {}) permet de sauvegarder les paramètres généraux du widget automatiquement à chaque modification d’un toggle.

Conclusion

Vous êtes maintenant prêts à créer vos propres plugins !

La structure de base du template est somme toute assez complète.

Voici les étapes à suivre lors de la création d'un plugin à partir du template :

  • Créez un nouveau plugin en dupliquant le template
  • Codez dans un premier temps l'UI des paramètres du plugin et de la figure
  • Développez progressivement le backend de la création de la figure à partir des paramètres
  • Configurez le chargement des paramètres de la figure à partir des fichiers de sauvegarde
  • Seulement une fois que tout fonctionne, passez à la génération du code à partir des paramètres de la figure
  • Configurez enfin les paramètres généraux du widget
  • Une fois le plugin terminé, pensez à le documenter avec la description et à la partager !

Dernière modification 10.01.2025: Update FR create an advanced plugin doc (#11) (2c3509c)