Création d'un plugin avancé

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

Introduction

Nous avons vu comment créer un plugin simple.


Cependant, les plugins sont généralement plus complexes, comprenant une dizaine de scripts.

Nous avons, pour les plugins plus complexes, besoin d'interagir avec la base de données de l'application, ainsi qu'avec les variables du projet chargé.


Nous allons dans ce tutoriel détailler pas à pas la création d'un plugin complexe, à l'aide d'un plugin de développement, qui vous servira de base pour vos futurs développements.

Template de développement

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

C’est pourquoi nous avons 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 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ément 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_widgets_console" %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 data_widget_top_icons",
        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: 0 8px; 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);",
        class = "data_widget_settings_code_panel"
    ),
    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_widgets_console" %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 data_widget_top_icons",
    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: 0 8px; 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);",
    class = "data_widget_settings_code_panel"
)

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%"))
        )
    ),
    # Div containing the figure output
    div(
        
    ),
    style = "width: 100%; padding: 5px; box-sizing: border-box; height: 100%; overflow-y: auto;"
)

Nous avons un uiOutput pour afficher un message d’erreur, que nous verrons dans le script du backend correspondant.

C’est à vous d’ajouter le code pour l’output, 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_settings_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){
        
        if (name %in% c("show_settings_file", "figure_and_settings_side_by_side")) toggle_value <- TRUE
        else 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(link_id)
}

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

Voici le contenu du fichier server.R en entier.

# Server - Main file

# Tabs

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

observe_event(input$current_tab_trigger_%widget_id%, {
    
    tab <- input$current_tab_%widget_id%
    
    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_widgets_console" %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);")
})

# 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")

observe_event(input$current_tab_trigger_%widget_id%, {
    
    tab <- input$current_tab_%widget_id%
    
    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_widgets_console" %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);")
})

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

observe_event(input$load_figure_settings_%widget_id%, {
    
    # 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

observe_event(input$save_params_and_code_%widget_id%, {
    
    # 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("modif_saved", "success")
    }
})

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

observe_event(input$load_figure_settings_%widget_id%, {
    
    # 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.

observe_event(input$save_params_and_code_%widget_id%, {
    
    # 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("modif_saved", "success")
    }
})

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
observe_event(input$code_%widget_id%_comment, {
    
    toggle_comments(
        id = id, input_id = "code_%widget_id%", code = input$code_%widget_id%,
        selection = input$code_%widget_id%_comment$range, session = session
    )
})

# Run all code with shortcut
observe_event(input$code_%widget_id%_run_all, {
    
    if ("projects_widgets_console" %in% user_accesses){
        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
observe_event(input$display_figure_%widget_id%, {
    
    # 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
        
        shinyjs::runjs(paste0("Shiny.setInputValue('", id, "-run_code_%widget_id%', Math.random());"))
    }
    
    # Check if user has access
    else if ("projects_widgets_console" %in% user_accesses){
        m$code_%widget_id% <- input$code_%widget_id%
        shinyjs::runjs(paste0("Shiny.setInputValue('", id, "-run_code_%widget_id%', Math.random());"))
    }
})

# Run code at patient update
# observe_event(m$selected_person, {
    # if (isTRUE(input$run_code_on_data_update_%widget_id%)) shinyjs::runjs(paste0("Shiny.setInputValue('", id, "-run_code_%widget_id%', Math.random());"))
# })

# Run code at visit_detail update
# observe_event(m$selected_visit_detail, {
    # if (isTRUE(input$run_code_on_data_update_%widget_id%)) shinyjs::runjs(paste0("Shiny.setInputValue('", id, "-run_code_%widget_id%', Math.random());"))
# })

# Run code
observe_event(input$run_code_%widget_id%, {
        
    # 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%")
})

# Save code with shortcut
observe_event(input$code_%widget_id%_save, shinyjs::runjs(paste0("Shiny.setInputValue('", id, "-save_params_and_code_%widget_id%', Math.random());")))

Le code dans l’observeur observe_event(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 observe_event(input$code_%widget_id%_run_all, {})
  • en cliquant sur le bouton “Afficher la figure” à gauche du widget, via l’observeur observe_event(input$display_figure_%widget_id%, {})

Dans les deux cas, le code est copié dans la variable m$code_%widget_id% puis l’observeur observe_event(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 observe_event(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(div(i18np$t("no_settings_file_selected"), style = paste0(settings_files_ui_style, "background-color: #606060ab;")))

## Show / hide settings file
observe_event(input$show_settings_file_%widget_id%, {
    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
observe_event(input$show_settings_files_tab_%widget_id%, {
    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
observe_event(input$create_settings_file_%widget_id%, shinyjs::show("add_settings_file_modal_%widget_id%"))

## Close add settings file modal
observe_event(input$close_add_settings_file_modal_%widget_id%, shinyjs::hide("add_settings_file_modal_%widget_id%"))

## Confirm creation of settings file
observe_event(input$add_settings_file_%widget_id%, {
    
    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 value FROM widgets_options WHERE widget_id = %widget_id% AND category = 'settings_files' AND name = 'file_name'", .con = m$db)
        files_names <- DBI::dbGetQuery(m$db, sql) %>% dplyr::pull()
        name_already_used <- remove_special_chars(file_name) %in% remove_special_chars(files_names)
        
        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)
            
            # Update filenames var
            m$settings_filenames_%widget_id% <- m$settings_filenames_%widget_id% %>% dplyr::bind_rows(tibble::tibble(id = new_id, name = file_name))
            
            # Reset fields
            shiny.fluent::updateTextField.shinyInput(session, "settings_file_name_%widget_id%", value = "")
            
            # Update dropdown
            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, value = new_id)
            
            # Reset ace editor code
            shinyAce::updateAceEditor(session, "code_%widget_id%", value = "")
            
            # Close modal
            shinyjs::hide("add_settings_file_modal_%widget_id%")
            
            # Notify user
            show_message_bar("new_settings_file_added", "success")
        }
    }
})

## A settings file is selected
observe_event(input$settings_file_%widget_id%, {
    
    # 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 = file_id,
        category = "general_settings", name = "selected_file_id", value = NA_character_, value_num = NA_real_, creator_id = m$user_id, datetime = now(), deleted = FALSE
    )
    DBI::dbAppendTable(m$db, "widgets_options", new_data)
    
    # Load saved settings
    shinyjs::runjs(paste0("Shiny.setInputValue('", id, "-load_figure_settings_%widget_id%', Math.random());"))
})

## Open delete a settings file modal
observe_event(input$delete_settings_file_%widget_id%, {
    if (length(input$settings_file_%widget_id%) == 0) return()
    shinyjs::show("delete_settings_file_modal_%widget_id%")
})

## Close delete a settings file modal
observe_event(input$close_file_deletion_modal_%widget_id%, shinyjs::hide("delete_settings_file_modal_%widget_id%"))

## Confirm settings file deletion
observe_event(input$confirm_file_deletion_%widget_id%, {
    
    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))
    
    # Delete selected file in widgets_options
    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))
    
    # Update filenames var
    m$settings_filenames_%widget_id% <- m$settings_filenames_%widget_id% %>% dplyr::filter(id != file_id)
    
    # Update dropdown
    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, value = NULL)
    
    # 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("settings_file_delete", "warning")
})

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 :

  • observe_event(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.
  • observe_event(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.
  • observe_event(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 “+”.
  • observe_event(input$close_add_settings_file_modal_%widget_id%, {}) permet de masquer le modal lorsque l’on clique sur la croix pour quitter le modal.
  • observe_event(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
  • observe_event(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é
  • observe_event(input$delete_settings_file_%widget_id%, {}) permet d’ouvrir le modal de suppression du fichier de sauvegarde sélectionné
  • observe_event(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

observe_event(input$figure_and_settings_side_by_side_%widget_id%, {
    
    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%");
    }
})

# Save general settings in db

observe_event(input$save_general_settings_%widget_id%, {
    
    # 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 = character(), value = character(), value_num = integer())
    
    # general_settings_vec <- c("show_settings_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)
})

Le premier observeur, observe_event(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 observe_event(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 26.06.2025: Update fr/docs/create_plugin/advanced (ddecc75)