Créer un plugin

Comment créer des plugins, pour ajouter des foncitonnalités à LinkR

Les plugins sont ce qui permet de visualiser et d’analyser les données à l’aide d’une interface low-code.

Ce sont des scripts écrits en R et Python, qui utilisent la librairie Shiny pour créer l’interface graphique.

Ils permettent de réaliser n’importe quelle visualisation ou n’importe quelle analyse sur les données, du moment que cela est possible en R ou en Python.

LinkR évolue en permanence grâce aux plugins créés par la communauté de ses utilisateurs.

Nous allons tout d’abord voir comment créer un plugin simple, puis nous verrons comment créer des plugins plus complexes grâce au template de développement proposé par InterHop.

1 - Création d'un plugin simple

Création d’un plugin simple permettant d’afficher les données sous forme d’histogramme

Spécifications du plugin

Nous allons créer une interface graphique qui permettra de visualiser la distribution d’une variable, sous forme d’un histogramme.

Nous devons faire un premier choix : s’agit-il d’un plugin de données individuelles (patient par patient) ou agrégées (sur un groupe de patients) ?

Il est plus fréquent de vouloir visualiser la distribution d’une variable sur un groupe de patients plutôt que sur un patient seul, nous allons donc créer un plugin de données agrégées.

Ensuite, à quoi devra ressembler notre interface graphique ?

Nous allons séparer l’écran en deux, à gauche nous visualiserons notre histogramme, et à droite nous pourrons régler les paramètres de la figure, avec un menu déroulant pour choisir la variable et un champ pour choisir le nombre de barres sur mon histogramme.

Côté serveur maintenant.

Un histogramme n’est pas adapté pour visualiser tout type de données : nous pourrons visualiser la distribution de données numériques, et de données catégorielles à condition que le nombre de catégories ne soit pas trop important.

Pour simplifier, nous n’allons autoriser que l’affichage des données numériques. Nous allons donc restreindre l’affichage à la variable d$measurement. Consultez la documentation sur la console pour plus d’informations sur les variables de données OMOP.

Lorsque nous allons changer la variable du nombre de barres de l’histogramme, les modifications devront être prises en compte après validation, pour ne pas réaliser de calculs inutiles. Nous devrons également donner des bornes de valeurs possibles.

Résumons les spécifications de notre plugin :

  • Côté UI :
    • Visualisation de l’histogramme à gauche de l’écran
    • Paramètres à droite de l’écran
      • Variable à afficher
      • Nombre de barres composant l’histogramme, avec des bornes inférieure et supérieure
      • Validation des modifications
  • Côté serveur :
    • N’autoriser que les données de la variable d$measurement
    • Modifier le nombre de barres de l’histogramme en fonction de la valeur renseignée
    • Lancer le code de la figure une fois le bouton de validation cliqué

Création du plugin

Rendez-vous sur la page des plugins depuis le menu en haut de l’écran.

Pour créer un plugin, cliquez sur l’icône “+” à gauche de l’écran.

Choisissez un nom, par exemple “Histogramme”.

Choisissez le type de données concernées : le plugin peut être utilisé sur des données individuelles (patient par patient), agrégées (un groupe de patients), ou sur les deux. Pour notre exemple, nous allons choisir “Données agrégées”.

Il est également possible de copier un plugin existant : nous le verrons dans la prochaine section, lorsque nous créerons un plugin à partir du template d’InterHop.

Une fois le plugin créé, sélectionnez-le. Vous arriverez sur la page de sommaire du plugin.

Vous voyez en haut à droite qu’un plugin est divisé en quatre onglets :

  • Sommaire : ici seront affichées les informations générale et la description de votre plugin. Nous détaillons cela dans le dernier paragraphe : “Partager le plugin”.
  • Code : c’est ici que nous éditerons les scripts permettant de créer le frontend et le backend de notre plugin (cf trois paragraphes suivants).
  • Test : cet onglet permet de tester le code du plugin avec des données
  • Partage : c’est ici que l’on pourra ajouter ce plugin à notre dépôt Git pour le partager avec le reste de la communauté

Structure d’un plugin

Rendez-vous sur l’onglet Code.

Un plugin est par défaut composé de ces trois fichiers :

  • ui.R : contient le code Shiny de l’interface utilisateur, que nous allons détailler dans le prochain paragraphe
  • server.R : contient le backend de l’application, que nous allons détailler dans le paragraphe “Serveur / backend”
  • translations.csv : contient les traductions pour le frontend et le backend

UI - interface utilisateur / frontend

Comme nous l’avons vu sur le schéma plus haut, nous voulons séparer l’écran du plugin en deux, avec à gauche la figure et à droite les paramètres de la figure.

Commencez par cliquer sur le fichier ui.R à gauche de l’écran.

Tout notre code de l’interface utilisateur devra se trouver dans une fonction tagList, qui permet de mettre bout à bout des balises HTML avec la librairie R Shiny.

Pour que deux div soient côte à côte, il faut qu’ils soient eux-mêmes dans un div avec l’attribut style = "display:flex;".

tagList(
    div(
        div(
            # Chaque id est dans une fonction ns, et comporte une balise %widget_id%
            id = ns("split_layout_left_%widget_id%"),
            style = "margin:10px 5px; width:50%; border:dashed 1px;"
        ),
        div(
            id = ns("split_layout_right_%widget_id%"),
            style = "margin:10px 5px; width:50%; border:dashed 1px;"
        ),
        style = "display:flex; height: 100%;", # Permet d'afficher côte à côte les deux div ci-dessus
    )
)

Notez que dès que l’on attribue un ID à un élément HTML, il doit comporter une balise %widget_id%, qui sera remplacée par l’ID du widget, permettant d’avoir des ID uniques. En effet, cela poserait problème si le même plugin était lancé dans deux widgets différents. En cas d’ID multiples, la page HTML ne s’affiche pas.

De plus, chaque ID est encapsulé dans une fonction ns (voir le chapitre sur les modules Shiny du livre Mastering Shiny pour plus d’informations).

Nous avons ici ajouté des bordures à nos div avec border:dashed 1px; afin de visualiser nos div, qui sont pour le moment vides. Nous retirerons ces attributs plus tard.

Cliquez sur l’icône “Exécuter le plugin” à gauche de l’écran.

Vous serez automatiquement basculés sur l’onglet “Test” et vous devriez obtenir ce résultat.

Nous voyons bien les deux blocs div côte à côte, avec une bordure en pointillés.

Ajoutons maintenant notre histogramme.

Nous utilisons pour cela la fonction plotOutput, que nous modifierons côté serveur pour afficher notre plot.

div(
    id = ns("split_layout_left_%widget_id%"),
    plotOutput(ns("plot_%widget_id%")), # Toujours mettre les ID dans des ns() avec un attribut %widget_id%
    style = "margin:10px 5px; width:50%; border:dashed 1px;"
)

Créons maintenant la configuration de notre figure, dans le div de droite.

Nous avons dit plus haut que nous voulions trois éléments :

  • un menu déroulant pour choisir la variable à afficher
  • un input numérique pour choisir le nombre de barres de l’histogramme à afficher
  • un bouton pour afficher la figure avec ces paramètres

Nous allons utiliser la librairie shiny.fluent, qui est celle utilisée pour toute l’interface utilisateur de LinkR, qui utilise Fluent UI.

Voici les fonctions à utiliser pour nos trois éléments :

Dans les plugins, il faut préfixer toutes les fonctions du nom de la librairie. Par exemple : shiny.fluent::Dropdown.shinyInput().

Créons le code pour afficher les éléments de configuration de la figure.

div(
    # id avec ns et %widget_id%
    id = ns("split_layout_right_%widget_id%"),

    # div contenant le titre, en gras (strong), avec un espace de 10 px entre le titre et le dropdown
    div(strong(i18np$t("concept")), style = "margin-bottom:10px;"),
    # Menu déroulant avec les concepts
    div(shiny.fluent::Dropdown.shinyInput(ns("concept_%widget_id%")), style = "width:300px;"), br(),

    # Input numérique pour choisir le nombre de barres de l'histogramme
    # Avec une valeur à 50, un minimum à 10 et un maximum à 100
    div(strong(i18np$t("num_bins")), style = "margin-bottom:10px;"),
    div(shiny.fluent::SpinButton.shinyInput(ns("num_bins_%widget_id%"), value = 50, min = 10, max = 100), style = "width:300px;"), br(),

    # Bouton pour afficher la figure
    shiny.fluent::PrimaryButton.shinyInput(ns("show_plot_%widget_id%"), i18np$t("show_plot")),
    style = "margin: 10px 5px; width:50%;"
)

Cliquez de nouveau sur “Exécuter le plugin”, vous devriez obtenir ceci.

Vous pouvez remarquer que les titres des inputs ont été mis dans une fonction i18np$t. Ceci permet de traduire les éléments à partir du fichier de traductions (translations.csv), ce que nous allons voir dans le prochaine paragraphe.

Traductions

Les traductions sont à insérer dans le fichier CSV translations.csv.

Il comporte les colonnes suivantes :

  • base : c’est le mot clef que vous allez insérer dans votre code et qui sera traduit selon la langue sélectionnée
  • en : c’est la traduction du mot en anglais
  • fr : traduction en français. Pour l’instant, seuls l’anglais et le français ont été développés. Il sera possible d’ajouter d’autres langues à terme.

Cliquez sur le fichier translations.csv, puis complétez-le avec les traductions suivantes.

base,en,fr
concept,Concept to show,Concept à afficher
num_bins,Number of bins,Nombre de barres
show_plot,Show plot,Afficher la figure

Relancez le code. Vous devriez obtenir ceci.

Les mots clefs ont été remplacés par leur traduction française.

Nous allons maintenant rendre tout ceci dynamique en codant le backend !

Serveur / backend

Sans le backend, l’interface graphique est statique, rien ne se passe quand je clique sur les boutons.

Comme nous l’avons vu dans la page de documentation pour créer des widgets, lorsque nous créons un widget, nous sélectionnons le plugin à utiliser ainsi que les concepts.

Les concepts sélectionnés se retrouveront dans la variable selected_concepts, qui comporte les colonnes suivantes :

  • concept_id : l’ID du concept, soit standard (à retrouver sur Athena), soit non standard (dans ce cas, supérieur à 2000000000 / 2B)
  • concept_name : le nom du concept
  • domain_id : le nom du Domaine OMOP, qui correspond souvent à la table OMOP (domaine ‘Measurement’ pour la variable d$measurement)
  • vocabulary_id : le nom de la terminologie correspondant au concept
  • mapped_to_concept_id & merge_mapped_concepts, non utilisés dans la version actuelle de LinkR

Pour pouvoir tester un plugin et faire que le backend fonctionne, il faut charger un projet contenant des donneés. Lancez par exemple le projet utilisé pour la mise en place.

Ensuite, pour simuler la création d’un widget, nous allons sélectionner des concepts pour tester notre plugin.

Vous avez à gauche de l’écran un bouton “Sélectionner des concepts”.

Ceci ouvrira le même menu que lorsque vous choisissez des concepts en créant un widget.

Sélectionnez pour l’exemple le concept Heart rate de la terminologie LOINC, puis cliquez sur “Valider”.

Faisons le test : ouvrez le fichier server.R, et copiez ce code :

print(selected_concepts)

Relancez le code du plugin, vous devriez obtenir ceci.

Vous voyez l’output du backend apparaître en bas de l’écran. Ceci n’est le cas qu’avec le test des plugins, ce qui permet de faciliter le débogage. Cet output est masqué lorsque les plugins sont utilisés dans des projets.

On voit donc bien le concept Heart rate apparaître avec son concept_id.

Nous allons maintenant écrire le code pour mettre à jour le menu déroulant des concepts.

# Ajout d'une ligne avec les valeurs 0 / "none"
concepts <-
    tibble::tibble(concept_id = 0L, concept_name = i18np$t("none")) %>%
    dplyr::bind_rows(selected_concepts %>% dplyr::select(concept_id, concept_name))

# On converti les concepts sous forme de liste
concepts <- convert_tibble_to_list(concepts, key_col = "concept_id", text_col = "concept_name")

# On instaure un délai, afin que le dropdown se mette à jour après avoir été créé
shinyjs::delay(500, shiny.fluent::updateDropdown.shinyInput(session, "concept_%widget_id%", options = concepts, value = 0L))

Mettez à jour translations.csv pour ajouter la traduction de none.

base,en,fr
concept,Concept to show,Concept à afficher
num_bins,Number of bins,Nombre de barres
show_plot,Show plot,Afficher la figure
none,None,Aucun

Plusieurs choses à noter.

On ajoute une ligne avec un concept vide, ’none’, ce qui permettra d’éviter les erreurs si le menu déroulant est vide.

On utilise la fonction convert_tibble_to_list, qui permet de convertir un tibble en liste, nécessaire pour être intégré dans un input de shiny.fluent. Les arguments seront key_col pour la colonne qui contient le code du concept (‘concept_id’), et text_col pour la colonne qui contient le texte (‘concept_name’).

On ajoute un délai d’exécution de l’update, avec shinyjs::delay(), qui est de 500 ms. Ceci permet de s’assurer que le dropdown a été créé dans l’UI avant de le mettre à jour.

Exécutez ce code, vous devriez maintenant avoir un menu déroulant avec les concepts que nous avons sélectionnés (en l’occurrence Heart rate).

Il ne nous reste plus qu’à afficher notre figure.

Nous allons utiliser la fonction observeEvent, qui déclenchera le code après avoir détecté un événement.

observeEvent(input$show_plot_%widget_id%, {

    # Le code dans cette fonction sera exécuté
    # chaque fois que je cliquerai sur le bouton avec l'id 'show_plot_%widget_id%'
    # (donc le bouton "Afficher la figure")
})

Important

Ajoutez toujours la balise %req% au début d'un observeEvent.
Cette balise sera remplacée par un code qui invalidera les anciens observers quand le widget sera mis à jour.


Dans le cas de l’édition de plugins, à chaque fois que vous cliquez sur “Exécuter le plugin”, les observers créés précédemment seront invalidés, ce qui évite d’avoir des conflits.

Voici les étapes de notre code :

    1. Récupérer le concept sélectionné dans le menu déroulant
    1. S’assurer que le concept appartient à un domaine qui peut s’afficher sous forme d’histogramme. Par simplicité, nous sélectionnerons uniquement le domaine ‘Measurement’.
    1. S’assurer que le tibble des données, filtré avec le concept sélectionné, n’est pas vide
    1. Créer le code de notre histogramme avec ggplot
    1. Mettre à jour notre output
observeEvent(input$show_plot_%widget_id%, {

    # Toujours mettre cette balise au début d'un observer
    %req%

    # Protéger le code en cas d'erreur avec un tryCatch
    tryCatch({

        # 1) Récupérer le concept sélectionné dans le menu déroulant
        selected_concept <-
            selected_concepts %>%
            dplyr::filter(concept_id == input$concept_%widget_id%)

        no_data_available <- TRUE

        # 2) Y-a-t-il bien un concept de sélectionné et le domain_id est-il égal à 'Measurement' ?
        if (nrow(selected_concept) > 0 && selected_concept$domain_id == "Measurement"){

            # 3) S'assurer que le tibble des données filtré sur ce concept n'est pas vide
            data <-
                d$measurement %>%
                dplyr::filter(measurement_concept_id == selected_concept$concept_id)

            if(data %>% dplyr::count() %>% dplyr::pull() > 0){

                # 4) Créer le code de l'histogramme
                plot <-
                    data %>%
                    ggplot2::ggplot(ggplot2::aes(x = value_as_number)) +
                    # On prend en compte le nombre de barres depuis notre variable input$num_bins_%widget_id%
                    ggplot2::geom_histogram(colour = "white", fill = "#377EB8", bins = input$num_bins_%widget_id%) +
                    ggplot2::theme_minimal() +
                    # On modifie les titres des axes X et Y
                    ggplot2::labs(x = selected_concept$concept_name, y = i18np$t("occurrences"))

                no_data_available <- FALSE
            }
        }

        # Graphique vide si pas de données disponibles
        if (no_data_available){
                plot <-
                    ggplot2::ggplot() +
                    ggplot2::theme_void() +
                    ggplot2::labs(title = i18np$t("no_data_available"))
        }
        
        # 5) Mise à jour de l'output
        output$plot_%widget_id% <- renderPlot(plot)

    # Le message d'erreur s'affichera dans la console R
    }, error = function(e) cat(paste0("\n", now(), " - ", toString(e))))
})

Mettez à jour les traductions.

base,en,fr
concept,Concept to show,Concept à afficher
num_bins,Number of bins,Nombre de barres
show_plot,Show plot,Afficher la figure
none,None,Aucun
occurrences,Occurrences,Occurences
no_data_available,No data available,Pas de données disponibles

Vous devriez avoir ceci.

Nous visualisons la distribution de la fréquence cardiaque chez tous les patients, depuis la variable d$measurement.

Voici les trois fichiers complets :

tagList(
    div(
        div(
            id = ns("split_layout_left_%widget_id%"),
            plotOutput(ns("plot_%widget_id%")),
            style = "margin:10px 5px; width:50%; border:dashed 1px;"
        ),
        div(
            # id avec ns et %widget_id%
            id = ns("split_layout_right_%widget_id%"),
        
            # div contenant le titre, en gras (strong), avec un espace de 10 px entre le titre et le dropdown
            div(strong(i18np$t("concept")), style = "margin-bottom:10px;"),
            # Menu déroulant avec les concepts
            div(shiny.fluent::Dropdown.shinyInput(ns("concept_%widget_id%")), style = "width:300px;"), br(),
        
            # Input numérique pour choisir le nombre de barres de l'histogramme
            # Avec une valeur à 50, un minimum à 10 et un maximum à 100
            div(strong(i18np$t("num_bins")), style = "margin-bottom:10px;"),
            div(shiny.fluent::SpinButton.shinyInput(ns("num_bins_%widget_id%"), value = 50, min = 10, max = 100), style = "width:300px;"), br(),
        
            # Bouton pour afficher la figure
            shiny.fluent::PrimaryButton.shinyInput(ns("show_plot_%widget_id%"), i18np$t("show_plot")),
            style = "margin: 10px 5px; width:50%;"
        ),
        style = "display:flex; height: 100%;",
    )
)
# Ajout d'une ligne avec les valeurs 0 / "none"
concepts <-
    tibble::tibble(concept_id = 0L, concept_name = i18np$t("none")) %>%
    dplyr::bind_rows(selected_concepts %>% dplyr::select(concept_id, concept_name))

# On convertit les concepts sous forme de liste
concepts <- convert_tibble_to_list(concepts, key_col = "concept_id", text_col = "concept_name")

# On instaure un délai, afin que le dropdown se mette à jour après avoir été créé
shinyjs::delay(500, shiny.fluent::updateDropdown.shinyInput(session, "concept_%widget_id%", options = concepts, value = 0L))

observeEvent(input$show_plot_%widget_id%, {

    # Toujours mettre cette balise au début d'un observer
    %req%

    # Protéger le code en cas d'erreur avec un tryCatch
    tryCatch({

        # 1) Récupérer le concept sélectionné dans le menu déroulant
        selected_concept <-
            selected_concepts %>%
            dplyr::filter(concept_id == input$concept_%widget_id%)

        no_data_available <- TRUE

        # 2) Le domain_id est-il égal à 'Measurement' ?
        if (nrow(selected_concept) > 0 && selected_concept$domain_id == "Measurement"){

            # 3) S'assurer que le tibble des données filtré sur ce concept n'est pas vide
            data <-
                d$measurement %>%
                dplyr::filter(measurement_concept_id == selected_concept$concept_id)

            if(data %>% dplyr::count() %>% dplyr::pull() > 0){

                # 4) Créer le code de l'histogramme
                plot <-
                    data %>%
                    ggplot2::ggplot(ggplot2::aes(x = value_as_number)) +
                    # On prend en compte le nombre de barres depuis notre variable input$num_bins_%widget_id%
                    ggplot2::geom_histogram(colour = "white", fill = "#377EB8", bins = input$num_bins_%widget_id%) +
                    ggplot2::theme_minimal() +
                    # On modifie les titres des axes X et Y
                    ggplot2::labs(x = selected_concept$concept_name, y = i18np$t("occurrences"))

                no_data_available <- FALSE
            }
        }

        # Graphique vide si pas de données disponibles
        if (no_data_available){
                plot <-
                    ggplot2::ggplot() +
                    ggplot2::theme_void() +
                    ggplot2::labs(title = i18np$t("no_data_available"))
        }
        
        # 5) Mise à jour de l'output
        output$plot_%widget_id% <- renderPlot(plot)

    # Le message d'erreur s'affichera dans la console R
    }, error = function(e) cat(paste0("\n", now(), " - ", toString(e))))
})
base,en,fr
concept,Concept to show,Concept à afficher
num_bins,Number of bins,Nombre de barres
show_plot,Show plot,Afficher la figure
none,None,Aucun
occurrences,Occurrences,Occurences
no_data_available,No data available,Pas de données disponibles
Vous venez de créer votre premier plugin ! Vous pouvez dès lors l'utiliser dans un projet et surtout l'améliorer.

L'avantage avec les plugins est que tout ce qui faisable en R ou Python est intégrable dans LinkR sous forme de plugin.

Ce n’est pas simple et cela demande de savoir manipuler la librairie Shiny. Pour en apprendre plus sur Shiny, je vous conseille le très bon livre Mastering Shiny.

Les plugins deviennent rapidement complexes, c’est pourquoi nous avons créé un template de développement pour avoir une base solide et commune pour coder des plugins plus complexes. C’est ce que nous allons voir dans le prochain chapitre.

Partager le plugin

Avant de partager le plugin, il est nécessaire de le documenter, afin que les utilisateurs sachent à quoi il sert et comment l’utiliser.

Pour cela, rendez vous sur la page de Sommaire du plugin. Vous voyez que les champs “Description courte” à gauche et “Description” à droite sont vides.

Cliquez sur le bouton “Editer les informations” à gauche de l’écran.

Vous pourrez alors modifier les informations relatives au plugin, notamment les auteurs ayant aidé dans sa conception et une description courte, qui s’affichera dans la page des plugins.

Nous pourrions par exemple donner cette description courte à notre plugin : “Un plugin permettant de visualiser les données structurées sous forme d’histogramme”.

Vous pouvez également éditer la Description complète en cliquant sur l’icône en haut à droite de l’écran.

Cela ouvrira un éditeur où vous pourrez écrire la description au format Markdown.

Une fois les modifications de la description validées, cliquez sur l’icône “Sauvegarder les modifications” à droite de l’écran.

Pour valider les modifications des informations du plugin, cliquez sur l’icône “Sauvegarder” à gauche de l’écran.

Maintenant que les informations de votre plugin sont bien renseignées, vous allez pouvoir le partager via l’onglet “Partage”, en haut à droite de l’écran, en suivant ce tutoriel.

2 - 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 !