From e820902eb3de5b01db7e1693b63303460df45403 Mon Sep 17 00:00:00 2001 From: Jake Tufts <137207796+JT-39@users.noreply.github.com> Date: Thu, 12 Dec 2024 12:37:43 +0000 Subject: [PATCH] Eesp review fixes (#49) * Chore: Removing compact table setting from info page * Feat: New navigation style - same as apprenticeship, cookies not quite working * Chore: Added gov link colour * Feat: Updated dfeshiny to use the new header() fn * Chore: Deleted unused example_tab file * Chore: Removing overflow site * Chore: Standardising site title useage, improving custom_disconnect_message * Fix: Cookies still not working properly possibly * Chore: Moving footer and left navigation fns to helper * Chore: Storing the main panels in their own scripts in ui_panels * Chore: Removing useShinydashboard() from ui * Chore: Tidying footer code * Chore: Changed .csv to CSV * Fix: Adding generic_ggiraph_options() to error plots * Fix: Using dfereactable on error tables * Fix: Replacing window_title logic to suit new navigation method * Fix: Corrected the cookies issue where the id was too strict so banner link not working * Feat: Adding pages id to bookmark so footer pages included too * Chore: Improving clarity of Create Your Own downlaod data from github msg * Chore: Changing text spinners to black and adding color option to with_gov_spinners() * Chore: Moving navigation fns to ui_layout fn script * Feat: Improving column names for LA and Regions also removing unecessary card headers * Chore: Standardising all tabs, also made tab text not bold * Chore: More consistent font sizes on Create Your Own inputs * Chore: Adding more header and intro info to Create Your Own page * Chore: Adding tooltip and rank colname for All LA regions table * Feat: Adding alt-text options for charts * Feat: Hiding copy to clipboard button and plot from screen readers * Feat: Download UI calculates actual size of the download, both table and image * Feat: Removing image filesize calc due to time taken * Feat: Dataset file size now calculating based on size of csv or xlsx dataset (not id name) * Chore: Adding spinning gear icon on developer updates to hidden for screen readers * Chore: Updating email contacts for team email * Feat: Ammending the support panel so it shows relevant info - still some test text in there * Feat: Adding return to the top button * Chore: Making contents title break words * Fix: Change id for download file text logic as was same as the button id * Fix: Screenshot of charts now working as added shinyWidgets::useShinydashboard() back in * Fix: Removing screenshot tests and shinyWidgets::useShinydashboard() * Chore: Increase chart download modal size so fits the text (file size info) * Chore: Seperating out the calculate_file_size fn from the file_type_input_btn fn --- .github/CONTRIBUTING.md | 52 +- 02_dev/info_pages/dev_user_guide.R | 11 +- 02_dev/la_level_page/la_dev_app.R | 1289 +++++++------ 02_dev/la_level_page/la_dev_app_mod.R | 2 +- R/fn_analysis.R | 8 +- R/fn_helper_functions.R | 4 +- R/fn_load_data.R | 680 +++---- R/fn_table_helpers.R | 4 +- R/fn_ui_layout.R | 205 +- R/lait_modules/mod_all_la_table.R | 64 +- R/lait_modules/mod_app_helpers.R | 5 +- R/lait_modules/mod_app_inputs.R | 364 ++-- R/lait_modules/mod_create_own_charts.R | 18 +- R/lait_modules/mod_create_own_inputs.R | 11 +- R/lait_modules/mod_create_own_table.R | 1715 +++++++++-------- R/lait_modules/mod_info_page.R | 729 +++---- R/lait_modules/mod_la_lvl_charts.R | 736 +++---- R/lait_modules/mod_la_lvl_metadata.R | 2 +- R/lait_modules/mod_la_lvl_table.R | 14 +- R/lait_modules/mod_region_table.R | 81 +- R/lait_modules/mod_stat_n_table.R | 51 +- R/ui_panels/accessibility_statement.R | 318 ++- R/ui_panels/all_la_level_panel.R | 9 + R/ui_panels/create_your_own_panel.R | 43 + R/ui_panels/example_tab_1.R | 130 -- R/ui_panels/la_level_panel.R | 22 + R/ui_panels/region_level_panel.R | 21 + R/ui_panels/stat_n_level_panel.R | 27 + R/ui_panels/support_panel.R | 49 + README.md | 4 +- global.R | 8 +- renv.lock | 63 +- server.R | 117 +- .../la-charts-la_bar_chart.png | Bin 23455 -> 23129 bytes .../la-charts-la_line_chart.png | Bin 25789 -> 25497 bytes tests/testthat/test-UI-mod_la_lvl_table.R | 20 +- ui.R | 317 +-- www/cookie-consent.js | 50 +- www/dfe_shiny_gov_style.css | 73 +- 39 files changed, 3733 insertions(+), 3583 deletions(-) create mode 100644 R/ui_panels/all_la_level_panel.R create mode 100644 R/ui_panels/create_your_own_panel.R delete mode 100644 R/ui_panels/example_tab_1.R create mode 100644 R/ui_panels/la_level_panel.R create mode 100644 R/ui_panels/region_level_panel.R create mode 100644 R/ui_panels/stat_n_level_panel.R create mode 100644 R/ui_panels/support_panel.R diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 8246a6e3..4d8d0b15 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,26 +1,26 @@ -# How to contribute - -Thank you for looking to contribute to our project! The following document outlines our contributors guidance. - -## Reporting bugs or features - -If you spot a bug or feature you want to report, please check first that it has not been reported as an [issue](https://github.com/dfe-analytical-services/shiny-template/issues) already. - -If no issue is open for your bug or feature, please [open a new one](https://github.com/dfe-analytical-services/shiny-template/issues/new) - -Please use the templates provided to ensure that there is sufficient detail in your reported issue. - -## Pull requests - -If you have written some code that fixes a bug or feature, please push this up in a new branch under the format "bug/issue" and open this as a new PR when it is ready to review. - -- Ensure the PR description clearly describes the problem and solution. -- Include the relevant issue number if applicable. - -### Code styling - -All code is styled using the styleR package. Running the `styler::style_dir()` function before pushing up to branch ensures that your code is in line with our style, and passes the pre-commit hooks. - -## Any other questions - -If you have any other questions, please do not hesitate to contact us at jake.tufts@education.gov.uk or explore.statistics@education.gov.uk +# How to contribute + +Thank you for looking to contribute to our project! The following document outlines our contributors guidance. + +## Reporting bugs or features + +If you spot a bug or feature you want to report, please check first that it has not been reported as an [issue](https://github.com/dfe-analytical-services/shiny-template/issues) already. + +If no issue is open for your bug or feature, please [open a new one](https://github.com/dfe-analytical-services/shiny-template/issues/new) + +Please use the templates provided to ensure that there is sufficient detail in your reported issue. + +## Pull requests + +If you have written some code that fixes a bug or feature, please push this up in a new branch under the format "bug/issue" and open this as a new PR when it is ready to review. + +- Ensure the PR description clearly describes the problem and solution. +- Include the relevant issue number if applicable. + +### Code styling + +All code is styled using the styleR package. Running the `styler::style_dir()` function before pushing up to branch ensures that your code is in line with our style, and passes the pre-commit hooks. + +## Any other questions + +If you have any other questions, please do not hesitate to contact us at Darlington.BRIDGE@education.gov.uk or explore.statistics@education.gov.uk diff --git a/02_dev/info_pages/dev_user_guide.R b/02_dev/info_pages/dev_user_guide.R index a432749e..a6f9cc08 100644 --- a/02_dev/info_pages/dev_user_guide.R +++ b/02_dev/info_pages/dev_user_guide.R @@ -77,15 +77,8 @@ ui_dev <- function(input, output, session) { shinyGovstyle::banner( "beta banner", "beta", - paste0( - "This Dashboard is in beta phase and we are still reviewing performance - and reliability. ", - "In case of slowdown or connection issues due to high demand, we have - produced two instances of this site which can be accessed at the - following links: ", - "Site 1 and ", - "Site 2." - ) + "This Dashboard is in beta phase and we are still reviewing performance + and reliability. " ), # Start of app ============================================================ diff --git a/02_dev/la_level_page/la_dev_app.R b/02_dev/la_level_page/la_dev_app.R index 7bc32945..ce50a270 100644 --- a/02_dev/la_level_page/la_dev_app.R +++ b/02_dev/la_level_page/la_dev_app.R @@ -1,645 +1,644 @@ -# Load global -source(here::here("global.R")) - -# Load functions -list.files("R/", full.names = TRUE) |> - (\(x) { - x[grepl("fn_", x)] - })() |> - purrr::walk(source) - - -# UI -ui_dev <- bslib::page_fillable( - - ## Custom CSS ============================================================= - shiny::includeCSS(here::here("www/dfe_shiny_gov_style.css")), - - # Tab header ============================================================== - h1("Local Authority View"), - div( - class = "well", - style = "overflow-y: visible;", - bslib::layout_column_wrap( - width = "15rem", # Minimum width for each input box before wrapping - shiny::selectizeInput( - inputId = "la_input", - label = "LA:", - choices = la_names_bds - ), - shiny::selectizeInput( - inputId = "topic_input", - label = "Topic:", - choices = c("All topics", metric_topics), - multiple = TRUE, - options = list( - maxItems = 1, - placeholder = "No topic selected, showing all indicators.", - plugins = list("clear_button"), - dropdownParent = "body" - ) - ), - shiny::selectizeInput( - inputId = "indicator", - label = "Indicator:", - choices = metric_names - ) - ), - # Conditional State-funded school banner - shiny::uiOutput("state_funded_banner") - ), - div( - class = "well", - style = "overflow-y: visible;", - bslib::card( - bslib::card_header("Local Authority, Region and England"), - bslib::card_body( - shinycssloaders::withSpinner( - reactable::reactableOutput("la_table"), - type = 6, - color = "#1d70b8" - ) - ) - ) - ), - div( - class = "well", - style = "overflow-y: visible;", - bslib::card( - bslib::card_body( - shinycssloaders::withSpinner( - reactable::reactableOutput("la_stats_table"), - type = 6, - color = "#1d70b8", - size = 0.5, - proxy.height = "100px" - ) - ) - ) - ), - div( - class = "well", - style = "overflow-y: visible;", - bslib::navset_card_underline( - id = "la_charts", - bslib::nav_panel( - title = "Line chart", - bslib::card( - bslib::card_body( - shinycssloaders::withSpinner( - ggiraph::girafeOutput("la_line_chart"), - type = 6, - color = "#1d70b8" - ) - ), - full_screen = TRUE - ), - ), - bslib::nav_panel( - title = "Bar chart", - bslib::card( - id = "la_bar_body", - bslib::card_body( - shinycssloaders::withSpinner( - ggiraph::girafeOutput("la_bar_chart"), - type = 6, - color = "#1d70b8" - ) - ), - full_screen = TRUE - ) - ) - ) - ), - div( - class = "well", - style = "overflow-y: visible;", - bslib::card( - bslib::card_body( - h3("Description:"), - shinycssloaders::withSpinner( - textOutput("description"), - type = 6, - color = "#1d70b8" - ), - h3("Methodology:"), - shinycssloaders::withSpinner( - uiOutput("methodology"), - type = 6, - color = "#1d70b8" - ), - div( - # Creates a flex container where the items are centered vertically - style = "display: flex; align-items: baseline;", - h3("Last Updated:", - style = "margin-right: 1rem; margin-bottom: 0.3rem;" - ), - shinycssloaders::withSpinner( - textOutput("last_update"), - type = 6, - color = "#1d70b8" - ) - ), - div( - style = "display: flex; align-items: baseline;", - h3("Next Updated:", - style = "margin-right: 1rem; margin-bottom: 0.3rem;" - ), - shinycssloaders::withSpinner( - uiOutput("next_update"), - type = 6, - color = "#1d70b8" - ) - ), - div( - style = "display: flex; align-items: baseline;", - h3("Source:", - style = "margin-right: 1rem; margin-bottom: 0.3rem;" - ), - shinycssloaders::withSpinner( - uiOutput("source"), - type = 6, - color = "#1d70b8" - ) - ) - ) - ) - ) -) - - -# Server -server_dev <- function(input, output, session) { - # Input ---------------------------------- - # Using the server to power to the provider dropdown for increased speed - shiny::observeEvent(input$topic_input, - { - # Save the currently selected indicator - current_indicator <- input$indicator - - # Get indicator choices for selected topic - # Include all rows if no topic is selected or "All topics" is selected - filtered_topic_bds <- bds_metrics |> - dplyr::filter( - if (is.null(input$topic_input) || "All topics" %in% input$topic_input) { - TRUE - } else { - .data$Topic %in% input$topic_input # Filter by selected topic(s) - } - ) |> - pull_uniques("Measure") - - # Ensure the current indicator stays selected if it's in the new list of available indicators - # Default to the first available indicator if the current one is no longer valid - selected_indicator <- if (current_indicator %in% filtered_topic_bds) { - current_indicator - } else { - filtered_topic_bds[1] - } - - shiny::updateSelectizeInput( - session = session, - inputId = "indicator", - label = "Indicator:", - choices = filtered_topic_bds, - selected = selected_indicator - ) - }, - ignoreNULL = FALSE - ) - - - # Main LA Level table ---------------------------------- - # Filter for selectedindicator - # Define filtered_bds outside of observeEvent - filtered_bds <- reactiveValues(data = NULL) - - observeEvent(input$indicator, { - # Don't change the currently selected indicator if no indicator is selected - if (is.null(input$indicator) || input$indicator == "") { - return() - } - - # Main LA Level table ---------------------------------- - # Filter for selected indicator - filtered_bds$data <- bds_metrics |> - dplyr::filter( - Measure == input$indicator - ) - }) - - # Get decimal places for indicator selected - indicator_dps <- reactive({ - filtered_bds$data |> - get_indicator_dps() - }) - - # Long format LA data - la_long <- reactive({ - # Filter stat neighbour for selected LA - filtered_sn <- stat_n_la |> - dplyr::filter(`LA Name` == input$la_input) - - # Statistical Neighbours - la_sns <- filtered_sn |> - pull_uniques("LA Name_sn") - - # LA region - la_region <- filtered_sn |> - pull_uniques("GOReg") - - # Determine London region to use - la_region_ldn_clean <- clean_ldn_region( - la_region, - filtered_bds$data - ) - - # Then filter for selected LA, region, stat neighbours and relevant national - la_filtered_bds <- filtered_bds$data |> - dplyr::filter( - `LA and Regions` %in% c(input$la_input, la_region_ldn_clean, la_sns, "England") - ) - - # SN average - sn_avg <- la_filtered_bds |> - dplyr::filter(`LA and Regions` %in% la_sns) |> - dplyr::summarise( - values_num = dplyr::na_if(mean(values_num, na.rm = TRUE), NaN), - .by = c("Years", "Years_num") - ) |> - dplyr::mutate( - "LA Number" = "-", - "LA and Regions" = "Statistical Neighbours", - .before = "Years" - ) - - # LA levels long - la_filtered_bds |> - dplyr::filter(`LA and Regions` %notin% c(la_sns)) |> - dplyr::select(`LA Number`, `LA and Regions`, Years, Years_num, values_num) |> - dplyr::bind_rows(sn_avg) |> - dplyr::mutate( - `LA and Regions` = factor( - `LA and Regions`, - levels = c( - input$la_input, la_region_ldn_clean, - "Statistical Neighbours", "England" - ) - ) - ) - }) - - # Difference between last two years - la_diff <- reactive({ - la_long() |> - dplyr::group_by(`LA and Regions`) |> - dplyr::arrange(`LA and Regions`, desc(Years)) |> - dplyr::mutate( - values_num = dplyr::lag(values_num) - values_num, - Years = "Change from previous year" - ) |> - dplyr::filter(dplyr::row_number() == 2) - }) - - # Build Main LA Level table - la_table <- shiny::reactive({ - # Join difference and pivot wider to recreate LAIT table - la_long() |> - dplyr::bind_rows(la_diff()) |> - tidyr::pivot_wider( - id_cols = c("LA Number", "LA and Regions"), - names_from = Years, - values_from = values_num - ) |> - dplyr::arrange(`LA and Regions`) - }) - - - # Stet funded school banner (appears for certain indicators) - output$state_funded_banner <- renderUI({ - # Get whether state-funded idnicator - state_funded <- filtered_bds$data |> - pull_uniques("state_funded_flag") |> - (\(x) !is.na(x))() - - # Render banner if state-funded - if (state_funded) { - tagList( - br(), - shinyGovstyle::noti_banner( - inputId = "notId", - title_txt = "Note", - body_txt = "Data includes only State-funded Schools." - ) - ) - } - }) - - output$la_table <- reactable::renderReactable({ - dfe_reactable( - la_table(), - columns = utils::modifyList( - format_num_reactable_cols( - la_table(), - get_indicator_dps(filtered_bds$data), - num_exclude = "LA Number" - ), - set_custom_default_col_widths() - ), - rowStyle = function(index) { - highlight_selected_row(index, la_table(), input$la_input) - } - ) - }) - - - # Stats LA Level table ---------------------------------- - la_stats_table <- shiny::reactive({ - # Extract change from prev year (from LA table) - la_change_prev <- la_diff() |> - filter_la_regions(input$la_input, pull_col = "values_num") - - # Set the trend value - la_trend <- as.numeric(la_change_prev) - - # Get polarity of indicator - la_indicator_polarity <- filtered_bds$data |> - pull_uniques("Polarity") - - # Get latest rank, ties are set to min & NA vals to NA rank - la_rank <- filtered_bds$data |> - filter_la_regions(la_names_bds, latest = TRUE) |> - calculate_rank(la_indicator_polarity) |> - filter_la_regions(input$la_input, pull_col = "rank") - - # Calculate quartile bands for indicator - la_quartile_bands <- filtered_bds$data |> - filter_la_regions(la_names_bds, latest = TRUE, pull_col = "values_num") |> - quantile(na.rm = TRUE) - - # Extracting LA latest value - la_indicator_val <- filtered_bds$data |> - filter_la_regions(input$la_input, latest = TRUE, pull_col = "values_num") - - # Boolean as to whether to include Quartile Banding - no_show_qb <- input$indicator %in% no_qb_indicators - - # Calculating which quartile this value sits in - la_quartile <- calculate_quartile_band( - la_indicator_val, - la_quartile_bands, - la_indicator_polarity - ) - - # Build stats LA Level table - la_stats_table <- build_la_stats_table( - la_diff(), - input$la_input, - la_trend, - la_change_prev, - la_rank, - la_quartile, - la_quartile_bands, - get_indicator_dps(filtered_bds$data), - la_indicator_polarity, - no_show_qb - ) - - la_stats_table - }) - - output$la_stats_table <- reactable::renderReactable({ - dfe_reactable( - la_stats_table(), - columns = modifyList( - # Create the reactable with specific column alignments - format_num_reactable_cols( - la_stats_table(), - get_indicator_dps(filtered_bds$data), - num_exclude = "LA Number", - categorical = c( - "Trend", "Quartile Banding", "Latest National Rank", - "A", "B", - "C", "D" - ) - ), - # Style Quartile Banding column with colour - list( - set_custom_default_col_widths(), - Trend = reactable::colDef( - header = add_tooltip_to_reactcol( - "Trend", - "Based on change from previous year" - ), - cell = trend_icon_renderer, - style = function(value) { - get_trend_colour(value, la_stats_table()$Polarity[1]) - } - ), - `Quartile Banding` = reactable::colDef( - style = function(value, index) { - quartile_banding_col_def(la_stats_table()[index, ]) - } - ), - `Latest National Rank` = reactable::colDef( - header = add_tooltip_to_reactcol( - "Latest National Rank", - "Rank 1 is always best/top" - ) - ), - Polarity = reactable::colDef(show = FALSE) - ) - ) - ) - }) - - - # LA Level line chart plot ---------------------------------- - la_line_chart <- reactive({ - # Generate the covid plot data if add_covid_plot is TRUE - covid_plot <- calculate_covid_plot( - la_long(), - covid_affected_data, - input$indicator, - "line" - ) - - # Build plot - la_line_chart <- la_long() |> - # Set geog orders so selected LA is on top of plot - reorder_la_regions(reverse = TRUE) |> - ggplot2::ggplot() + - ggiraph::geom_line_interactive( - ggplot2::aes( - x = Years_num, - y = values_num, - color = `LA and Regions`, - data_id = `LA and Regions` - ), - na.rm = TRUE, - linewidth = 1 - ) + - # Only show point data where line won't appear (NAs) - ggplot2::geom_point( - data = subset(create_show_point( - la_long(), - covid_affected_data, - input$indicator - ), show_point), - ggplot2::aes( - x = Years_num, - y = values_num, - color = `LA and Regions` - ), - shape = 15, - size = 1, - na.rm = TRUE - ) + - # Add COVID plot if indicator affected - add_covid_elements(covid_plot) + - format_axes(la_long()) + - set_plot_colours(la_long(), focus_group = input$la_input) + - set_plot_labs(filtered_bds$data) + - custom_theme() + - # Revert order of the legend so goes from right to left - ggplot2::guides(color = ggplot2::guide_legend(reverse = TRUE)) - - # Creating vertical geoms to make vertical hover tooltip - vertical_hover <- lapply( - get_years(la_long()), - tooltip_vlines, - la_long(), - indicator_dps(), - input$la_input - ) - - # Plotting interactive graph - ggiraph::girafe( - ggobj = (la_line_chart + vertical_hover), - width_svg = 8.5, - options = generic_ggiraph_options( - opts_hover( - css = "stroke-dasharray:5,5;stroke:black;stroke-width:2px;" - ) - ), - fonts = list(sans = "Arial") - ) - }) - - output$la_line_chart <- ggiraph::renderGirafe({ - la_line_chart() - }) - - - # LA Level bar plot ---------------------------------- - la_bar_chart <- reactive({ - # Generate the covid plot data if add_covid_plot is TRUE - covid_plot <- calculate_covid_plot( - la_long(), - covid_affected_data, - input$indicator, - "bar" - ) - - # Build plot - la_bar_chart <- la_long() |> - ggplot2::ggplot() + - ggiraph::geom_col_interactive( - ggplot2::aes( - x = Years_num, - y = values_num, - fill = `LA and Regions`, - tooltip = tooltip_bar(la_long(), indicator_dps(), input$la_input), - data_id = `LA and Regions` - ), - position = "dodge", - width = 0.6, - na.rm = TRUE, - colour = "black" - ) + - # Add COVID plot if indicator affected - add_covid_elements(covid_plot) + - format_axes(la_long()) + - set_plot_colours(la_long(), "fill", input$la_input) + - set_plot_labs(filtered_bds$data) + - custom_theme() - - # Plotting interactive graph - ggiraph::girafe( - ggobj = la_bar_chart, - width_svg = 8.5, - options = generic_ggiraph_options( - opts_hover( - css = "stroke-dasharray:5,5;stroke:black;stroke-width:2px;" - ) - ), - fonts = list(sans = "Arial") - ) - }) - - - output$la_bar_chart <- ggiraph::renderGirafe({ - la_bar_chart() - }) - - - # LA Metadata ---------------------------------- - # Reactive values to store previous data - previous_metadata <- reactiveValues( - description = NULL, - methodology = NULL, - last_update = NULL, - next_update = NULL, - source = NULL - ) - - # Outputs using the helper function - output$description <- renderText({ - update_and_fetch_metadata( - input$indicator, - "Description", - previous_metadata, - "description" - ) - }) - - output$methodology <- renderUI({ - update_and_fetch_metadata( - input$indicator, - "Methodology", - previous_metadata, - "methodology" - ) - }) - - output$last_update <- renderText({ - update_and_fetch_metadata( - input$indicator, - "Last Update", - previous_metadata, - "last_update" - ) - }) - - output$next_update <- renderUI({ - update_and_fetch_metadata( - input$indicator, - "Next Update", - previous_metadata, - "next_update" - ) - }) - - output$source <- renderUI({ - hyperlink <- update_and_fetch_metadata( - input$indicator, - "Hyperlink(s)", - previous_metadata, - "source" - ) - dfeshiny::external_link(href = hyperlink, link_text = input$indicator) - }) -} - -# App -shinyApp(ui_dev, server_dev) +# Load global +source(here::here("global.R")) + +# Load functions +list.files("R/", full.names = TRUE) |> + (\(x) { + x[grepl("fn_", x)] + })() |> + purrr::walk(source) + + +# UI +ui_dev <- bslib::page_fillable( + + ## Custom CSS ============================================================= + shiny::includeCSS(here::here("www/dfe_shiny_gov_style.css")), + + # Tab header ============================================================== + h1("Local Authority View"), + div( + class = "well", + style = "overflow-y: visible;", + bslib::layout_column_wrap( + width = "15rem", # Minimum width for each input box before wrapping + shiny::selectizeInput( + inputId = "la_input", + label = "LA:", + choices = la_names_bds + ), + shiny::selectizeInput( + inputId = "topic_input", + label = "Topic:", + choices = c("All topics", metric_topics), + multiple = TRUE, + options = list( + maxItems = 1, + placeholder = "No topic selected, showing all indicators.", + plugins = list("clear_button"), + dropdownParent = "body" + ) + ), + shiny::selectizeInput( + inputId = "indicator", + label = "Indicator:", + choices = metric_names + ) + ), + # Conditional State-funded school banner + shiny::uiOutput("state_funded_banner") + ), + div( + class = "well", + style = "overflow-y: visible;", + bslib::card( + bslib::card_body( + shinycssloaders::withSpinner( + reactable::reactableOutput("la_table"), + type = 6, + color = "#1d70b8" + ) + ) + ) + ), + div( + class = "well", + style = "overflow-y: visible;", + bslib::card( + bslib::card_body( + shinycssloaders::withSpinner( + reactable::reactableOutput("la_stats_table"), + type = 6, + color = "#1d70b8", + size = 0.5, + proxy.height = "100px" + ) + ) + ) + ), + div( + class = "well", + style = "overflow-y: visible;", + bslib::navset_card_underline( + id = "la_charts", + bslib::nav_panel( + title = "Line chart", + bslib::card( + bslib::card_body( + shinycssloaders::withSpinner( + ggiraph::girafeOutput("la_line_chart"), + type = 6, + color = "#1d70b8" + ) + ), + full_screen = TRUE + ), + ), + bslib::nav_panel( + title = "Bar chart", + bslib::card( + id = "la_bar_body", + bslib::card_body( + shinycssloaders::withSpinner( + ggiraph::girafeOutput("la_bar_chart"), + type = 6, + color = "#1d70b8" + ) + ), + full_screen = TRUE + ) + ) + ) + ), + div( + class = "well", + style = "overflow-y: visible;", + bslib::card( + bslib::card_body( + h3("Description:"), + shinycssloaders::withSpinner( + textOutput("description"), + type = 6, + color = "#1d70b8" + ), + h3("Methodology:"), + shinycssloaders::withSpinner( + uiOutput("methodology"), + type = 6, + color = "#1d70b8" + ), + div( + # Creates a flex container where the items are centered vertically + style = "display: flex; align-items: baseline;", + h3("Last Updated:", + style = "margin-right: 1rem; margin-bottom: 0.3rem;" + ), + shinycssloaders::withSpinner( + textOutput("last_update"), + type = 6, + color = "#1d70b8" + ) + ), + div( + style = "display: flex; align-items: baseline;", + h3("Next Updated:", + style = "margin-right: 1rem; margin-bottom: 0.3rem;" + ), + shinycssloaders::withSpinner( + uiOutput("next_update"), + type = 6, + color = "#1d70b8" + ) + ), + div( + style = "display: flex; align-items: baseline;", + h3("Source:", + style = "margin-right: 1rem; margin-bottom: 0.3rem;" + ), + shinycssloaders::withSpinner( + uiOutput("source"), + type = 6, + color = "#1d70b8" + ) + ) + ) + ) + ) +) + + +# Server +server_dev <- function(input, output, session) { + # Input ---------------------------------- + # Using the server to power to the provider dropdown for increased speed + shiny::observeEvent(input$topic_input, + { + # Save the currently selected indicator + current_indicator <- input$indicator + + # Get indicator choices for selected topic + # Include all rows if no topic is selected or "All topics" is selected + filtered_topic_bds <- bds_metrics |> + dplyr::filter( + if (is.null(input$topic_input) || "All topics" %in% input$topic_input) { + TRUE + } else { + .data$Topic %in% input$topic_input # Filter by selected topic(s) + } + ) |> + pull_uniques("Measure") + + # Ensure the current indicator stays selected if it's in the new list of available indicators + # Default to the first available indicator if the current one is no longer valid + selected_indicator <- if (current_indicator %in% filtered_topic_bds) { + current_indicator + } else { + filtered_topic_bds[1] + } + + shiny::updateSelectizeInput( + session = session, + inputId = "indicator", + label = "Indicator:", + choices = filtered_topic_bds, + selected = selected_indicator + ) + }, + ignoreNULL = FALSE + ) + + + # Main LA Level table ---------------------------------- + # Filter for selectedindicator + # Define filtered_bds outside of observeEvent + filtered_bds <- reactiveValues(data = NULL) + + observeEvent(input$indicator, { + # Don't change the currently selected indicator if no indicator is selected + if (is.null(input$indicator) || input$indicator == "") { + return() + } + + # Main LA Level table ---------------------------------- + # Filter for selected indicator + filtered_bds$data <- bds_metrics |> + dplyr::filter( + Measure == input$indicator + ) + }) + + # Get decimal places for indicator selected + indicator_dps <- reactive({ + filtered_bds$data |> + get_indicator_dps() + }) + + # Long format LA data + la_long <- reactive({ + # Filter stat neighbour for selected LA + filtered_sn <- stat_n_la |> + dplyr::filter(`LA Name` == input$la_input) + + # Statistical Neighbours + la_sns <- filtered_sn |> + pull_uniques("LA Name_sn") + + # LA region + la_region <- filtered_sn |> + pull_uniques("GOReg") + + # Determine London region to use + la_region_ldn_clean <- clean_ldn_region( + la_region, + filtered_bds$data + ) + + # Then filter for selected LA, region, stat neighbours and relevant national + la_filtered_bds <- filtered_bds$data |> + dplyr::filter( + `LA and Regions` %in% c(input$la_input, la_region_ldn_clean, la_sns, "England") + ) + + # SN average + sn_avg <- la_filtered_bds |> + dplyr::filter(`LA and Regions` %in% la_sns) |> + dplyr::summarise( + values_num = dplyr::na_if(mean(values_num, na.rm = TRUE), NaN), + .by = c("Years", "Years_num") + ) |> + dplyr::mutate( + "LA Number" = "-", + "LA and Regions" = "Statistical Neighbours", + .before = "Years" + ) + + # LA levels long + la_filtered_bds |> + dplyr::filter(`LA and Regions` %notin% c(la_sns)) |> + dplyr::select(`LA Number`, `LA and Regions`, Years, Years_num, values_num) |> + dplyr::bind_rows(sn_avg) |> + dplyr::mutate( + `LA and Regions` = factor( + `LA and Regions`, + levels = c( + input$la_input, la_region_ldn_clean, + "Statistical Neighbours", "England" + ) + ) + ) + }) + + # Difference between last two years + la_diff <- reactive({ + la_long() |> + dplyr::group_by(`LA and Regions`) |> + dplyr::arrange(`LA and Regions`, desc(Years)) |> + dplyr::mutate( + values_num = dplyr::lag(values_num) - values_num, + Years = "Change from previous year" + ) |> + dplyr::filter(dplyr::row_number() == 2) + }) + + # Build Main LA Level table + la_table <- shiny::reactive({ + # Join difference and pivot wider to recreate LAIT table + la_long() |> + dplyr::bind_rows(la_diff()) |> + tidyr::pivot_wider( + id_cols = c("LA Number", "LA and Regions"), + names_from = Years, + values_from = values_num + ) |> + dplyr::arrange(`LA and Regions`) + }) + + + # Stet funded school banner (appears for certain indicators) + output$state_funded_banner <- renderUI({ + # Get whether state-funded idnicator + state_funded <- filtered_bds$data |> + pull_uniques("state_funded_flag") |> + (\(x) !is.na(x))() + + # Render banner if state-funded + if (state_funded) { + tagList( + br(), + shinyGovstyle::noti_banner( + inputId = "notId", + title_txt = "Note", + body_txt = "Data includes only State-funded Schools." + ) + ) + } + }) + + output$la_table <- reactable::renderReactable({ + dfe_reactable( + la_table(), + columns = utils::modifyList( + format_num_reactable_cols( + la_table(), + get_indicator_dps(filtered_bds$data), + num_exclude = "LA Number" + ), + set_custom_default_col_widths() + ), + rowStyle = function(index) { + highlight_selected_row(index, la_table(), input$la_input) + } + ) + }) + + + # Stats LA Level table ---------------------------------- + la_stats_table <- shiny::reactive({ + # Extract change from prev year (from LA table) + la_change_prev <- la_diff() |> + filter_la_regions(input$la_input, pull_col = "values_num") + + # Set the trend value + la_trend <- as.numeric(la_change_prev) + + # Get polarity of indicator + la_indicator_polarity <- filtered_bds$data |> + pull_uniques("Polarity") + + # Get latest rank, ties are set to min & NA vals to NA rank + la_rank <- filtered_bds$data |> + filter_la_regions(la_names_bds, latest = TRUE) |> + calculate_rank(la_indicator_polarity) |> + filter_la_regions(input$la_input, pull_col = "rank") + + # Calculate quartile bands for indicator + la_quartile_bands <- filtered_bds$data |> + filter_la_regions(la_names_bds, latest = TRUE, pull_col = "values_num") |> + quantile(na.rm = TRUE) + + # Extracting LA latest value + la_indicator_val <- filtered_bds$data |> + filter_la_regions(input$la_input, latest = TRUE, pull_col = "values_num") + + # Boolean as to whether to include Quartile Banding + no_show_qb <- input$indicator %in% no_qb_indicators + + # Calculating which quartile this value sits in + la_quartile <- calculate_quartile_band( + la_indicator_val, + la_quartile_bands, + la_indicator_polarity + ) + + # Build stats LA Level table + la_stats_table <- build_la_stats_table( + la_diff(), + input$la_input, + la_trend, + la_change_prev, + la_rank, + la_quartile, + la_quartile_bands, + get_indicator_dps(filtered_bds$data), + la_indicator_polarity, + no_show_qb + ) + + la_stats_table + }) + + output$la_stats_table <- reactable::renderReactable({ + dfe_reactable( + la_stats_table(), + columns = modifyList( + # Create the reactable with specific column alignments + format_num_reactable_cols( + la_stats_table(), + get_indicator_dps(filtered_bds$data), + num_exclude = "LA Number", + categorical = c( + "Trend", "Quartile Banding", "Latest National Rank", + "A", "B", + "C", "D" + ) + ), + # Style Quartile Banding column with colour + list( + set_custom_default_col_widths(), + Trend = reactable::colDef( + header = add_tooltip_to_reactcol( + "Trend", + "Based on change from previous year" + ), + cell = trend_icon_renderer, + style = function(value) { + get_trend_colour(value, la_stats_table()$Polarity[1]) + } + ), + `Quartile Banding` = reactable::colDef( + style = function(value, index) { + quartile_banding_col_def(la_stats_table()[index, ]) + } + ), + `Latest National Rank` = reactable::colDef( + header = add_tooltip_to_reactcol( + "Latest National Rank", + "Rank 1 is always best/top" + ) + ), + Polarity = reactable::colDef(show = FALSE) + ) + ) + ) + }) + + + # LA Level line chart plot ---------------------------------- + la_line_chart <- reactive({ + # Generate the covid plot data if add_covid_plot is TRUE + covid_plot <- calculate_covid_plot( + la_long(), + covid_affected_data, + input$indicator, + "line" + ) + + # Build plot + la_line_chart <- la_long() |> + # Set geog orders so selected LA is on top of plot + reorder_la_regions(reverse = TRUE) |> + ggplot2::ggplot() + + ggiraph::geom_line_interactive( + ggplot2::aes( + x = Years_num, + y = values_num, + color = `LA and Regions`, + data_id = `LA and Regions` + ), + na.rm = TRUE, + linewidth = 1 + ) + + # Only show point data where line won't appear (NAs) + ggplot2::geom_point( + data = subset(create_show_point( + la_long(), + covid_affected_data, + input$indicator + ), show_point), + ggplot2::aes( + x = Years_num, + y = values_num, + color = `LA and Regions` + ), + shape = 15, + size = 1, + na.rm = TRUE + ) + + # Add COVID plot if indicator affected + add_covid_elements(covid_plot) + + format_axes(la_long()) + + set_plot_colours(la_long(), focus_group = input$la_input) + + set_plot_labs(filtered_bds$data) + + custom_theme() + + # Revert order of the legend so goes from right to left + ggplot2::guides(color = ggplot2::guide_legend(reverse = TRUE)) + + # Creating vertical geoms to make vertical hover tooltip + vertical_hover <- lapply( + get_years(la_long()), + tooltip_vlines, + la_long(), + indicator_dps(), + input$la_input + ) + + # Plotting interactive graph + ggiraph::girafe( + ggobj = (la_line_chart + vertical_hover), + width_svg = 8.5, + options = generic_ggiraph_options( + opts_hover( + css = "stroke-dasharray:5,5;stroke:black;stroke-width:2px;" + ) + ), + fonts = list(sans = "Arial") + ) + }) + + output$la_line_chart <- ggiraph::renderGirafe({ + la_line_chart() + }) + + + # LA Level bar plot ---------------------------------- + la_bar_chart <- reactive({ + # Generate the covid plot data if add_covid_plot is TRUE + covid_plot <- calculate_covid_plot( + la_long(), + covid_affected_data, + input$indicator, + "bar" + ) + + # Build plot + la_bar_chart <- la_long() |> + ggplot2::ggplot() + + ggiraph::geom_col_interactive( + ggplot2::aes( + x = Years_num, + y = values_num, + fill = `LA and Regions`, + tooltip = tooltip_bar(la_long(), indicator_dps(), input$la_input), + data_id = `LA and Regions` + ), + position = "dodge", + width = 0.6, + na.rm = TRUE, + colour = "black" + ) + + # Add COVID plot if indicator affected + add_covid_elements(covid_plot) + + format_axes(la_long()) + + set_plot_colours(la_long(), "fill", input$la_input) + + set_plot_labs(filtered_bds$data) + + custom_theme() + + # Plotting interactive graph + ggiraph::girafe( + ggobj = la_bar_chart, + width_svg = 8.5, + options = generic_ggiraph_options( + opts_hover( + css = "stroke-dasharray:5,5;stroke:black;stroke-width:2px;" + ) + ), + fonts = list(sans = "Arial") + ) + }) + + + output$la_bar_chart <- ggiraph::renderGirafe({ + la_bar_chart() + }) + + + # LA Metadata ---------------------------------- + # Reactive values to store previous data + previous_metadata <- reactiveValues( + description = NULL, + methodology = NULL, + last_update = NULL, + next_update = NULL, + source = NULL + ) + + # Outputs using the helper function + output$description <- renderText({ + update_and_fetch_metadata( + input$indicator, + "Description", + previous_metadata, + "description" + ) + }) + + output$methodology <- renderUI({ + update_and_fetch_metadata( + input$indicator, + "Methodology", + previous_metadata, + "methodology" + ) + }) + + output$last_update <- renderText({ + update_and_fetch_metadata( + input$indicator, + "Last Update", + previous_metadata, + "last_update" + ) + }) + + output$next_update <- renderUI({ + update_and_fetch_metadata( + input$indicator, + "Next Update", + previous_metadata, + "next_update" + ) + }) + + output$source <- renderUI({ + hyperlink <- update_and_fetch_metadata( + input$indicator, + "Hyperlink(s)", + previous_metadata, + "source" + ) + dfeshiny::external_link(href = hyperlink, link_text = input$indicator) + }) +} + +# App +shinyApp(ui_dev, server_dev) diff --git a/02_dev/la_level_page/la_dev_app_mod.R b/02_dev/la_level_page/la_dev_app_mod.R index c5bbb4f6..014fe2d7 100644 --- a/02_dev/la_level_page/la_dev_app_mod.R +++ b/02_dev/la_level_page/la_dev_app_mod.R @@ -38,7 +38,7 @@ ui_mod <- bslib::page_fillable( div( class = "well", style = "overflow-y: visible;", - bslib::navset_card_underline( + bslib::navset_card_tab( id = "la_charts", LA_LineChartUI("la_line_chart"), LA_BarChartUI("la_bar_chart") diff --git a/R/fn_analysis.R b/R/fn_analysis.R index 6a3d3f48..cfb6b0d1 100644 --- a/R/fn_analysis.R +++ b/R/fn_analysis.R @@ -269,10 +269,11 @@ calculate_rank <- function(filtered_data, indicator_polarity) { #' # Assuming `df` is your data frame and `la_names_vec` is a vector of LA names #' filtered_data <- filter_la_data_all_la(df, la_names_vec) #' -filter_la_data_all_la <- function(data, la_names) { +filter_la_data_all_la <- function(data, la_names, geog_colname) { data |> dplyr::filter(`LA and Regions` %in% la_names) |> - dplyr::arrange(`LA and Regions`) + dplyr::arrange(`LA and Regions`) |> + dplyr::rename("LA" = `LA and Regions`) } @@ -310,7 +311,8 @@ filter_region_data_all_la <- function(data, la_names) { ))) == 0) ) |> dplyr::mutate(Rank = "") |> - dplyr::arrange(`LA Number`) + dplyr::arrange(`LA Number`) |> + dplyr::rename("Region" = `LA and Regions`) } diff --git a/R/fn_helper_functions.R b/R/fn_helper_functions.R index 0fd87b01..8c7a5917 100644 --- a/R/fn_helper_functions.R +++ b/R/fn_helper_functions.R @@ -684,11 +684,11 @@ add_line_breaks <- function(text, max_length = 20) { #' # Wrap a plot with a larger spinner #' with_gov_spinner(plotOutput("la_plot"), size = 2) #' -with_gov_spinner <- function(ui_element, spinner_type = 6, size = 1) { +with_gov_spinner <- function(ui_element, spinner_type = 6, size = 1, color = "#1d70b8") { shinycssloaders::withSpinner( ui_element, type = spinner_type, - color = "#1d70b8", + color = color, size = size, proxy.height = paste0(250 * size, "px") ) diff --git a/R/fn_load_data.R b/R/fn_load_data.R index b3c23e27..e06b0e07 100644 --- a/R/fn_load_data.R +++ b/R/fn_load_data.R @@ -1,322 +1,358 @@ -#' Shared Folder Path -#' -#' This variable holds the path to the 'LAIT - modernisation' teams channel folder. -#' The folder is synchronised and located in the user's local system. -#' -shared_folder <- paste0( - r"(C:\Users\jtufts\Department for Education\LAIT modernisation - General)", - r"(\LAIT Modernisation 2024\Information for App Development)" -) - - -#' Clean SNP Column Names -#' -#' This function cleans the column names of a given data frame. Specifically, it adds numbers to 'SNP' columns -#' based on the numbers extracted from 'SN' columns. -#' -#' @param data A data frame that contains columns with names starting with 'SN' and 'SNP'. -#' -#' @return A data frame with cleaned column names. -#' -#' @examples -#' \dontrun{ -#' data <- data.frame(SN1 = c(1, 2), SNP = c(3, 4), SN2 = c(5, 6), SNP = c(7, 8)) -#' clean_snp_colnames(data) -#' } -#' -#' @export -clean_snp_colnames <- function(data) { - col_names <- colnames(data) - - # Logical vectors to identify "SN" and "SNP" columns - sn_cols <- which(grepl("^SN\\d+$", col_names)) - snp_cols <- which(grepl("^SNP", col_names)) - - if (length(sn_cols) < 1) { - stop( - paste0( - "SN columns do not seem to be in the right format e.g., SNx", - "where x is a number" - ) - ) - } - - if (length(snp_cols) < 1) { - stop( - paste0( - "SNP columns do not seem to be in the right format e.g., SNPx", - "where x can be anything" - ) - ) - } - - # Extract the numbers from "SN" columns - sn_numbers <- gsub("^SN", "", col_names[sn_cols]) - - # Clean column names vector - clean_col_names <- col_names - - # Assign new names to "SNP" columns using extracted "SN" column numbers - clean_col_names[snp_cols] <- paste0("SNP", sn_numbers) - - # Assign the new column names to the dataframe - colnames(data) <- clean_col_names - - data -} - - -#' Create Measure Key -#' -#' This function creates a new column 'measure_key' in the given data frame. The 'measure_key' is created by -#' concatenating the 'Topic' and 'Measure_short' columns, replacing spaces with underscores, and converting -#' the resulting string to lowercase. -#' -#' @param data A data frame that contains the columns 'Topic' and 'Measure_short'. -#' -#' @return A data frame with an additional 'measure_key' column. -#' -#' @examples -#' \dontrun{ -#' data <- data.frame(Topic = c("Topic1", "Topic2"), Measure_short = c("Measure1", "Measure2")) -#' create_measure_key(data) -#' } -#' -#' @export -create_measure_key <- function(data) { - data |> - dplyr::mutate( - measure_key = tolower(gsub(" ", "_", paste(Topic, Measure_short))), - .after = Measure_short - ) -} - - -#' Generate Downloadable File Based on Data and File Type -#' -#' This function generates a temporary file for download based on the provided -#' data and file type. Supported file types include CSV, XLSX, PNG, and HTML. -#' The function writes the data to the corresponding file format, and returns -#' the file path for download. -#' -#' @param data A dataset or object to be saved, which can be a data frame or -#' a reactive list containing specific data for charts or widgets. -#' @param file_type A string specifying the desired file type for download. -#' Supported types are: "csv", "xlsx", "png", and "html". This string -#' is matched case-insensitively. -#' -#' @return A character string representing the path to the generated file. -#' The file will be saved temporarily with the appropriate extension -#' based on the provided `file_type`. -#' -#' @details -#' The function handles multiple file types as follows: -#' - For `"csv"`, it writes the data to a CSV file without row names. -#' - For `"xlsx"`, it saves the data as an Excel file with auto column widths. -#' - For `"png"`, it saves a ggplot object to a PNG file with specified size. -#' - For `"html"`, it saves an HTML widget to an HTML file. -#' If an unsupported file type is provided, the function returns an error. -#' -#' @examples -#' \dontrun{ -#' # Save a data frame as a CSV file -#' generate_download_file(mtcars, "csv") -#' -#' # Save a ggplot chart as PNG -#' plot_data <- list(png = ggplot(mtcars, aes(mpg, wt)) + -#' geom_point()) -#' generate_download_file(plot_data, "png") -#' } -#' -generate_download_file <- function(data, file_type, svg_width = 8.5) { - out <- tempfile(fileext = dplyr::case_when( - grepl("csv", file_type, ignore.case = TRUE) ~ ".csv", - grepl("xlsx", file_type, ignore.case = TRUE) ~ ".xlsx", - grepl("svg", file_type, ignore.case = TRUE) ~ ".svg", - grepl("html", file_type, ignore.case = TRUE) ~ ".html", - TRUE ~ "Error" - )) - - if (grepl("csv", file_type, ignore.case = TRUE)) { - write.csv(data, file = out, row.names = FALSE) - } else if (grepl("xlsx", file_type, ignore.case = TRUE)) { - openxlsx::write.xlsx(data, file = out, colWidths = "Auto") - } else if (grepl("svg", file_type, ignore.case = TRUE)) { - ggplot2::ggsave(filename = out, plot = data, width = svg_width, height = 6) - } else if (grepl("html", file_type, ignore.case = TRUE)) { - htmlwidgets::saveWidget(widget = data, file = out) - } - - out -} - - -#' Create Download Handler for Shiny Application -#' -#' This function creates a `downloadHandler` for use in a Shiny app, allowing -#' users to download a file in the specified format. The file name is generated -#' dynamically based on the provided extension input and table name prefix. -#' -#' @param export_file A string representing the file path of the file to be -#' downloaded. This should be a pre-generated file ready for download. -#' @param ext_input A reactive expression that returns the file type selected -#' by the user. Supported types are: "xlsx", "csv", "png", and "html". -#' The file extension is matched case-insensitively. -#' @param table_name_prefix A reactive expression returning a vector of strings -#' that represent parts of the file name. These are concatenated with -#' hyphens (`-`) to form the base of the download file name. -#' -#' @return A `downloadHandler` object for use in the server function of a -#' Shiny app. This handler will allow the user to download a file -#' with the desired format and name. -#' -#' @details -#' The filename is generated based on the table name prefix and current date, -#' with the appropriate file extension determined by the user's selection. -#' Supported extensions are: `.xlsx`, `.csv`, `.png`, and `.html`. The content -#' of the file is copied from `export_file`, and a notification is shown to -#' indicate that the file is being generated. -#' -#' @examples -#' \dontrun{ -#' # Create a download handler for a CSV file -#' create_download_handler("path/to/file.csv", reactive("csv"), reactive("data-table")) -#' } -#' -create_download_handler <- function(local) { - downloadHandler( - filename = function() { - file_ext <- dplyr::case_when( - grepl("xlsx", local$file_type, ignore.case = TRUE) ~ ".xlsx", - grepl("csv", local$file_type, ignore.case = TRUE) ~ ".csv", - grepl("svg", local$file_type, ignore.case = TRUE) ~ ".svg", - grepl("html", local$file_type, ignore.case = TRUE) ~ ".html", - TRUE ~ "Error" - ) - paste0(paste(local$file_name, collapse = "-"), "-", Sys.Date(), file_ext) - }, - content = function(file) { - pop_up <- shiny::showNotification("Generating download file", duration = NULL) - file.copy(local$export_file, file) - on.exit(shiny::removeNotification(pop_up), add = TRUE) - } - ) -} - - -#' Generate a Radio Button Input for File Type Selection -#' -#' This function creates a radio button input for selecting the download file -#' format in a Shiny application. The label, hint, and choices for the radio -#' button are dynamically generated based on the type of file being downloaded -#' (either a data table or plot). -#' -#' @param input_id A string representing the input ID for the radio button, -#' which will be used to access the selected file type in the Shiny -#' server logic. -#' @param file_type A string that specifies the type of file being downloaded. -#' It can either be "table" (for downloading data tables) or any other -#' string (for downloading plots). Defaults to "table". -#' -#' @return A `shinyGovstyle::radio_button_Input` object to be included in the -#' Shiny UI, allowing the user to choose between available file formats -#' for the download. -#' -#' @details -#' When the `file_type` is "table", the user will have the option to select -#' between "CSV" and "XLSX" file formats. For other file types, the user can -#' select between "PNG" and "HTML". The default selected option is "CSV" for -#' tables and "PNG" for plots. The hint label displayed below the input will -#' provide guidance based on the type of download. -#' -#' @examples -#' \dontrun{ -#' # Generate file type selection for a table -#' file_type_input_btn("file_type", file_type = "table") -#' -#' # Generate file type selection for a plot -#' file_type_input_btn("file_type", file_type = "plot") -#' } -#' -file_type_input_btn <- function(input_id, file_type = "table") { - shinyGovstyle::radio_button_Input( - inputId = input_id, - label = h2("Choose download file format"), - hint_label = if (file_type == "table") { - paste0( - "This will download all data related to the providers and options selected.", - " The XLSX format is designed for use in Microsoft Excel." - ) - } else { - paste0( - "This will download the plots related to the options selected.", - " The HTML format contains the interactive element." - ) - }, - choices = if (file_type == "table") { - c("CSV", "XLSX") - } else { - c("SVG", "HTML") - }, - selected = if (file_type == "table") "CSV" else "SVG" - ) -} - - -#' Update and fetch metadata for a given indicator -#' -#' This function retrieves the metadata for a specified indicator and updates -#' the associated reactive storage. If the indicator is empty, the previously -#' stored value is returned. -#' -#' @param input_indicator A string representing the selected indicator. If -#' empty, the function returns the previously stored value. -#' @param metadata_type A string specifying the type of metadata to fetch (e.g., -#' "Description", "Methodology"). -#' @param reactive_storage A `reactiveValues` object where the metadata is -#' stored and updated. -#' @param key A string representing the key in `reactive_storage` corresponding -#' to the metadata type. -#' -#' @return The metadata associated with the specified indicator and metadata -#' type. If the indicator is empty, the previously stored value is returned. -#' -#' @examples -#' \dontrun{ -#' previous_metadata <- reactiveValues(description = NULL) -#' update_and_fetch_metadata( -#' input_indicator = "Indicator A", -#' metadata_type = "Description", -#' reactive_storage = previous_metadata, -#' key = "description" -#' ) -#' } -#' -update_and_fetch_metadata <- function(input_indicator, - metadata_type, - reactive_storage, - key) { - if (input_indicator == "") { - return(reactive_storage[[key]]) - } - - # Fetch the metadata for the selected indicator - metadata <- metrics_clean |> - get_metadata(input_indicator, metadata_type) - - # Update the previous value in the reactive storage - reactive_storage[[key]] <- metadata - - return(metadata) -} - - - -read_data_dict_shared_folder <- function(shared_folder, sheet_name) { - readxl::read_xlsx( - path = paste0(shared_folder, "/../Information for App Development/LAIT Data Dictionary (To QA!).xlsx"), - sheet = sheet_name, - # Replace multi-space with single-space - .name_repair = clean_spaces - ) -} +#' Shared Folder Path +#' +#' This variable holds the path to the 'LAIT - modernisation' teams channel folder. +#' The folder is synchronised and located in the user's local system. +#' +shared_folder <- paste0( + r"(C:\Users\jtufts\Department for Education\LAIT modernisation - General)", + r"(\LAIT Modernisation 2024\Information for App Development)" +) + + +#' Clean SNP Column Names +#' +#' This function cleans the column names of a given data frame. Specifically, it adds numbers to 'SNP' columns +#' based on the numbers extracted from 'SN' columns. +#' +#' @param data A data frame that contains columns with names starting with 'SN' and 'SNP'. +#' +#' @return A data frame with cleaned column names. +#' +#' @examples +#' \dontrun{ +#' data <- data.frame(SN1 = c(1, 2), SNP = c(3, 4), SN2 = c(5, 6), SNP = c(7, 8)) +#' clean_snp_colnames(data) +#' } +#' +#' @export +clean_snp_colnames <- function(data) { + col_names <- colnames(data) + + # Logical vectors to identify "SN" and "SNP" columns + sn_cols <- which(grepl("^SN\\d+$", col_names)) + snp_cols <- which(grepl("^SNP", col_names)) + + if (length(sn_cols) < 1) { + stop( + paste0( + "SN columns do not seem to be in the right format e.g., SNx", + "where x is a number" + ) + ) + } + + if (length(snp_cols) < 1) { + stop( + paste0( + "SNP columns do not seem to be in the right format e.g., SNPx", + "where x can be anything" + ) + ) + } + + # Extract the numbers from "SN" columns + sn_numbers <- gsub("^SN", "", col_names[sn_cols]) + + # Clean column names vector + clean_col_names <- col_names + + # Assign new names to "SNP" columns using extracted "SN" column numbers + clean_col_names[snp_cols] <- paste0("SNP", sn_numbers) + + # Assign the new column names to the dataframe + colnames(data) <- clean_col_names + + data +} + + +#' Create Measure Key +#' +#' This function creates a new column 'measure_key' in the given data frame. The 'measure_key' is created by +#' concatenating the 'Topic' and 'Measure_short' columns, replacing spaces with underscores, and converting +#' the resulting string to lowercase. +#' +#' @param data A data frame that contains the columns 'Topic' and 'Measure_short'. +#' +#' @return A data frame with an additional 'measure_key' column. +#' +#' @examples +#' \dontrun{ +#' data <- data.frame(Topic = c("Topic1", "Topic2"), Measure_short = c("Measure1", "Measure2")) +#' create_measure_key(data) +#' } +#' +#' @export +create_measure_key <- function(data) { + data |> + dplyr::mutate( + measure_key = tolower(gsub(" ", "_", paste(Topic, Measure_short))), + .after = Measure_short + ) +} + + +#' Generate Downloadable File Based on Data and File Type +#' +#' This function generates a temporary file for download based on the provided +#' data and file type. Supported file types include CSV, XLSX, PNG, and HTML. +#' The function writes the data to the corresponding file format, and returns +#' the file path for download. +#' +#' @param data A dataset or object to be saved, which can be a data frame or +#' a reactive list containing specific data for charts or widgets. +#' @param file_type A string specifying the desired file type for download. +#' Supported types are: "csv", "xlsx", "png", and "html". This string +#' is matched case-insensitively. +#' +#' @return A character string representing the path to the generated file. +#' The file will be saved temporarily with the appropriate extension +#' based on the provided `file_type`. +#' +#' @details +#' The function handles multiple file types as follows: +#' - For `"csv"`, it writes the data to a CSV file without row names. +#' - For `"xlsx"`, it saves the data as an Excel file with auto column widths. +#' - For `"png"`, it saves a ggplot object to a PNG file with specified size. +#' - For `"html"`, it saves an HTML widget to an HTML file. +#' If an unsupported file type is provided, the function returns an error. +#' +#' @examples +#' \dontrun{ +#' # Save a data frame as a CSV file +#' generate_download_file(mtcars, "csv") +#' +#' # Save a ggplot chart as PNG +#' plot_data <- list(png = ggplot(mtcars, aes(mpg, wt)) + +#' geom_point()) +#' generate_download_file(plot_data, "png") +#' } +#' +generate_download_file <- function(data, file_type, svg_width = 8.5) { + out <- tempfile(fileext = dplyr::case_when( + grepl("csv", file_type, ignore.case = TRUE) ~ ".csv", + grepl("xlsx", file_type, ignore.case = TRUE) ~ ".xlsx", + grepl("svg", file_type, ignore.case = TRUE) ~ ".svg", + grepl("html", file_type, ignore.case = TRUE) ~ ".html", + TRUE ~ "Error" + )) + + if (grepl("csv", file_type, ignore.case = TRUE)) { + write.csv(data, file = out, row.names = FALSE) + } else if (grepl("xlsx", file_type, ignore.case = TRUE)) { + openxlsx::write.xlsx(data, file = out, colWidths = "Auto") + } else if (grepl("svg", file_type, ignore.case = TRUE)) { + ggplot2::ggsave(filename = out, plot = data, width = svg_width, height = 6) + } else if (grepl("html", file_type, ignore.case = TRUE)) { + htmlwidgets::saveWidget(widget = data, file = out) + } + + out +} + + +#' Create Download Handler for Shiny Application +#' +#' This function creates a `downloadHandler` for use in a Shiny app, allowing +#' users to download a file in the specified format. The file name is generated +#' dynamically based on the provided extension input and table name prefix. +#' +#' @param export_file A string representing the file path of the file to be +#' downloaded. This should be a pre-generated file ready for download. +#' @param ext_input A reactive expression that returns the file type selected +#' by the user. Supported types are: "xlsx", "csv", "png", and "html". +#' The file extension is matched case-insensitively. +#' @param table_name_prefix A reactive expression returning a vector of strings +#' that represent parts of the file name. These are concatenated with +#' hyphens (`-`) to form the base of the download file name. +#' +#' @return A `downloadHandler` object for use in the server function of a +#' Shiny app. This handler will allow the user to download a file +#' with the desired format and name. +#' +#' @details +#' The filename is generated based on the table name prefix and current date, +#' with the appropriate file extension determined by the user's selection. +#' Supported extensions are: `.xlsx`, `.csv`, `.png`, and `.html`. The content +#' of the file is copied from `export_file`, and a notification is shown to +#' indicate that the file is being generated. +#' +#' @examples +#' \dontrun{ +#' # Create a download handler for a CSV file +#' create_download_handler("path/to/file.csv", reactive("csv"), reactive("data-table")) +#' } +#' +create_download_handler <- function(local) { + downloadHandler( + filename = function() { + file_ext <- dplyr::case_when( + grepl("xlsx", local$file_type, ignore.case = TRUE) ~ ".xlsx", + grepl("csv", local$file_type, ignore.case = TRUE) ~ ".csv", + grepl("svg", local$file_type, ignore.case = TRUE) ~ ".svg", + grepl("html", local$file_type, ignore.case = TRUE) ~ ".html", + TRUE ~ "Error" + ) + paste0(paste(local$file_name, collapse = "-"), "-", Sys.Date(), file_ext) + }, + content = function(file) { + pop_up <- shiny::showNotification("Generating download file", duration = NULL) + file.copy(local$export_file, file) + on.exit(shiny::removeNotification(pop_up), add = TRUE) + } + ) +} + + +# Helper function to calculate actual file size +calculate_file_size <- function(file_type, data) { + # Create a temporary file + temp_file <- tempfile(fileext = paste0(".", tolower(file_type))) + + # Create file or return estimated size + if (file_type == "CSV") { + write.csv(data, temp_file, row.names = FALSE) + } else if (file_type == "XLSX") { + openxlsx::write.xlsx(data, temp_file, colWidths = "auto") + } else if (file_type == "SVG") { + return("usually 20 KB and no larger than 200 KB") + } else if (file_type == "HTML") { + return("usually 275 KB and no larger than 500 KB") + } + + # Get the file size in KB + file_size_kb <- ceiling((file.size(temp_file) / 1024) / 5) * 5 + + # Round file size to nearest 10, while handling small sizes correctly + rounded_file_size <- round(file_size_kb, 2) + + unlink(temp_file) # Remove the temporary file + return(paste0(rounded_file_size, " KB")) +} + + +#' Generate a Radio Button Input for File Type Selection +#' +#' This function creates a radio button input for selecting the download file +#' format in a Shiny application. The label, hint, and choices for the radio +#' button are dynamically generated based on the type of file being downloaded +#' (either a data table or plot). +#' +#' @param input_id A string representing the input ID for the radio button, +#' which will be used to access the selected file type in the Shiny +#' server logic. +#' @param file_type A string that specifies the type of file being downloaded. +#' It can either be "table" (for downloading data tables) or any other +#' string (for downloading plots). Defaults to "table". +#' +#' @return A `shinyGovstyle::radio_button_Input` object to be included in the +#' Shiny UI, allowing the user to choose between available file formats +#' for the download. +#' +#' @details +#' When the `file_type` is "table", the user will have the option to select +#' between "CSV" and "XLSX" file formats. For other file types, the user can +#' select between "PNG" and "HTML". The default selected option is "CSV" for +#' tables and "PNG" for plots. The hint label displayed below the input will +#' provide guidance based on the type of download. +#' +#' @examples +#' \dontrun{ +#' # Generate file type selection for a table +#' file_type_input_btn("file_type", file_type = "table") +#' +#' # Generate file type selection for a plot +#' file_type_input_btn("file_type", file_type = "plot") +#' } +#' +file_type_input_btn <- function(input_id, data = NULL, file_type = "table") { + # Generate choices with actual file size + choices_with_size <- if (file_type == "table") { + c( + paste0("CSV (less than ", calculate_file_size("CSV", data), ")"), + paste0("XLSX (less than ", calculate_file_size("XLSX", data), ")") + ) + } else { + c( + paste0("SVG (", calculate_file_size("SVG", data), ")"), + paste0("HTML (", calculate_file_size("HTML", data), ")") + ) + } + + shinyGovstyle::radio_button_Input( + inputId = input_id, + label = h2("Choose download file format"), + hint_label = if (file_type == "table") { + paste0( + "This will download all data related to the providers and options selected.", + " The XLSX format is designed for use in Microsoft Excel." + ) + } else { + paste0( + "This will download the plots related to the options selected.", + " The HTML format contains the interactive element." + ) + }, + choices = choices_with_size, + selected = choices_with_size[1] + ) +} + + +#' Update and fetch metadata for a given indicator +#' +#' This function retrieves the metadata for a specified indicator and updates +#' the associated reactive storage. If the indicator is empty, the previously +#' stored value is returned. +#' +#' @param input_indicator A string representing the selected indicator. If +#' empty, the function returns the previously stored value. +#' @param metadata_type A string specifying the type of metadata to fetch (e.g., +#' "Description", "Methodology"). +#' @param reactive_storage A `reactiveValues` object where the metadata is +#' stored and updated. +#' @param key A string representing the key in `reactive_storage` corresponding +#' to the metadata type. +#' +#' @return The metadata associated with the specified indicator and metadata +#' type. If the indicator is empty, the previously stored value is returned. +#' +#' @examples +#' \dontrun{ +#' previous_metadata <- reactiveValues(description = NULL) +#' update_and_fetch_metadata( +#' input_indicator = "Indicator A", +#' metadata_type = "Description", +#' reactive_storage = previous_metadata, +#' key = "description" +#' ) +#' } +#' +update_and_fetch_metadata <- function(input_indicator, + metadata_type, + reactive_storage, + key) { + if (input_indicator == "") { + return(reactive_storage[[key]]) + } + + # Fetch the metadata for the selected indicator + metadata <- metrics_clean |> + get_metadata(input_indicator, metadata_type) + + # Update the previous value in the reactive storage + reactive_storage[[key]] <- metadata + + return(metadata) +} + + + +read_data_dict_shared_folder <- function(shared_folder, sheet_name) { + readxl::read_xlsx( + path = paste0(shared_folder, "/../Information for App Development/LAIT Data Dictionary (To QA!).xlsx"), + sheet = sheet_name, + # Replace multi-space with single-space + .name_repair = clean_spaces + ) +} diff --git a/R/fn_table_helpers.R b/R/fn_table_helpers.R index 9c0aa3a8..c0056e8d 100644 --- a/R/fn_table_helpers.R +++ b/R/fn_table_helpers.R @@ -645,8 +645,8 @@ build_sn_stats_table <- function( #' } #' ) #' } -highlight_selected_row <- function(index, data, selected_area = NULL) { - la_region <- data[index, "LA and Regions"] +highlight_selected_row <- function(index, data, selected_area = NULL, geog_col = "LA and Regions") { + la_region <- data[index, geog_col] # Handle missing values first if (is.na(la_region)) { diff --git a/R/fn_ui_layout.R b/R/fn_ui_layout.R index aab35679..edcddcd3 100644 --- a/R/fn_ui_layout.R +++ b/R/fn_ui_layout.R @@ -37,7 +37,7 @@ create_download_options_ui <- function(download_id, copy_clipboard_id) { actionButton( copy_clipboard_id, "Copy Chart to Clipboard", - icon = icon("copy"), + icon = icon("copy", `aria-hidden` = "true"), class = "gov-uk-button" ), style = "max-width: none;" @@ -66,7 +66,8 @@ create_hidden_clipboard_plot <- function(clipboard_plot_id) { # Hidden static plot for copy-to-clipboard div( shiny::plotOutput(clipboard_plot_id), - style = "content-visibility: hidden;" + style = "content-visibility: hidden;", + `aria-hidden` = "true" ) } @@ -157,8 +158,8 @@ full_data_on_github_noti <- function() { input_id = "full_data_on_github", title_txt = "Information", body_txt = shiny::HTML(paste0( - "The full dataset is available to download as a .csv on GitHub. ", - "The file is ", + "The full dataset is available for download as a CSV on GitHub. ", + "You can access the file ", dfeshiny::external_link( href = paste0( "https://github.com/dfe-analytical-services/", @@ -168,9 +169,201 @@ full_data_on_github_noti <- function() { ), ".
", "", - "This should be the preferred method for large data downloads ", - "especially for use with code." + "We recommend this method for downloading large datasets, ", + "especially if you plan to use the data in your code." )), type = "standard" ) } + + +dfe_footer <- function(links_list) { + # Add the HTML around the link and make an id by snake casing + create_footer_link <- function(link_text) { + shiny::tags$li( + class = "govuk-footer__inline-list-item", + actionLink( + class = "govuk-link govuk-footer__link", + inputId = tolower(gsub(" ", "_", link_text)), + label = link_text + ) + ) + } + + # The HTML div to be returned + shiny::tags$footer( + class = "govuk-footer ", + role = "contentinfo", + shiny::div(class = "govuk-width-container ", shiny::div( + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # Add custom links in + shiny::div( + class = "govuk-footer__meta-item govuk-footer__meta-item--grow", + + # Set a visually hidden title for accessibility + shiny::h2(class = "govuk-visually-hidden", "Support links"), + # Generate as many links as needed + shiny::tags$ul( + class = "govuk-footer__inline-list", + lapply(links_list, create_footer_link) + ) + ), + + # ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + # Back to copied code from shinyGovstyle + shiny::div(class = "govuk-footer__meta", shiny::tagList( + shiny::div( + class = "govuk-footer__meta-item govuk-footer__meta-item--grow", + shiny::tag( + "svg", + list( + role = "presentation", + focusable = "false", + class = "govuk-footer__licence-logo", + xmlns = "http://www.w3.org/2000/svg", + viewbox = "0 0 483.2 195.7", + height = "17", + width = "41", + shiny::tag("path", list( + fill = "currentColor", + d = paste0( + "M421.5 142.8V.1l-50.7 32.3v161.1h112.4v-50.7", + "zm-122.3-9.6A47.12 47.12 0 0 1 221 97.8c0-26 21", + ".1-47.1 47.1-47.1 16.7 0 31.4 8.7 39.7 21.8l42.7", + "-27.2A97.63 97.63 0 0 0 268.1 0c-36.5 0-68.3 20.1", + "-85.1 49.7A98 98 0 0 0 97.8 0C43.9 0 0 43.9 0 97", + ".8s43.9 97.8 97.8 97.8c36.5 0 68.3-20.1 85.1-49.", + "7a97.76 97.76 0 0 0 149.6 25.4l19.4 22.2h3v-87.8", + "h-80l24.3 27.5zM97.8 145c-26 0-47.1-21.1-47.1-47", + ".1s21.1-47.1 47.1-47.1 47.2 21 47.2 47S123.8 145", + " 97.8 145" + ) + )) + ) + ), + shiny::tags$span( + class = "govuk-footer__licence-description", + "All content is available under the", + shiny::tags$a( + class = "govuk-footer__link", + href = "https://www.nationalarchives.gov.uk/doc/open-government-licence/version/3/", + rel = "license", + "Open Government Licence v3.0", + .noWS = "after" + ), + ", except where otherwise stated" + ) + ), + shiny::tags$div( + class = "govuk-footer__meta-item", + shiny::tags$a( + class = "govuk-footer__link govuk-footer__copyright-logo", + href = + paste0( + "https://www.nationalarchives.gov.uk/information-management/", + "re-using-public-sector-information/uk-government-licensing-framework/crown-copyright/" + ), + "\u00A9 Crown copyright" + ) + ) + )) + )) + ) +} + + +# left nav ==================================================================== +dfe_contents_links <- function(links_list) { + # Add the HTML around the link and make an id by snake casing + create_sidelink <- function(link_text) { + tags$li( + "—", + actionLink(tolower(gsub( + " ", "_", link_text + )), link_text, class = "contents_link") + ) + } + + # The HTML div to be returned + tags$div( + style = "position: sticky; top: 0.5rem; padding: 0.25rem; word-break: break-word;", + # Make it stick! + h2("Contents"), + # remove the circle bullets + tags$ol( + style = "list-style-type: none; padding-left: 0; font-size: 1rem;", + lapply(links_list, create_sidelink) + ) + ) +} + + +cookies_banner_server_jt <- function(id = "cookies_banner", + input_cookies, + parent_session, + google_analytics_key = NULL, + cookies_link_panel = "cookies_panel_ui") { + shiny::moduleServer(id, function(input, output, session) { + if (is.null(google_analytics_key)) { + warning("Please provide a valid Google Analytics key") + } + shiny::observeEvent(input_cookies(), { + if (!is.null(input_cookies())) { + if (!("dfe_analytics" %in% names(input_cookies()))) { + shinyjs::show(id = "cookies_main") + } else { + shinyjs::hide(id = "cookies_main") + msg <- list( + name = "dfe_analytics", + value = input_cookies()$dfe_analytics + ) + session$sendCustomMessage("analytics-consent", msg) + if ("cookies" %in% names(input)) { + if ("dfe_analytics" %in% names(input_cookies())) { + if (input_cookies()$dfe_analytics == "denied") { + ga_msg <- list(name = paste0("_ga_", google_analytics_key)) + session$sendCustomMessage("cookie-clear", ga_msg) + } + } + } + } + } else { + shinyjs::hide(id = "cookies_main", asis = TRUE) + shinyjs::toggle(id = "cookies_div", asis = TRUE) + } + }) + shiny::observeEvent(input$cookies_accept, { + msg <- list(name = "dfe_analytics", value = "granted") + session$sendCustomMessage("cookie-set", msg) + session$sendCustomMessage("analytics-consent", msg) + shinyjs::hide(id = "cookies_main", asis = TRUE) + }) + shiny::observeEvent(input$cookies_reject, { + msg <- list(name = "dfe_analytics", value = "denied") + session$sendCustomMessage("cookie-set", msg) + session$sendCustomMessage("analytics-consent", msg) + shinyjs::hide(id = "cookies_main", asis = TRUE) + }) + shiny::observeEvent(input$cookies_link, { + shiny::updateTabsetPanel( + session = parent_session, "pages", + selected = cookies_link_panel + ) + }) + return(shiny::renderText({ + cookies_text_stem <- "You have chosen to" + cookies_text_tail <- "the use of cookies on this website." + if (!is.null(input_cookies())) { + if ("dfe_analytics" %in% names(input_cookies())) { + if (input_cookies()$dfe_analytics == "granted") { + paste(cookies_text_stem, "accept", cookies_text_tail) + } else { + paste(cookies_text_stem, "reject", cookies_text_tail) + } + } + } else { + "Cookies consent has not been confirmed." + } + })) + }) +} diff --git a/R/lait_modules/mod_all_la_table.R b/R/lait_modules/mod_all_la_table.R index 5925ddc2..e4406e2f 100644 --- a/R/lait_modules/mod_all_la_table.R +++ b/R/lait_modules/mod_all_la_table.R @@ -152,34 +152,32 @@ AllLA_TableUI <- function(id) { div( class = "well", style = "overflow-y: visible;", - bslib::navset_tab( + bslib::navset_card_tab( id = "all_la_table_tabs", bslib::nav_panel( "Tables", - bslib::card( - bslib::card_header( - Get_AllLATableNameUI(ns("table_name")), - style = "text-align: center;" - ), - bslib::card_header("Local Authorities"), + bslib::card_header( + Get_AllLATableNameUI(ns("table_name")), + style = "text-align: center;" + ), + bslib::card_header("Local Authorities"), + with_gov_spinner( + reactable::reactableOutput(ns("la_table")), + size = 3 + ), + div( + # Add black border between the tables + style = "border-top: 2px solid black; padding-top: 2.5rem;", + bslib::card_header("Regions"), with_gov_spinner( - reactable::reactableOutput(ns("la_table")), - size = 3 - ), - div( - # Add black border between the tables - style = "border-top: 2px solid black; padding-top: 2.5rem;", - bslib::card_header("Regions"), - with_gov_spinner( - reactable::reactableOutput(ns("region_table")), - size = 1.6 - ) + reactable::reactableOutput(ns("region_table")), + size = 1.6 ) ) ), bslib::nav_panel( "Download data", - file_type_input_btn(ns("file_type")), + shiny::uiOutput(ns("download_file_txt")), Download_DataUI(ns("all_download"), "All Geographies Table"), Download_DataUI(ns("la_download"), "LA Table"), Download_DataUI(ns("region_download"), "Region Table") @@ -235,6 +233,13 @@ AllLA_TableServer <- function(id, app_inputs, bds_metrics, la_names_bds) { ) # All geographies table download ------------------------------------------ + # File download text - calculates file size + ns <- NS(id) + output$download_file_txt <- shiny::renderUI({ + file_type_input_btn(ns("file_type"), all_la_table()) + }) + + # Download dataset Download_DataServer( "all_download", reactive(input$file_type), @@ -262,7 +267,7 @@ AllLA_TableServer <- function(id, app_inputs, bds_metrics, la_names_bds) { set_custom_default_col_widths() ), rowStyle = function(index) { - highlight_selected_row(index, all_la_la_table, app_inputs$la()) + highlight_selected_row(index, all_la_la_table, app_inputs$la(), "LA") }, pagination = FALSE ) @@ -287,10 +292,10 @@ AllLA_TableServer <- function(id, app_inputs, bds_metrics, la_names_bds) { # Sums number of non-NA cols (left of LA and Regions) and checks if = 0 rowSums(!is.na(dplyr::select(all_la_table(), -c(`LA Number`, `LA and Regions`)))) == 0) ) |> - # Replace Rank with a blank col + # Replace Rank dplyr::mutate(Rank = "") |> - dplyr::rename(` ` = "Rank") |> - dplyr::arrange(`LA Number`) + dplyr::arrange(`LA Number`) |> + dplyr::rename("Region" = `LA and Regions`) # Get region of LA all_la_region <- stat_n_la |> @@ -309,10 +314,19 @@ AllLA_TableServer <- function(id, app_inputs, bds_metrics, la_names_bds) { num_exclude = "LA Number", categorical = "Rank" ), - set_custom_default_col_widths() + list( + set_custom_default_col_widths(), + Rank = reactable::colDef( + header = add_tooltip_to_reactcol( + "Rank", + "Regions are not currently ranked", + placement = "top" + ) + ) + ) ), rowStyle = function(index) { - highlight_selected_row(index, all_la_region_table, all_la_region) + highlight_selected_row(index, all_la_region_table, all_la_region, "Region") }, pagination = FALSE # class = "hidden-column-headers" diff --git a/R/lait_modules/mod_app_helpers.R b/R/lait_modules/mod_app_helpers.R index 43f898d4..e412f62d 100644 --- a/R/lait_modules/mod_app_helpers.R +++ b/R/lait_modules/mod_app_helpers.R @@ -19,7 +19,7 @@ PageHeaderUI <- function(id) { shinycssloaders::withSpinner( shiny::uiOutput(ns("page_header")), type = 7, - color = "#1d70b8", + color = "#0b0c0c", size = 1, proxy.height = paste0(250 * 0.25, "px") ) @@ -238,7 +238,8 @@ DownloadChartModalUI <- function(id, chart_type) { footer = shiny::tagAppendAttributes( shiny::modalButton("Close"), class = "govuk-button--secondary" - ) + ), + size = "l" ) } diff --git a/R/lait_modules/mod_app_inputs.R b/R/lait_modules/mod_app_inputs.R index 1fd03c96..edc9a718 100644 --- a/R/lait_modules/mod_app_inputs.R +++ b/R/lait_modules/mod_app_inputs.R @@ -1,182 +1,182 @@ -# nolint start: object_name -# -#' Shiny Module UI for Displaying the App Inputs -#' -#' This function creates a Shiny UI module for displaying the app inputs. -#' The inputs include a select input for the local authority (LA) name, -#' the topic name, and the indicator name. -#' Each input is wrapped in a div with a well and a layout column for styling. -#' -#' @param id A unique ID that identifies the UI element -#' @return A div object that contains the UI elements for the module -#' -appInputsUI <- function(id) { - ns <- NS(id) - - div( - class = "well", - style = "overflow-y: visible; position: relative;", - bslib::layout_column_wrap( - width = "15rem", # Minimum width for each input box before wrapping - shiny::selectizeInput( - inputId = ns("la_name"), - label = tags$label( - "Local Authority:", - create_tooltip_icon("Change selection by scrolling or typing") - ), - choices = la_names_bds, - options = list( - placeholder = "Start typing or scroll to find a Local Authority...", - plugins = list("clear_button") - ) - ), - shiny::selectizeInput( - inputId = ns("topic_name"), - label = tags$label( - id = ns("topic_label"), - "Topic:" - ), - choices = c("All Topics", metric_topics), - selected = "All Topics", - options = list( - placeholder = "No topic selected, showing all indicators...", - plugins = list("clear_button") - ) - ), - shiny::selectizeInput( - inputId = ns("indicator_name"), - label = "Indicator:", - choices = metric_names, - options = list( - placeholder = "Start typing or scroll to find an indicator...", - plugins = list("clear_button") - ) - ) - ) - ) -} - - -#' Shiny Server Function for Handling the App Inputs with Synchronisation -#' -#' This function creates a Shiny server module for handling the app inputs -#' and synchronising them across multiple pages. -#' It observes the selected topic name and updates the choices for the -#' indicator name based on the selected topic, and also updates the shared -#' reactive values to keep the inputs in sync between pages. -#' -#' @param id A unique ID that identifies the server function. -#' @param shared_values A `reactiveValues` object to store shared input values -#' that can be accessed and modified across different modules. -#' @return A list of reactive expressions for the app inputs, including -#' the selected LA name, topic name, and indicator name. -#' -appInputsServer <- function(id, - shared_values, - topic_indicator_full) { - moduleServer(id, function(input, output, session) { - # Reactive value to store the previous LA name - previous_la_name <- reactiveVal(NULL) - - # Debounce input values to prevent looping when inputs change quickly - debounced_la_name <- shiny::debounce(reactive(input$la_name), 150) - debounced_topic_name <- shiny::debounce(reactive(input$topic_name), 150) - debounced_indicator_name <- shiny::debounce(reactive(input$indicator_name), 150) - - # Update Indicator dropdown for selected Topic - shiny::observeEvent(debounced_topic_name(), - { - # Save the currently selected indicator - current_indicator <- debounced_indicator_name() - - # Determine the filter condition for Topic - topic_filter <- debounced_topic_name() - - # Get indicator choices for selected topic - # Include all rows if no topic is selected or "All Topics" is selected - filtered_topic_bds <- topic_indicator_full |> - filter_by_topic("Topic", topic_filter) |> - pull_uniques("Measure") - - # Ensure the current indicator stays selected - # Default to the first topic indicator if the current is not valid - selected_indicator <- if (current_indicator %in% filtered_topic_bds) { - current_indicator - } else { - filtered_topic_bds[1] - } - - # Update the Indicator dropdown based on selected Topic - shiny::updateSelectizeInput( - session = session, - inputId = "indicator_name", - choices = filtered_topic_bds, - selected = selected_indicator - ) - - # Update the shared reactive value for the topic - shared_values$topic <- debounced_topic_name() - }, - ignoreNULL = FALSE - ) - - # Prevent LA input from being empty by storing its previous value - shiny::observeEvent(debounced_la_name(), { - # Check if the LA name is NULL or empty - la_name <- debounced_la_name() - - if ("" %notin% la_name && !is.null(la_name)) { - # Update the reactive value with the current valid input - previous_la_name(la_name) - - # Synchronise the shared reactive value - shared_values$la <- la_name - } - }) - - # Set dynamic topic label - # (to display topic when not selected or all topics selected) - update_topic_label( - indicator_input = debounced_indicator_name, - topic_input = debounced_topic_name, - topic_indicator_data = topic_indicator_full, - topic_label_id = "topic_label" - ) - - # Observe and synchronise Indicator input changes - observeEvent(debounced_indicator_name(), { - shared_values$indicator <- debounced_indicator_name() - }) - - # Synchronise inputs across pages: - # LA - observe({ - shiny::updateSelectizeInput(session, "la_name", selected = shared_values$la) - }) - # Topic - observe({ - shiny::updateSelectizeInput(session, "topic_name", selected = shared_values$topic) - }) - # Indicator - observe({ - shiny::updateSelectizeInput(session, "indicator_name", selected = shared_values$indicator) - }) - - # Return reactive settings - app_settings <- list( - la = reactive({ - previous_la_name() - }), - topic = reactive({ - debounced_topic_name() - }), - indicator = reactive({ - debounced_indicator_name() - }) - ) - - return(app_settings) - }) -} - -# nolint end +# nolint start: object_name +# +#' Shiny Module UI for Displaying the App Inputs +#' +#' This function creates a Shiny UI module for displaying the app inputs. +#' The inputs include a select input for the local authority (LA) name, +#' the topic name, and the indicator name. +#' Each input is wrapped in a div with a well and a layout column for styling. +#' +#' @param id A unique ID that identifies the UI element +#' @return A div object that contains the UI elements for the module +#' +appInputsUI <- function(id) { + ns <- NS(id) + + div( + class = "well", + style = "overflow-y: visible; position: relative;", + bslib::layout_column_wrap( + width = "15rem", # Minimum width for each input box before wrapping + shiny::selectizeInput( + inputId = ns("la_name"), + label = tags$label( + "Local Authority:", + create_tooltip_icon("Change selection by scrolling or typing") + ), + choices = la_names_bds, + options = list( + placeholder = "Start typing or scroll to find a Local Authority...", + plugins = list("clear_button") + ) + ), + shiny::selectizeInput( + inputId = ns("topic_name"), + label = tags$label( + id = ns("topic_label"), + "Topic:" + ), + choices = c("All Topics", metric_topics), + selected = "All Topics", + options = list( + placeholder = "No topic selected, showing all indicators...", + plugins = list("clear_button") + ) + ), + shiny::selectizeInput( + inputId = ns("indicator_name"), + label = "Indicator:", + choices = metric_names, + options = list( + placeholder = "Start typing or scroll to find an indicator...", + plugins = list("clear_button") + ), + ) + ) + ) +} + + +#' Shiny Server Function for Handling the App Inputs with Synchronisation +#' +#' This function creates a Shiny server module for handling the app inputs +#' and synchronising them across multiple pages. +#' It observes the selected topic name and updates the choices for the +#' indicator name based on the selected topic, and also updates the shared +#' reactive values to keep the inputs in sync between pages. +#' +#' @param id A unique ID that identifies the server function. +#' @param shared_values A `reactiveValues` object to store shared input values +#' that can be accessed and modified across different modules. +#' @return A list of reactive expressions for the app inputs, including +#' the selected LA name, topic name, and indicator name. +#' +appInputsServer <- function(id, + shared_values, + topic_indicator_full) { + moduleServer(id, function(input, output, session) { + # Reactive value to store the previous LA name + previous_la_name <- reactiveVal(NULL) + + # Debounce input values to prevent looping when inputs change quickly + debounced_la_name <- shiny::debounce(reactive(input$la_name), 150) + debounced_topic_name <- shiny::debounce(reactive(input$topic_name), 75) + debounced_indicator_name <- shiny::debounce(reactive(input$indicator_name), 150) + + # Update Indicator dropdown for selected Topic + shiny::observeEvent(debounced_topic_name(), + { + # Save the currently selected indicator + current_indicator <- debounced_indicator_name() + + # Determine the filter condition for Topic + topic_filter <- debounced_topic_name() + + # Get indicator choices for selected topic + # Include all rows if no topic is selected or "All Topics" is selected + filtered_topic_bds <- topic_indicator_full |> + filter_by_topic("Topic", topic_filter) |> + pull_uniques("Measure") + + # Ensure the current indicator stays selected + # Default to the first topic indicator if the current is not valid + selected_indicator <- if (current_indicator %in% filtered_topic_bds) { + current_indicator + } else { + filtered_topic_bds[1] + } + + # Update the Indicator dropdown based on selected Topic + shiny::updateSelectizeInput( + session = session, + inputId = "indicator_name", + choices = filtered_topic_bds, + selected = selected_indicator + ) + + # Update the shared reactive value for the topic + shared_values$topic <- debounced_topic_name() + }, + ignoreNULL = FALSE + ) + + # Prevent LA input from being empty by storing its previous value + shiny::observeEvent(debounced_la_name(), { + # Check if the LA name is NULL or empty + la_name <- debounced_la_name() + + if ("" %notin% la_name && !is.null(la_name)) { + # Update the reactive value with the current valid input + previous_la_name(la_name) + + # Synchronise the shared reactive value + shared_values$la <- la_name + } + }) + + # Set dynamic topic label + # (to display topic when not selected or all topics selected) + update_topic_label( + indicator_input = debounced_indicator_name, + topic_input = debounced_topic_name, + topic_indicator_data = topic_indicator_full, + topic_label_id = "topic_label" + ) + + # Observe and synchronise Indicator input changes + observeEvent(debounced_indicator_name(), { + shared_values$indicator <- debounced_indicator_name() + }) + + # Synchronise inputs across pages: + # LA + observe({ + shiny::updateSelectizeInput(session, "la_name", selected = shared_values$la) + }) + # Topic + observe({ + shiny::updateSelectizeInput(session, "topic_name", selected = shared_values$topic) + }) + # Indicator + observe({ + shiny::updateSelectizeInput(session, "indicator_name", selected = shared_values$indicator) + }) + + # Return reactive settings + app_settings <- list( + la = reactive({ + previous_la_name() + }), + topic = reactive({ + debounced_topic_name() + }), + indicator = reactive({ + debounced_indicator_name() + }) + ) + + return(app_settings) + }) +} + +# nolint end diff --git a/R/lait_modules/mod_create_own_charts.R b/R/lait_modules/mod_create_own_charts.R index c4649a02..a92aee73 100644 --- a/R/lait_modules/mod_create_own_charts.R +++ b/R/lait_modules/mod_create_own_charts.R @@ -270,21 +270,24 @@ CreateOwnLineChartServer <- function(id, query, bds_metrics, covid_affected_data if ("Message from tool" %in% colnames(create_own_data())) { ggiraph::girafe( ggobj = display_no_data_plot("No plot as not enough selections made."), - width_svg = 8.5 + width_svg = 8.5, + options = generic_ggiraph_options() ) } else if ( chart_info$no_geogs() > 4 ) { ggiraph::girafe( ggobj = display_no_data_plot(label = "No plot as too many Geographies selected."), - width_svg = 8.5 + width_svg = 8.5, + options = generic_ggiraph_options() ) } else if ( chart_info$no_indicators() > 3 ) { ggiraph::girafe( ggobj = display_no_data_plot(label = "No plot as too many Indicators selected."), - width_svg = 8.5 + width_svg = 8.5, + options = generic_ggiraph_options() ) # Plot line chart @@ -527,21 +530,24 @@ CreateOwnBarChartServer <- function(id, query, bds_metrics, covid_affected_data) if ("Message from tool" %in% colnames(create_own_data())) { ggiraph::girafe( ggobj = display_no_data_plot("No plot as not enough selections made."), - width_svg = 8.5 + width_svg = 8.5, + options = generic_ggiraph_options() ) } else if ( chart_info$no_geogs() > 4 ) { ggiraph::girafe( ggobj = display_no_data_plot(label = "No plot as too many Geographies selected."), - width_svg = 8.5 + width_svg = 8.5, + options = generic_ggiraph_options() ) } else if ( chart_info$no_indicators() > 3 ) { ggiraph::girafe( ggobj = display_no_data_plot(label = "No plot as too many Indicators selected."), - width_svg = 8.5 + width_svg = 8.5, + options = generic_ggiraph_options() ) # Plot chart diff --git a/R/lait_modules/mod_create_own_inputs.R b/R/lait_modules/mod_create_own_inputs.R index 18cefca9..49d04605 100644 --- a/R/lait_modules/mod_create_own_inputs.R +++ b/R/lait_modules/mod_create_own_inputs.R @@ -96,7 +96,12 @@ Create_MainInputsUI <- function(id) { ), # Add selection (query) button "Add selection" = div( - style = "height: 100%; display: flex; justify-content: center; align-items: flex-end;", + style = " + height: 100%; + display: flex; + justify-content: center; + align-items: flex-end; + ", shinyGovstyle::button_Input( inputId = ns("add_query"), label = "Add selections", @@ -324,9 +329,9 @@ YearRangeServer <- function(id, bds_metrics, indicator_input, clear_selections) shinyWidgets::updatePickerInput( session = session, inputId = "year_range", - choices = "Please select an indicator first", + choices = "Select an indicator to see year range", options = shinyWidgets::pickerOptions( - noneSelectedText = "Select an indicator to see year range", + noneSelectedText = "Select an indicator...", maxOptions = 2, maxOptionsText = "Select and indicator", size = "auto" diff --git a/R/lait_modules/mod_create_own_table.R b/R/lait_modules/mod_create_own_table.R index bc184d69..4cc91a53 100644 --- a/R/lait_modules/mod_create_own_table.R +++ b/R/lait_modules/mod_create_own_table.R @@ -1,854 +1,861 @@ -# nolint start: object_name -# -# Staging table ================================================================ -# Staging BDS ------------------------------------------------------------------ -# Filter the BDS for current user input selections -# (used to create the staging table) -# -#' Staging BDS Server -#' -#' This function filters the BDS (Business Data Service) metrics based on -#' user input selections to create a staging table. It filters for selected -#' topic-indicator pairs, geographic groupings, and a specified year range. -#' -#' @param id A unique identifier for the Shiny module. -#' @param create_inputs A list of reactive inputs generated by the main -#' input module, including selected topic-indicator pairs. -#' @param geog_groups A reactive expression providing the selected geographic -#' groups based on user input. -#' @param year_input A list containing reactive expressions for selected -#' year range and available years choices. -#' @param bds_metrics A data frame containing the business data service metrics -#' used for filtering based on user selections. -#' @return A reactive data frame that contains the filtered BDS metrics -#' suitable for display in the staging table. -#' -StagingBDSServer <- function(id, - create_inputs, - geog_groups, - year_input, - bds_metrics) { - moduleServer(id, function(input, output, session) { - # Forcing module to react to change in year input (not best practice) - observeEvent(year_input$range(), { - year_input$range() - }) - - # Filter BDS for topic-indicator pairs in the selected_values reactive - topic_indicator_bds <- reactive({ - req(length(create_inputs$indicator()) > 0) - bds_metrics |> - dplyr::filter(Measure %in% create_inputs$indicator()) - }) - - # Now filter BDS for geographies and year range - # Split from above so if indicator doesn't change then don't recompute - staging_bds <- reactive({ - req(geog_groups(), topic_indicator_bds()) - # Filter by full geography inputs - filtered_bds <- topic_indicator_bds() |> - dplyr::filter( - `LA and Regions` %in% geog_groups() - ) - - # Cleaning Years - # Check if all years have consistent suffix - consistent_str_years <- check_year_suffix_consistency(filtered_bds) - - # If not consistent suffix use the cleaned year cols (numeric years) - if (!consistent_str_years) { - filtered_bds <- filtered_bds |> - dplyr::mutate( - Years = Years_num - ) - } - - # Apply the year range filter - # If only one year selected then show just that year - if (length(year_input$range()) == 1) { - filtered_bds <- filtered_bds |> - dplyr::filter( - Years == year_input$range()[1] - ) - } else if (length(year_input$range()) == 2) { - filtered_bds <- filtered_bds |> - dplyr::filter( - Years >= year_input$range()[1], - Years <= year_input$range()[2] - ) - } - - # Return the user selection filtered data for staging table - filtered_bds - }) - - - # Return staging BDS - staging_bds - }) -} - - -# Staging data ----------------------------------------------------------------- -# -#' Staging Data Server -#' -#' This function builds a staging table for displaying filtered BDS metrics -#' in a Shiny application. It incorporates statistical neighbour associations -#' if selected and formats the data into a wide format for easier analysis. -#' -#' @param id A unique identifier for the Shiny module. -#' @param create_inputs A list of reactive inputs generated by the main -#' input module, including selected topic-indicator pairs. -#' @param staging_bds A reactive expression providing the filtered BDS metrics -#' based on user selections. -#' @param region_names_bds A vector of names representing regions in the BDS. -#' @param la_names_bds A vector of names representing local authorities in the BDS. -#' @param stat_n_la A data frame containing statistical neighbour data for LAs. -#' @return A reactive data frame that contains the formatted staging table -#' ready for display in the Shiny app. -#' -StagingDataServer <- function( - id, create_inputs, staging_bds, region_names_bds, la_names_bds, stat_n_la) { - moduleServer(id, function(input, output, session) { - # Make statistical neighbour association table available - stat_n_association <- StatN_AssociationServer( - "stat_n_association", - create_inputs, - la_names_bds, - stat_n_la - ) - - # Build the staging table - staging_table <- reactive({ - # Selected relevant cols - # Coerce to wide format - # (any new values created set to NaN so can be picked up as user created NAs) - # Set regions and England as themselves for Region - wide_table <- staging_bds() |> - dplyr::select( - `LA Number`, `LA and Regions`, Region, Topic, - Measure, Years, Years_num, values_num, Values - ) |> - tidyr::pivot_wider( - id_cols = c("LA Number", "LA and Regions", "Region", "Topic", "Measure"), - names_from = Years, - values_from = values_num, - values_fill = NaN - ) |> - dplyr::mutate(Region = dplyr::case_when( - `LA and Regions` %in% c("England", region_names_bds) ~ `LA and Regions`, - TRUE ~ Region - )) - - # Order columns (and sort year cols order) - wide_table_ordered <- wide_table |> - dplyr::select( - `LA Number`, `LA and Regions`, Region, - Topic, Measure, - dplyr::all_of(sort_year_columns(wide_table)) - ) - - # If SNs included, add SN LA association column - # Multi-join as want to include an association for every row (even duplicates) - if (isTRUE(create_inputs$la_group() == "la_stat_ns")) { - wide_table_ordered <- wide_table_ordered |> - dplyr::left_join( - stat_n_association(), - by = "LA and Regions", - relationship = "many-to-many" - ) |> - dplyr::relocate(sn_parent, .after = "Measure") |> - dplyr::rename("Statistical Neighbour Group" = "sn_parent") - } - - # Staging table formatted and ready for output - wide_table_ordered - }) - - # Return staging table - staging_table - }) -} - - -# Staging table UI ------------------------------------------------------------- -# Simple reactable table inside a well div -# -#' Staging Table UI -#' -#' This function creates the user interface for the staging table, which -#' displays the current selections in a well-styled format. The UI includes -#' a header and a reactable output for rendering the staging data. -#' -#' @param id A unique identifier for the Shiny module. -#' @return A div containing the UI elements for the staging table, including -#' a header and a reactable output. -#' -StagingTableUI <- function(id) { - ns <- NS(id) - - div( - class = "well", - style = "overflow-y: visible;", - bslib::layout_column_wrap( - h3( - "Staging Table", - create_tooltip_icon("Showing data from current selections") - ), - # Include empty divs so matches inputs above and add selections aligns - div(), - div(), - # Add selections button - Create_MainInputsUI("create_inputs")["Add selection"] - ), - bslib::card( - with_gov_spinner( - reactable::reactableOutput(ns("staging_table")), - size = 0.5 - ) - ) - ) -} - - -# Staging table Server --------------------------------------------------------- -# Output a formatted reactable table of the staging data -# Few error message table outputs for incorrect/ missing selections -# -#' Staging Table Server -#' -#' This function generates the server-side logic for the staging table, which -#' renders a reactable table of the current selections. It handles error -#' messages for incorrect or missing selections and formats the staging data -#' for better readability. It filters the BDS data based on user inputs and -#' prepares it for display. -#' -#' @param id A unique identifier for the Shiny module. -#' @param create_inputs A list of reactive inputs generated by the main input -#' module, including selected indicators and geography. -#' @param region_names_bds A vector of names representing regions in the BDS. -#' @param la_names_bds A vector of names representing local authorities in the BDS. -#' @param stat_n_la A data frame containing statistical neighbour data for LAs. -#' @param geog_groups A reactive expression that provides the selected geography -#' groups based on user input. -#' @param year_input A reactive expression providing the selected year range. -#' @param bds_metrics A data frame containing the BDS metrics used for filtering. -#' @return A reactable output for the staging table, displaying filtered BDS data -#' or error messages based on user selections. -StagingTableServer <- function(id, - create_inputs, - region_names_bds, - la_names_bds, - stat_n_la, - geog_groups, - year_input, - bds_metrics) { - moduleServer(id, function(input, output, session) { - # Staging table reactable ouput - output$staging_table <- reactable::renderReactable({ - # Display messages if there are incorrect selections - if (length(create_inputs$indicator()) == 0 && is.null(geog_groups())) { - return(reactable::reactable( - data.frame( - `Message from tool` = "Please add selections (above).", - check.names = FALSE - ) - )) - } else if (length(create_inputs$indicator()) == 0) { - return(reactable::reactable( - data.frame( - `Message from tool` = "Please add an indicator selection (above).", - check.names = FALSE - ) - )) - } else if (is.null(geog_groups())) { - return(reactable::reactable( - data.frame( - `Message from tool` = "Please add a geography selection (above).", - check.names = FALSE - ) - )) - } - - # Filtering BDS for staging data - staging_bds <- StagingBDSServer( - "staging_bds", - create_inputs, - geog_groups, - year_input, - bds_metrics - ) - - # Build staging data - staging_data <- StagingDataServer( - "staging_data", - create_inputs, - staging_bds, - region_names_bds, - la_names_bds, - stat_n_la - ) - - # Output table - formatting numbers, long text and page settings - dfe_reactable( - staging_data(), - columns = utils::modifyList( - format_num_reactable_cols( - staging_data(), - get_indicator_dps(staging_bds()), - num_exclude = c("LA Number", "Topic", "Measure") - ), - list( - set_custom_default_col_widths( - Measure = set_min_col_width(90) - ), - # Truncates long cell values and displays hover with full value - Measure = reactable::colDef( - html = TRUE, - cell = function(value, index, name) { - truncate_cell_with_hover(text = value, tooltip = value) - } - ) - ) - ), - defaultPageSize = 3, - showPageSizeOptions = TRUE, - pageSizeOptions = c(3, 5, 10, 25), - compact = TRUE - ) - }) - }) -} - - -# Query table ================================================================== -# Query data ------------------------------------------------------------------- -# -#' Query Data Server -#' -#' This function manages the server-side logic for storing and displaying -#' queries based on user selections. It allows users to add queries to a -#' saved list and formats the data for display. The function maintains -#' a reactive data structure that includes the selected topics, indicators, -#' geography, and year range. -#' -#' @param id A unique identifier for the Shiny module. -#' @param create_inputs A list of reactive inputs generated by the main input -#' module, including selected indicators and geography. -#' @param geog_groups A reactive expression that provides the selected geography -#' groups based on user input. -#' @param year_input A reactive expression providing the selected year range. -#' @param staging_data A reactive expression that contains the staging data -#' filtered based on user selections. -#' @return A reactive value list containing the current queries and output data -#' for display, including options for removing queries. -#' -QueryDataServer <- function(id, - create_inputs, - geog_groups, - year_input, - staging_data) { - moduleServer(id, function(input, output, session) { - # Reactive value "query" used to store query data - # Uses lists to store multiple inputs (Geographies & Indicators) - query <- reactiveValues( - data = data.frame( - Topic = I(list()), - Indicator = I(list()), - `LA and Regions` = I(list()), - `Year range` = I(list()), - `Click to remove query` = character(), - `.query_id` = numeric(), - check.names = FALSE - ), - output = data.frame( - `LA Number` = character(), - `LA and Regions` = character(), - Region = character(), - Topic = character(), - Measure = character(), - check.names = FALSE - ) - ) - - # When "Add table" button clicked - add query to saved queries - observeEvent(create_inputs$add_query(), - { - # Check if anything selected - req(length(geog_groups()) > 0 && length(create_inputs$indicator()) > 0) - - # Create a unique identifier for the new query (current no of queries + 1) - new_q_id <- max(c(0, query$data$.query_id), na.rm = TRUE) + 1 - - # Creating year range info - # Get the range of available years - available_years <- range(year_input$choices()) - - # Define the year range info logic - # None selected - all years - "All years (x to y)" - # Range selected - "x to y" - # One year selected - "x" - year_range_display <- dplyr::case_when( - length(year_input$range()) == 0 ~ paste0("All years (", available_years[1], " to ", available_years[2], ")"), - length(year_input$range()) == 2 ~ paste(year_input$range()[1], "to", year_input$range()[2]), - length(year_input$range()) == 1 ~ paste0("", year_input$range()[1]) - ) - - # Evaluate user inputs for get_geog_selection() - evaluated_inputs <- list( - geog = create_inputs$geog(), - la_group = create_inputs$la_group(), - inc_regions = create_inputs$inc_regions(), - inc_england = create_inputs$inc_england() - ) - - # Get selected Indicator Topics - selected_topics <- staging_data() |> - pull_uniques("Topic") - - # Create query information - # Split multiple input choices with commas and line breaks - # (indicator x, indicator y) - # Assign the new query ID, selected topic-indicator pairs, - # create the geog selections (special formatting for groupings), - # year range (with logic from above) and the remove col - new_query <- data.frame( - .query_id = new_q_id, - Topic = paste(selected_topics, collapse = ",
"), - Indicator = paste(create_inputs$indicator(), collapse = ",
"), - `LA and Regions` = paste( - get_geog_selection(evaluated_inputs, la_names_bds, region_names_bds, stat_n_geog), - collapse = ",
" - ), - `Year range` = year_range_display, - `Click to remove query` = "Remove", - check.names = FALSE - ) - - # Append new query to the existing queries - query$data <- query$data |> - rbind(new_query) - - # Appending the data of the new query to the output table - # Adding new query ID to staging data - # (so remove button also removes relevant data from output table) - query_output <- query$output - staging_to_append <- staging_data() - staging_to_append$.query_id <- new_q_id - consistent_staging_final_yrs <- data.frame( - Years = c( - colnames(query_output)[grepl("^\\d{4}", colnames(query_output))], - colnames(staging_to_append)[grepl("^\\d{4}", colnames(staging_to_append))] - ) - ) |> check_year_suffix_consistency() - - # If not consistent suffixes then clean both dfs year cols - if (!consistent_staging_final_yrs && nrow(query_output) > 0) { - query_output <- rename_columns_with_year(query_output) - staging_to_append <- rename_columns_with_year(staging_to_append) - } - - # Get all years across both dfs - all_year_columns <- union( - grep("^\\d{4}", names(query_output), value = TRUE), - grep("^\\d{4}", names(staging_to_append), value = TRUE) - ) - - # Add the new (missing) years onto the existing dfs with values as NaN - # This is so that they can be coded as "-" in the table - # Saved queries - if (nrow(query_output) > 0) { - for (col in setdiff(all_year_columns, names(query_output))) { - query_output[[col]] <- NaN - } - } - - # New query - if (nrow(staging_to_append) > 0) { - for (col in setdiff(all_year_columns, names(staging_to_append))) { - staging_to_append[[col]] <- NaN - } - } - - # Combine query tables for final table output - query$output <- rbind(query_output, staging_to_append) - }, - ignoreInit = TRUE - ) - - query - }) -} - - -# Query Table UI --------------------------------------------------------------- -# -#' Query Table UI -#' -#' This function creates the user interface for displaying a summary of -#' saved queries in a well-styled format. It includes a reactable table -#' output to present the user's selections. -#' -#' @param id A unique identifier for the Shiny module. -#' @return A UI element that displays a summary of selections in a -#' reactable table format. -#' -QueryTableUI <- function(id) { - ns <- NS(id) - - div( - class = "well", - style = "overflow-y: visible;", - h3("Summary of Selections"), - bslib::card( - with_gov_spinner( - reactable::reactableOutput(ns("query_table")), - size = 0.5 - ) - ) - ) -} - -# Query Table Server ----------------------------------------------------------- -# Renders the query table and manages removal actions -# -#' Query Table Server -#' -#' This function handles the server-side logic for rendering the query -#' table and managing the removal of saved queries. It displays the -#' current queries and allows users to remove specific entries. -#' -#' @param id A unique identifier for the Shiny module. -#' @param query A reactive list containing the current query data, including -#' saved queries and output for display. -#' @return A reactive value list that updates when queries are added or -#' removed, reflecting the current state of the query data. -#' -QueryTableServer <- function(id, query) { - moduleServer(id, function(input, output, session) { - # Display message if there are no saved selections - output$query_table <- reactable::renderReactable({ - req(nrow(query$data)) - if (nrow(query$data) == 0) { - return(reactable::reactable( - data.frame(`Message from tool` = "No saved selections.", check.names = FALSE) - )) - } - - # Output table - Allow html (for
), - # add the JS from reactable.extras::button_extra() for remove button - # Show only unique topics and remove the query ID col - dfe_reactable( - query$data, - columns = list( - Indicator = html_col_def(), - `LA and Regions` = html_col_def(), - `Click to remove query` = reactable::colDef( - cell = reactable::JS( - "function(cellInfo) { - const buttonId = 'query_table-remove-' + cellInfo.row['.query_id']; - console.log('Generated button ID:', buttonId); // Confirm buttonId in console - return React.createElement(ButtonExtras, { - id: buttonId, - label: 'Remove', - uuid: cellInfo.row['.query_id'], - column: cellInfo.column.id, - class: 'govuk-button--warning', - className: 'govuk-button--warning' - }, cellInfo.index); - }" - ) - ), - Topic = html_col_def(), - .query_id = reactable::colDef(show = FALSE) - ), - defaultPageSize = 5, - showPageSizeOptions = TRUE, - pageSizeOptions = c(5, 10, 25), - compact = TRUE - ) - }) - - # Remove query button logic - observe({ - req(nrow(query$data)) - - # Create button observers for each row using the query ID - lapply(query$data$.query_id, function(q_id) { - # Create matching query ID for each remove button - remove_button_id <- paste0("remove-", q_id) - - # Observe the button click - observeEvent(input[[remove_button_id]], - { - # Remove the corresponding row (query) from query$data using the query ID - query$data <- query$data[query$data$.query_id != q_id, , drop = FALSE] - - # Also remove the corresponding rows from query$output - query$output <- query$output[query$output$.query_id != q_id, , drop = FALSE] - - # If no rows (queries) left then also remove the years cols - # This is so that if a user wants a range of years next - # the legacy years aren't still there - if (nrow(query$output) == 0) { - query$output <- query$output |> - dplyr::select( - `LA Number`, - `LA and Regions`, - Region, - Topic, - Measure, - .query_id - ) - } - }, - ignoreInit = TRUE - ) - }) - }) - - # Output updated query (which is up-to-date with any removed rows) - query - }) -} - - -# Create Own Table ============================================================= -# Create Own Data -------------------------------------------------------------- -# -#' Create Own Data Server -#' -#' This function processes saved queries and generates a cleaned final -#' table output for display. It checks for year suffix consistency and -#' adjusts the column names accordingly. If there are no saved queries, -#' it returns a message indicating this. -#' -#' @param id A unique identifier for the Shiny module. -#' @param query A reactive list containing the current query data, including -#' saved queries and output for display. -#' @param bds_metrics A data frame containing metrics related to the BDS, -#' which is used to verify year suffix consistency. -#' @return A reactive data frame containing the cleaned final output table -#' with correctly formatted year columns and relevant information. -#' -CreateOwnDataServer <- function(id, query, bds_metrics) { - moduleServer(id, function(input, output, session) { - # Building data for the output of all saved queries - clean_final_table <- reactive({ - req(query$data) - - # Check if there are any saved queries - if (nrow(query$data) == 0) { - return( - data.frame( - `Message from tool` = "No saved selections.", - check.names = FALSE - ) - ) - } - - # Remove columns that contain only NaN values - # (aka user removed query that was including these years so no need to display them now) - query_output_clean <- query$output[, !sapply(query$output, function(x) all(is.nan(x)))] - - # Logic to reset the year cols to have year suffixes if they match - # (As they may have been cleaned from the code logic at end of the new query chunk) - # Determine if output indicators share year suffix consistency - output_indicators <- query_output_clean |> pull_uniques("Measure") - share_year_suffix <- bds_metrics |> - dplyr::filter(Measure %in% output_indicators) |> - check_year_suffix_consistency() - - # Reapply year suffixes to columns if needed - if (share_year_suffix) { - years_dict <- bds_metrics |> - dplyr::filter(Measure %in% output_indicators) |> - dplyr::distinct(Years, Years_num) - - # Replace numeric year columns with the corresponding suffix - new_col_names <- colnames(query_output_clean) |> - vapply(function(col) { - if (col %in% years_dict$Years_num) { - return(years_dict$Years[match(col, years_dict$Years_num)]) - } else { - return(col) - } - }, character(1)) - - colnames(query_output_clean) <- new_col_names - } - - # Final query output table with ordered columns (SN parent if selected) - # and sorted year columns - query_output_clean |> - dplyr::select( - `LA Number`, `LA and Regions`, - Region, Topic, Measure, - tidyselect::any_of("Statistical Neighbour Group"), - dplyr::all_of(sort_year_columns(query_output_clean)) - ) - }) - - # Return data ready to render as output of Create Own Table - clean_final_table - }) -} - - -# Create Own BDS --------------------------------------------------------------- -# -#' Create Own BDS Server -#' -#' This function filters the BDS metrics based on the topic-indicator pairs -#' present in the final output table. It returns a reactive data frame -#' containing only the relevant entries from the BDS that match the specified -#' selections. -#' -#' @param id A unique identifier for the Shiny module. -#' @param create_own_table A reactive expression that returns the final output -#' table containing selected topic-indicator pairs. -#' @param bds_metrics A data frame containing the full BDS metrics to be -#' filtered based on the selections. -#' @return A reactive data frame containing the filtered BDS metrics based -#' on the selected topic-indicator pairs from the final output table. -#' -CreateOwnBDSServer <- function(id, create_own_table, bds_metrics) { - moduleServer(id, function(input, output, session) { - # Filtering BDS for all topic-indicator pairs in the final output table - # (The filtered_bds only has the staging topic-indicator pairs) - final_filtered_bds <- reactive({ - output_table_filters <- create_own_table() |> - dplyr::distinct(`LA and Regions`, Topic, Measure) - - bds_metrics |> - dplyr::semi_join( - output_table_filters, - by = c("LA and Regions", "Topic", "Measure") - ) - }) - - final_filtered_bds - }) -} - - -# Create Own Table UI ---------------------------------------------------------- -# -#' Create Own Table UI -#' -#' This function generates the user interface for displaying the output table -#' that shows all saved selections, along with a download section for exporting -#' the table in various file formats. -#' -#' @param id A unique identifier for the Shiny module, used for namespacing. -#' @return A UI component consisting of a well containing the output table and -#' download options. -#' -CreateOwnTableUI <- function(id) { - ns <- NS(id) - - div( - class = "well", - style = "overflow-y: visible;", - h3( - "Output Table", - create_tooltip_icon( - ' - ' - ) - ), - bslib::navset_card_tab( - # Create Own Table ------------------------------------------------------- - bslib::nav_panel( - title = "Output Table", - with_gov_spinner( - reactable::reactableOutput(ns("output_table")), - size = 0.75 - ) - ), - # Create Own Download ---------------------------------------------------- - bslib::nav_panel( - title = "Download", - file_type_input_btn(ns("file_type")), - Download_DataUI(ns("table_download"), "Output Table") - ) - ) - ) -} - -# Create Own Table Server ------------------------------------------------------ -# -#' Create Own Table Server -#' -#' This function manages the server logic for displaying the output table -#' based on all saved selections. It handles the formatting of the data -#' and the functionality for downloading the table in different formats. -#' -#' @param id A unique identifier for the Shiny module. -#' @param query A reactive object containing saved queries and their data. -#' @param bds_metrics A data frame containing the full BDS metrics used -#' for filtering and formatting the output table. -#' @return None. This function updates the output table and manages -#' download functionality within the Shiny app. -#' -CreateOwnTableServer <- function(id, query, bds_metrics) { - moduleServer(id, function(input, output, session) { - # Load data for Create Own Table - create_own_data <- CreateOwnDataServer( - "create_own_table", - query, - bds_metrics - ) - - # Load BDS made from Create Own data - create_own_bds <- CreateOwnBDSServer( - "create_own_bds", - create_own_data, - bds_metrics - ) - - # Final output table (based on saved queries) ------------------------------ - output$output_table <- reactable::renderReactable({ - # Display the final query table data - # Format numeric cols (using dps based of output table indicators), - # Truncate measure with hover and page settings - dfe_reactable( - create_own_data(), - columns = utils::modifyList( - format_num_reactable_cols( - create_own_data(), - get_indicator_dps(create_own_bds()), - num_exclude = c("LA Number", "Topic", "Measure") - ), - list( - set_custom_default_col_widths(), - Measure = reactable::colDef( - html = TRUE, - cell = function(value, index, name) { - truncate_cell_with_hover(text = value, tooltip = value) - } - ) - ) - ), - defaultPageSize = 5, - showPageSizeOptions = TRUE, - pageSizeOptions = c(5, 10, 25), - compact = TRUE - ) - }) - - # Download the output table ------------------------------------------------ - Download_DataServer( - "table_download", - reactive(input$file_type), - reactive(replace_nan_with_empty(create_own_data())), - reactive("LAIT-create-your-own-table") - ) - }) -} - -# nolint end +# nolint start: object_name +# +# Staging table ================================================================ +# Staging BDS ------------------------------------------------------------------ +# Filter the BDS for current user input selections +# (used to create the staging table) +# +#' Staging BDS Server +#' +#' This function filters the BDS (Business Data Service) metrics based on +#' user input selections to create a staging table. It filters for selected +#' topic-indicator pairs, geographic groupings, and a specified year range. +#' +#' @param id A unique identifier for the Shiny module. +#' @param create_inputs A list of reactive inputs generated by the main +#' input module, including selected topic-indicator pairs. +#' @param geog_groups A reactive expression providing the selected geographic +#' groups based on user input. +#' @param year_input A list containing reactive expressions for selected +#' year range and available years choices. +#' @param bds_metrics A data frame containing the business data service metrics +#' used for filtering based on user selections. +#' @return A reactive data frame that contains the filtered BDS metrics +#' suitable for display in the staging table. +#' +StagingBDSServer <- function(id, + create_inputs, + geog_groups, + year_input, + bds_metrics) { + moduleServer(id, function(input, output, session) { + # Forcing module to react to change in year input (not best practice) + observeEvent(year_input$range(), { + year_input$range() + }) + + # Filter BDS for topic-indicator pairs in the selected_values reactive + topic_indicator_bds <- reactive({ + req(length(create_inputs$indicator()) > 0) + bds_metrics |> + dplyr::filter(Measure %in% create_inputs$indicator()) + }) + + # Now filter BDS for geographies and year range + # Split from above so if indicator doesn't change then don't recompute + staging_bds <- reactive({ + req(geog_groups(), topic_indicator_bds()) + # Filter by full geography inputs + filtered_bds <- topic_indicator_bds() |> + dplyr::filter( + `LA and Regions` %in% geog_groups() + ) + + # Cleaning Years + # Check if all years have consistent suffix + consistent_str_years <- check_year_suffix_consistency(filtered_bds) + + # If not consistent suffix use the cleaned year cols (numeric years) + if (!consistent_str_years) { + filtered_bds <- filtered_bds |> + dplyr::mutate( + Years = Years_num + ) + } + + # Apply the year range filter + # If only one year selected then show just that year + if (length(year_input$range()) == 1) { + filtered_bds <- filtered_bds |> + dplyr::filter( + Years == year_input$range()[1] + ) + } else if (length(year_input$range()) == 2) { + filtered_bds <- filtered_bds |> + dplyr::filter( + Years >= year_input$range()[1], + Years <= year_input$range()[2] + ) + } + + # Return the user selection filtered data for staging table + filtered_bds + }) + + + # Return staging BDS + staging_bds + }) +} + + +# Staging data ----------------------------------------------------------------- +# +#' Staging Data Server +#' +#' This function builds a staging table for displaying filtered BDS metrics +#' in a Shiny application. It incorporates statistical neighbour associations +#' if selected and formats the data into a wide format for easier analysis. +#' +#' @param id A unique identifier for the Shiny module. +#' @param create_inputs A list of reactive inputs generated by the main +#' input module, including selected topic-indicator pairs. +#' @param staging_bds A reactive expression providing the filtered BDS metrics +#' based on user selections. +#' @param region_names_bds A vector of names representing regions in the BDS. +#' @param la_names_bds A vector of names representing local authorities in the BDS. +#' @param stat_n_la A data frame containing statistical neighbour data for LAs. +#' @return A reactive data frame that contains the formatted staging table +#' ready for display in the Shiny app. +#' +StagingDataServer <- function( + id, create_inputs, staging_bds, region_names_bds, la_names_bds, stat_n_la) { + moduleServer(id, function(input, output, session) { + # Make statistical neighbour association table available + stat_n_association <- StatN_AssociationServer( + "stat_n_association", + create_inputs, + la_names_bds, + stat_n_la + ) + + # Build the staging table + staging_table <- reactive({ + # Selected relevant cols + # Coerce to wide format + # (any new values created set to NaN so can be picked up as user created NAs) + # Set regions and England as themselves for Region + wide_table <- staging_bds() |> + dplyr::select( + `LA Number`, `LA and Regions`, Region, Topic, + Measure, Years, Years_num, values_num, Values + ) |> + tidyr::pivot_wider( + id_cols = c("LA Number", "LA and Regions", "Region", "Topic", "Measure"), + names_from = Years, + values_from = values_num, + values_fill = NaN + ) |> + dplyr::mutate(Region = dplyr::case_when( + `LA and Regions` %in% c("England", region_names_bds) ~ `LA and Regions`, + TRUE ~ Region + )) + + # Order columns (and sort year cols order) + wide_table_ordered <- wide_table |> + dplyr::select( + `LA Number`, `LA and Regions`, Region, + Topic, Measure, + dplyr::all_of(sort_year_columns(wide_table)) + ) + + # If SNs included, add SN LA association column + # Multi-join as want to include an association for every row (even duplicates) + if (isTRUE(create_inputs$la_group() == "la_stat_ns")) { + wide_table_ordered <- wide_table_ordered |> + dplyr::left_join( + stat_n_association(), + by = "LA and Regions", + relationship = "many-to-many" + ) |> + dplyr::relocate(sn_parent, .after = "Measure") |> + dplyr::rename("Statistical Neighbour Group" = "sn_parent") + } + + # Staging table formatted and ready for output + wide_table_ordered + }) + + # Return staging table + staging_table + }) +} + + +# Staging table UI ------------------------------------------------------------- +# Simple reactable table inside a well div +# +#' Staging Table UI +#' +#' This function creates the user interface for the staging table, which +#' displays the current selections in a well-styled format. The UI includes +#' a header and a reactable output for rendering the staging data. +#' +#' @param id A unique identifier for the Shiny module. +#' @return A div containing the UI elements for the staging table, including +#' a header and a reactable output. +#' +StagingTableUI <- function(id) { + ns <- NS(id) + + div( + class = "well", + style = "overflow-y: visible;", + bslib::layout_column_wrap( + h3( + "Staging Table", + create_tooltip_icon("Showing data from current selections") + ), + # Include empty divs so matches inputs above and add selections aligns + div(), + div(), + # Add selections button + Create_MainInputsUI("create_inputs")["Add selection"] + ), + bslib::card( + with_gov_spinner( + reactable::reactableOutput(ns("staging_table")), + size = 0.5 + ) + ) + ) +} + + +# Staging table Server --------------------------------------------------------- +# Output a formatted reactable table of the staging data +# Few error message table outputs for incorrect/ missing selections +# +#' Staging Table Server +#' +#' This function generates the server-side logic for the staging table, which +#' renders a reactable table of the current selections. It handles error +#' messages for incorrect or missing selections and formats the staging data +#' for better readability. It filters the BDS data based on user inputs and +#' prepares it for display. +#' +#' @param id A unique identifier for the Shiny module. +#' @param create_inputs A list of reactive inputs generated by the main input +#' module, including selected indicators and geography. +#' @param region_names_bds A vector of names representing regions in the BDS. +#' @param la_names_bds A vector of names representing local authorities in the BDS. +#' @param stat_n_la A data frame containing statistical neighbour data for LAs. +#' @param geog_groups A reactive expression that provides the selected geography +#' groups based on user input. +#' @param year_input A reactive expression providing the selected year range. +#' @param bds_metrics A data frame containing the BDS metrics used for filtering. +#' @return A reactable output for the staging table, displaying filtered BDS data +#' or error messages based on user selections. +StagingTableServer <- function(id, + create_inputs, + region_names_bds, + la_names_bds, + stat_n_la, + geog_groups, + year_input, + bds_metrics) { + moduleServer(id, function(input, output, session) { + # Staging table reactable ouput + output$staging_table <- reactable::renderReactable({ + # Display messages if there are incorrect selections + if (length(create_inputs$indicator()) == 0 && is.null(geog_groups())) { + return(dfe_reactable( + data.frame( + `Message from tool` = "Please add selections (above).", + check.names = FALSE + ) + )) + } else if (length(create_inputs$indicator()) == 0) { + return(dfe_reactable( + data.frame( + `Message from tool` = "Please add an indicator selection (above).", + check.names = FALSE + ) + )) + } else if (is.null(geog_groups())) { + return(dfe_reactable( + data.frame( + `Message from tool` = "Please add a geography selection (above).", + check.names = FALSE + ) + )) + } + + # Filtering BDS for staging data + staging_bds <- StagingBDSServer( + "staging_bds", + create_inputs, + geog_groups, + year_input, + bds_metrics + ) + + # Build staging data + staging_data <- StagingDataServer( + "staging_data", + create_inputs, + staging_bds, + region_names_bds, + la_names_bds, + stat_n_la + ) + + # Output table - formatting numbers, long text and page settings + dfe_reactable( + staging_data(), + columns = utils::modifyList( + format_num_reactable_cols( + staging_data(), + get_indicator_dps(staging_bds()), + num_exclude = c("LA Number", "Topic", "Measure") + ), + list( + set_custom_default_col_widths( + Measure = set_min_col_width(90) + ), + # Truncates long cell values and displays hover with full value + Measure = reactable::colDef( + html = TRUE, + cell = function(value, index, name) { + truncate_cell_with_hover(text = value, tooltip = value) + } + ) + ) + ), + defaultPageSize = 3, + showPageSizeOptions = TRUE, + pageSizeOptions = c(3, 5, 10, 25), + compact = TRUE + ) + }) + }) +} + + +# Query table ================================================================== +# Query data ------------------------------------------------------------------- +# +#' Query Data Server +#' +#' This function manages the server-side logic for storing and displaying +#' queries based on user selections. It allows users to add queries to a +#' saved list and formats the data for display. The function maintains +#' a reactive data structure that includes the selected topics, indicators, +#' geography, and year range. +#' +#' @param id A unique identifier for the Shiny module. +#' @param create_inputs A list of reactive inputs generated by the main input +#' module, including selected indicators and geography. +#' @param geog_groups A reactive expression that provides the selected geography +#' groups based on user input. +#' @param year_input A reactive expression providing the selected year range. +#' @param staging_data A reactive expression that contains the staging data +#' filtered based on user selections. +#' @return A reactive value list containing the current queries and output data +#' for display, including options for removing queries. +#' +QueryDataServer <- function(id, + create_inputs, + geog_groups, + year_input, + staging_data) { + moduleServer(id, function(input, output, session) { + # Reactive value "query" used to store query data + # Uses lists to store multiple inputs (Geographies & Indicators) + query <- reactiveValues( + data = data.frame( + Topic = I(list()), + Indicator = I(list()), + `LA and Regions` = I(list()), + `Year range` = I(list()), + `Click to remove query` = character(), + `.query_id` = numeric(), + check.names = FALSE + ), + output = data.frame( + `LA Number` = character(), + `LA and Regions` = character(), + Region = character(), + Topic = character(), + Measure = character(), + check.names = FALSE + ) + ) + + # When "Add table" button clicked - add query to saved queries + observeEvent(create_inputs$add_query(), + { + # Check if anything selected + req(length(geog_groups()) > 0 && length(create_inputs$indicator()) > 0) + + # Create a unique identifier for the new query (current no of queries + 1) + new_q_id <- max(c(0, query$data$.query_id), na.rm = TRUE) + 1 + + # Creating year range info + # Get the range of available years + available_years <- range(year_input$choices()) + + # Define the year range info logic + # None selected - all years - "All years (x to y)" + # Range selected - "x to y" + # One year selected - "x" + year_range_display <- dplyr::case_when( + length(year_input$range()) == 0 ~ paste0("All years (", available_years[1], " to ", available_years[2], ")"), + length(year_input$range()) == 2 ~ paste(year_input$range()[1], "to", year_input$range()[2]), + length(year_input$range()) == 1 ~ paste0("", year_input$range()[1]) + ) + + # Evaluate user inputs for get_geog_selection() + evaluated_inputs <- list( + geog = create_inputs$geog(), + la_group = create_inputs$la_group(), + inc_regions = create_inputs$inc_regions(), + inc_england = create_inputs$inc_england() + ) + + # Get selected Indicator Topics + selected_topics <- staging_data() |> + pull_uniques("Topic") + + # Create query information + # Split multiple input choices with commas and line breaks + # (indicator x, indicator y) + # Assign the new query ID, selected topic-indicator pairs, + # create the geog selections (special formatting for groupings), + # year range (with logic from above) and the remove col + new_query <- data.frame( + .query_id = new_q_id, + Topic = paste(selected_topics, collapse = ",
"), + Indicator = paste(create_inputs$indicator(), collapse = ",
"), + `LA and Regions` = paste( + get_geog_selection(evaluated_inputs, la_names_bds, region_names_bds, stat_n_geog), + collapse = ",
" + ), + `Year range` = year_range_display, + `Click to remove query` = "Remove", + check.names = FALSE + ) + + # Append new query to the existing queries + query$data <- query$data |> + rbind(new_query) + + # Appending the data of the new query to the output table + # Adding new query ID to staging data + # (so remove button also removes relevant data from output table) + query_output <- query$output + staging_to_append <- staging_data() + staging_to_append$.query_id <- new_q_id + consistent_staging_final_yrs <- data.frame( + Years = c( + colnames(query_output)[grepl("^\\d{4}", colnames(query_output))], + colnames(staging_to_append)[grepl("^\\d{4}", colnames(staging_to_append))] + ) + ) |> check_year_suffix_consistency() + + # If not consistent suffixes then clean both dfs year cols + if (!consistent_staging_final_yrs && nrow(query_output) > 0) { + query_output <- rename_columns_with_year(query_output) + staging_to_append <- rename_columns_with_year(staging_to_append) + } + + # Get all years across both dfs + all_year_columns <- union( + grep("^\\d{4}", names(query_output), value = TRUE), + grep("^\\d{4}", names(staging_to_append), value = TRUE) + ) + + # Add the new (missing) years onto the existing dfs with values as NaN + # This is so that they can be coded as "-" in the table + # Saved queries + if (nrow(query_output) > 0) { + for (col in setdiff(all_year_columns, names(query_output))) { + query_output[[col]] <- NaN + } + } + + # New query + if (nrow(staging_to_append) > 0) { + for (col in setdiff(all_year_columns, names(staging_to_append))) { + staging_to_append[[col]] <- NaN + } + } + + # Combine query tables for final table output + query$output <- rbind(query_output, staging_to_append) + }, + ignoreInit = TRUE + ) + + query + }) +} + + +# Query Table UI --------------------------------------------------------------- +# +#' Query Table UI +#' +#' This function creates the user interface for displaying a summary of +#' saved queries in a well-styled format. It includes a reactable table +#' output to present the user's selections. +#' +#' @param id A unique identifier for the Shiny module. +#' @return A UI element that displays a summary of selections in a +#' reactable table format. +#' +QueryTableUI <- function(id) { + ns <- NS(id) + + div( + class = "well", + style = "overflow-y: visible;", + h3("Summary of Selections"), + bslib::card( + with_gov_spinner( + reactable::reactableOutput(ns("query_table")), + size = 0.5 + ) + ) + ) +} + +# Query Table Server ----------------------------------------------------------- +# Renders the query table and manages removal actions +# +#' Query Table Server +#' +#' This function handles the server-side logic for rendering the query +#' table and managing the removal of saved queries. It displays the +#' current queries and allows users to remove specific entries. +#' +#' @param id A unique identifier for the Shiny module. +#' @param query A reactive list containing the current query data, including +#' saved queries and output for display. +#' @return A reactive value list that updates when queries are added or +#' removed, reflecting the current state of the query data. +#' +QueryTableServer <- function(id, query) { + moduleServer(id, function(input, output, session) { + # Display message if there are no saved selections + output$query_table <- reactable::renderReactable({ + req(nrow(query$data)) + if (nrow(query$data) == 0) { + return(dfe_reactable( + data.frame(`Message from tool` = "No saved selections.", check.names = FALSE) + )) + } + + # Output table - Allow html (for
), + # add the JS from reactable.extras::button_extra() for remove button + # Show only unique topics and remove the query ID col + dfe_reactable( + query$data, + columns = list( + Indicator = html_col_def(), + `LA and Regions` = html_col_def(), + `Click to remove query` = reactable::colDef( + cell = reactable::JS( + "function(cellInfo) { + const buttonId = 'query_table-remove-' + cellInfo.row['.query_id']; + console.log('Generated button ID:', buttonId); // Confirm buttonId in console + return React.createElement(ButtonExtras, { + id: buttonId, + label: 'Remove', + uuid: cellInfo.row['.query_id'], + column: cellInfo.column.id, + class: 'govuk-button--warning', + className: 'govuk-button--warning' + }, cellInfo.index); + }" + ) + ), + Topic = html_col_def(), + .query_id = reactable::colDef(show = FALSE) + ), + defaultPageSize = 5, + showPageSizeOptions = TRUE, + pageSizeOptions = c(5, 10, 25), + compact = TRUE + ) + }) + + # Remove query button logic + observe({ + req(nrow(query$data)) + + # Create button observers for each row using the query ID + lapply(query$data$.query_id, function(q_id) { + # Create matching query ID for each remove button + remove_button_id <- paste0("remove-", q_id) + + # Observe the button click + observeEvent(input[[remove_button_id]], + { + # Remove the corresponding row (query) from query$data using the query ID + query$data <- query$data[query$data$.query_id != q_id, , drop = FALSE] + + # Also remove the corresponding rows from query$output + query$output <- query$output[query$output$.query_id != q_id, , drop = FALSE] + + # If no rows (queries) left then also remove the years cols + # This is so that if a user wants a range of years next + # the legacy years aren't still there + if (nrow(query$output) == 0) { + query$output <- query$output |> + dplyr::select( + `LA Number`, + `LA and Regions`, + Region, + Topic, + Measure, + .query_id + ) + } + }, + ignoreInit = TRUE + ) + }) + }) + + # Output updated query (which is up-to-date with any removed rows) + query + }) +} + + +# Create Own Table ============================================================= +# Create Own Data -------------------------------------------------------------- +# +#' Create Own Data Server +#' +#' This function processes saved queries and generates a cleaned final +#' table output for display. It checks for year suffix consistency and +#' adjusts the column names accordingly. If there are no saved queries, +#' it returns a message indicating this. +#' +#' @param id A unique identifier for the Shiny module. +#' @param query A reactive list containing the current query data, including +#' saved queries and output for display. +#' @param bds_metrics A data frame containing metrics related to the BDS, +#' which is used to verify year suffix consistency. +#' @return A reactive data frame containing the cleaned final output table +#' with correctly formatted year columns and relevant information. +#' +CreateOwnDataServer <- function(id, query, bds_metrics) { + moduleServer(id, function(input, output, session) { + # Building data for the output of all saved queries + clean_final_table <- reactive({ + req(query$data) + + # Check if there are any saved queries + if (nrow(query$data) == 0) { + return( + data.frame( + `Message from tool` = "No saved selections.", + check.names = FALSE + ) + ) + } + + # Remove columns that contain only NaN values + # (aka user removed query that was including these years so no need to display them now) + query_output_clean <- query$output[, !sapply(query$output, function(x) all(is.nan(x)))] + + # Logic to reset the year cols to have year suffixes if they match + # (As they may have been cleaned from the code logic at end of the new query chunk) + # Determine if output indicators share year suffix consistency + output_indicators <- query_output_clean |> pull_uniques("Measure") + share_year_suffix <- bds_metrics |> + dplyr::filter(Measure %in% output_indicators) |> + check_year_suffix_consistency() + + # Reapply year suffixes to columns if needed + if (share_year_suffix) { + years_dict <- bds_metrics |> + dplyr::filter(Measure %in% output_indicators) |> + dplyr::distinct(Years, Years_num) + + # Replace numeric year columns with the corresponding suffix + new_col_names <- colnames(query_output_clean) |> + vapply(function(col) { + if (col %in% years_dict$Years_num) { + return(years_dict$Years[match(col, years_dict$Years_num)]) + } else { + return(col) + } + }, character(1)) + + colnames(query_output_clean) <- new_col_names + } + + # Final query output table with ordered columns (SN parent if selected) + # and sorted year columns + query_output_clean |> + dplyr::select( + `LA Number`, `LA and Regions`, + Region, Topic, Measure, + tidyselect::any_of("Statistical Neighbour Group"), + dplyr::all_of(sort_year_columns(query_output_clean)) + ) + }) + + # Return data ready to render as output of Create Own Table + clean_final_table + }) +} + + +# Create Own BDS --------------------------------------------------------------- +# +#' Create Own BDS Server +#' +#' This function filters the BDS metrics based on the topic-indicator pairs +#' present in the final output table. It returns a reactive data frame +#' containing only the relevant entries from the BDS that match the specified +#' selections. +#' +#' @param id A unique identifier for the Shiny module. +#' @param create_own_table A reactive expression that returns the final output +#' table containing selected topic-indicator pairs. +#' @param bds_metrics A data frame containing the full BDS metrics to be +#' filtered based on the selections. +#' @return A reactive data frame containing the filtered BDS metrics based +#' on the selected topic-indicator pairs from the final output table. +#' +CreateOwnBDSServer <- function(id, create_own_table, bds_metrics) { + moduleServer(id, function(input, output, session) { + # Filtering BDS for all topic-indicator pairs in the final output table + # (The filtered_bds only has the staging topic-indicator pairs) + final_filtered_bds <- reactive({ + output_table_filters <- create_own_table() |> + dplyr::distinct(`LA and Regions`, Topic, Measure) + + bds_metrics |> + dplyr::semi_join( + output_table_filters, + by = c("LA and Regions", "Topic", "Measure") + ) + }) + + final_filtered_bds + }) +} + + +# Create Own Table UI ---------------------------------------------------------- +# +#' Create Own Table UI +#' +#' This function generates the user interface for displaying the output table +#' that shows all saved selections, along with a download section for exporting +#' the table in various file formats. +#' +#' @param id A unique identifier for the Shiny module, used for namespacing. +#' @return A UI component consisting of a well containing the output table and +#' download options. +#' +CreateOwnTableUI <- function(id) { + ns <- NS(id) + + div( + class = "well", + style = "overflow-y: visible;", + h3( + "Output Table", + create_tooltip_icon( + ' + ' + ) + ), + bslib::navset_card_tab( + # Create Own Table ------------------------------------------------------- + bslib::nav_panel( + title = "Output Table", + with_gov_spinner( + reactable::reactableOutput(ns("output_table")), + size = 0.75 + ) + ), + # Create Own Download ---------------------------------------------------- + bslib::nav_panel( + title = "Download", + shiny::uiOutput(ns("download_file_txt")), + Download_DataUI(ns("table_download"), "Output Table") + ) + ) + ) +} + +# Create Own Table Server ------------------------------------------------------ +# +#' Create Own Table Server +#' +#' This function manages the server logic for displaying the output table +#' based on all saved selections. It handles the formatting of the data +#' and the functionality for downloading the table in different formats. +#' +#' @param id A unique identifier for the Shiny module. +#' @param query A reactive object containing saved queries and their data. +#' @param bds_metrics A data frame containing the full BDS metrics used +#' for filtering and formatting the output table. +#' @return None. This function updates the output table and manages +#' download functionality within the Shiny app. +#' +CreateOwnTableServer <- function(id, query, bds_metrics) { + moduleServer(id, function(input, output, session) { + # Load data for Create Own Table + create_own_data <- CreateOwnDataServer( + "create_own_table", + query, + bds_metrics + ) + + # Load BDS made from Create Own data + create_own_bds <- CreateOwnBDSServer( + "create_own_bds", + create_own_data, + bds_metrics + ) + + # Final output table (based on saved queries) ------------------------------ + output$output_table <- reactable::renderReactable({ + # Display the final query table data + # Format numeric cols (using dps based of output table indicators), + # Truncate measure with hover and page settings + dfe_reactable( + create_own_data(), + columns = utils::modifyList( + format_num_reactable_cols( + create_own_data(), + get_indicator_dps(create_own_bds()), + num_exclude = c("LA Number", "Topic", "Measure") + ), + list( + set_custom_default_col_widths(), + Measure = reactable::colDef( + html = TRUE, + cell = function(value, index, name) { + truncate_cell_with_hover(text = value, tooltip = value) + } + ) + ) + ), + defaultPageSize = 5, + showPageSizeOptions = TRUE, + pageSizeOptions = c(5, 10, 25), + compact = TRUE + ) + }) + + # Download the output table ------------------------------------------------ + # File download text - calculates file size + ns <- NS(id) + output$download_file_txt <- shiny::renderUI({ + file_type_input_btn(ns("file_type"), replace_nan_with_empty(create_own_data())) + }) + + # Download dataset + Download_DataServer( + "table_download", + reactive(input$file_type), + reactive(replace_nan_with_empty(create_own_data())), + reactive("LAIT-create-your-own-table") + ) + }) +} + +# nolint end diff --git a/R/lait_modules/mod_info_page.R b/R/lait_modules/mod_info_page.R index 4d00aa41..c1f1d18c 100644 --- a/R/lait_modules/mod_info_page.R +++ b/R/lait_modules/mod_info_page.R @@ -1,364 +1,365 @@ -# nolint start: object_name -# -# Display Indicator Information table -IndicatorInfoTableUI <- function(id) { - ns <- NS(id) - with_gov_spinner( - reactable::reactableOutput(ns("indicator_info_table")) - ) -} - - -# Compute Indicator Information table -IndicatorInfoTableServer <- function(id, metrics_data) { - moduleServer(id, function(input, output, session) { - output$indicator_info_table <- reactable::renderReactable({ - # Select columns to show indicator information - indicator_info <- metrics_data |> - dplyr::select( - Topic, - Measure, - `Data Owner (DO) /Supplier and Contact Details`, - `Last Update`, - `Next Update`, - `Hyperlink(s)` - ) |> - # Convert to nice looking links - dplyr::rowwise() |> - dplyr::mutate(`Hyperlink(s) (opens in new tab)` = as.character( - dfeshiny::external_link( - href = `Hyperlink(s)`, - link_text = Measure, - add_warning = FALSE - ) - )) |> - dplyr::ungroup() |> - order_alphabetically(Measure) - - # Output table - dfe_reactable( - indicator_info, - columns = list(`Hyperlink(s)` = reactable::colDef(show = FALSE)), - defaultPageSize = 5, - showPageSizeOptions = TRUE, - pageSizeOptions = c(5, 10, 25), - compact = TRUE, - searchable = TRUE - ) - }) - }) -} - -LatestDataUpdateUI <- function(id) { - ns <- NS(id) - - bslib::card( - full_screen = FALSE, - class = "govuk-notification-banner", - style = "border-radius: 12px; overflow: hidden;", # Add curved corners - bslib::card_body( - style = "gap: 0; padding: 0.7rem;", - div( - class = "govuk-notification-banner__header", - tags$h2( - class = "govuk-notification-banner__title", - id = ns("latest_update_indicator"), - "Latest Updated Indicator(s)" - ) - ), - div( - class = "govuk-notification-banner__content", - style = "border-radius: 9px;", - tags$p( - class = "govuk-notification-banner__heading", - "These indicators were most recently updated:" - ), - with_gov_spinner( - reactable::reactableOutput(ns("latest_update_table")), - size = 0.6 - ) - ) - ) - ) -} - - - -LatestDataUpdateServer <- function(id, metrics_data) { - moduleServer(id, function(input, output, session) { - # Prepare the data - latest_updated_indicator <- metrics_data |> - dplyr::mutate(latest_update_date = as.Date(paste(`Last Update`, "01"), - format = "%B %Y %d" - )) |> - dplyr::filter(latest_update_date == max(latest_update_date)) |> - dplyr::select(Indicator = Measure, `Last Update`) |> - order_alphabetically(Indicator) - - # Render the reactable table with scrollable rows - output$latest_update_table <- reactable::renderReactable({ - dfe_reactable( - latest_updated_indicator, - pagination = FALSE, - bordered = TRUE, - striped = TRUE, - compact = TRUE, - height = "220px", - searchable = TRUE - ) - }) - }) -} - - - -LatestDevUpdateUI <- function(id) { - ns <- NS(id) - - # Use bslib::card() for a clean and modern collapsible card structure - bslib::card( - class = "dev-update-card", - style = " - border: 1px solid #ccc; - border-radius: 12px; - padding: 20px; - margin-bottom: 20px; - background-color: #f9f9f9; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); - ", - # Card header with title, spinning gear icon, and collapse toggle - bslib::card_header( - shiny::tags$div( - style = " - display: flex; - align-items: center; - justify-content: space-between; - ", - # Title text - shiny::tags$div( - style = "display: flex; align-items: center;", - shiny::tags$h3( - "Latest Development Updates", - style = " - margin: 0; - font-weight: bold; - " - ), - # Spinning gear icon - shiny::tags$div( - style = " - width: 40px; - height: 40px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - margin-left: 1rem; - ", - shiny::tags$i( - class = "fas fa-gear", # Font Awesome icon - style = " - color: #1d70b8; - font-size: 20px; - animation: rotateIcon 2s infinite linear; - " - ) - ) - ), - # Collapse toggle button - shiny::tags$button( - class = "btn btn-link", - type = "button", - `data-bs-toggle` = "collapse", - `data-bs-target` = paste0("#", ns("collapseBody")), - `aria-expanded` = "true", - `aria-controls` = ns("collapseBody"), - style = "font-size: 20px; color: #1d70b8;", - shiny::tags$i(class = "fas fa-chevron-down") - ) - ) - ), - # Card body with collapsible content - shiny::tags$div( - id = ns("collapseBody"), - class = "collapse show", # Default to expanded - shiny::tags$div( - class = "card-body", - # Animated text for description - shiny::tags$p( - "Below are the most recent development updates related to the tool:" - ), - # Latest development details - shiny::tags$div( - style = "margin-bottom: 10px;", - with_gov_spinner( - shiny::uiOutput(ns("latest_update_table")), - size = 0.7, - spinner_type = 7 - ) - ) - ) - ), - # Card footer with external link to GitHub - bslib::card_footer( - style = "border: none;", - shiny::HTML(paste0( - "For more information, please visit the ", - dfeshiny::external_link( - href = "https://github.com/dfe-analytical-services/local-authority-interactive-tool", - link_text = "LAIT GitHub", - add_warning = TRUE - ), - "." - )) - ), - # Add the keyframe animation for spinning - shiny::tags$style( - shiny::HTML(" - @keyframes rotateIcon { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } - } - .btn-link { - text-decoration: none; - } - ") - ) - ) -} - - -LatestDevUpdateServer <- function(id, dev_update_log) { - moduleServer(id, function(input, output, session) { - # Extract the most recent development update - latest_dev_update <- dev_update_log |> - dplyr::filter(Date == max(Date)) |> - dplyr::slice_min(1) - - # Render the update content inside a styled card - output$latest_update_table <- shiny::renderUI({ - htmltools::tags$div( - style = "line-height: 1.6;", - htmltools::tags$p( - htmltools::tags$b("Type:"), - paste(latest_dev_update$Type) - ), - htmltools::tags$p( - htmltools::tags$b("Summary:"), - paste(latest_dev_update$Summary) - ), - htmltools::tags$p( - htmltools::tags$b("Details:"), - shiny::br(), - paste(latest_dev_update$Details) - ), - htmltools::tags$p( - htmltools::tags$b("Date Updated:"), - paste(latest_dev_update$Date) - ) - ) - }) - }) -} - - -UsefulLinksUI <- function(id) { - ns <- NS(id) - - # UI container for useful links - with_gov_spinner( - shiny::uiOutput(ns("useful_links_lst")), - spinner_type = 7 - ) -} - -UsefulLinksServer <- function(id, useful_links) { - moduleServer(id, function(input, output, session) { - # Prepare the data for display - useful_links_formatted <- useful_links |> - dplyr::rowwise() |> - dplyr::mutate(nice_useful_link = as.character( - dfeshiny::external_link( - href = Link, - link_text = Tool_Name, - add_warning = FALSE - ) - )) |> - dplyr::ungroup() - - # Render the UI - output$useful_links_lst <- shiny::renderUI({ - # Create a styled container for the links - htmltools::tags$div( - style = " - line-height: 1.6; - width: 100%; - min-width: 400px; - background-color: #f9f9f9; - border: 1px solid #ddd; - border-radius: 8px; - padding: 0 20px 20px 20px; - margin-bottom: 20px; - box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1); - ", - # Group by 'Type' and render each group in a single card - purrr::imap(unique(useful_links_formatted$Type), function(type, index) { - # Subset links of the same type - links_by_type <- useful_links_formatted |> dplyr::filter(Type == type) - - # Wrap the entire group in a card - htmltools::tags$div( - # Type Header with "Owner" inline for the first type - if (index == 1) { - htmltools::tags$div( - style = " - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 15px; - padding-top: 15px; - ", - htmltools::tags$div( - style = "flex: 2; font-weight: bold;", - type - ), - htmltools::tags$div( - style = "flex: 1; text-align: left; font-weight: bold;", - "Owner" - ) - ) - } else { - htmltools::tags$h3( - type, - style = "margin-bottom: 15px; padding-top: 15px;" - ) - }, - # List the links for the type - htmltools::tags$div( - purrr::map(seq_len(nrow(links_by_type)), function(i) { - htmltools::tags$div( - style = "display: flex; justify-content: space-between; align-items: center;", - # Left: The link - htmltools::tags$div( - style = "flex: 2;", - shiny::HTML(links_by_type$nice_useful_link[i]) - ), - # Right: The owner - htmltools::tags$div( - style = " - flex: 1; - text-align: left; - ", - links_by_type$Owner[i] - ) - ) - }) - ) - ) - }) - ) - }) - }) -} - -# nolint end +# nolint start: object_name +# +# Display Indicator Information table +IndicatorInfoTableUI <- function(id) { + ns <- NS(id) + with_gov_spinner( + reactable::reactableOutput(ns("indicator_info_table")) + ) +} + + +# Compute Indicator Information table +IndicatorInfoTableServer <- function(id, metrics_data) { + moduleServer(id, function(input, output, session) { + output$indicator_info_table <- reactable::renderReactable({ + # Select columns to show indicator information + indicator_info <- metrics_data |> + dplyr::select( + Topic, + Measure, + `Data Owner (DO) /Supplier and Contact Details`, + `Last Update`, + `Next Update`, + `Hyperlink(s)` + ) |> + # Convert to nice looking links + dplyr::rowwise() |> + dplyr::mutate(`Hyperlink(s) (opens in new tab)` = as.character( + dfeshiny::external_link( + href = `Hyperlink(s)`, + link_text = Measure, + add_warning = FALSE + ) + )) |> + dplyr::ungroup() |> + order_alphabetically(Measure) + + # Output table + dfe_reactable( + indicator_info, + columns = list(`Hyperlink(s)` = reactable::colDef(show = FALSE)), + defaultPageSize = 5, + showPageSizeOptions = TRUE, + pageSizeOptions = c(5, 10, 25), + searchable = TRUE + ) + }) + }) +} + +LatestDataUpdateUI <- function(id) { + ns <- NS(id) + + bslib::card( + full_screen = FALSE, + class = "govuk-notification-banner", + style = "border-radius: 12px; overflow: hidden;", # Add curved corners + bslib::card_body( + style = "gap: 0; padding: 0.7rem;", + div( + class = "govuk-notification-banner__header", + tags$h2( + class = "govuk-notification-banner__title", + id = ns("latest_update_indicator"), + "Latest Updated Indicator(s)" + ) + ), + div( + class = "govuk-notification-banner__content", + style = "border-radius: 9px;", + tags$p( + class = "govuk-notification-banner__heading", + "These indicators were most recently updated:" + ), + with_gov_spinner( + reactable::reactableOutput(ns("latest_update_table")), + size = 0.6 + ) + ) + ) + ) +} + + + +LatestDataUpdateServer <- function(id, metrics_data) { + moduleServer(id, function(input, output, session) { + # Prepare the data + latest_updated_indicator <- metrics_data |> + dplyr::mutate(latest_update_date = as.Date(paste(`Last Update`, "01"), + format = "%B %Y %d" + )) |> + dplyr::filter(latest_update_date == max(latest_update_date)) |> + dplyr::select(Indicator = Measure, `Last Update`) |> + order_alphabetically(Indicator) + + # Render the reactable table with scrollable rows + output$latest_update_table <- reactable::renderReactable({ + dfe_reactable( + latest_updated_indicator, + pagination = FALSE, + bordered = TRUE, + striped = TRUE, + height = "220px", + searchable = TRUE + ) + }) + }) +} + + + +LatestDevUpdateUI <- function(id) { + ns <- NS(id) + + # Use bslib::card() for a clean and modern collapsible card structure + bslib::card( + class = "dev-update-card", + style = " + border: 1px solid #ccc; + border-radius: 12px; + padding: 20px; + margin-bottom: 20px; + background-color: #f9f9f9; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + ", + # Card header with title, spinning gear icon, and collapse toggle + bslib::card_header( + shiny::tags$div( + style = " + display: flex; + align-items: center; + justify-content: space-between; + ", + # Title text + shiny::tags$div( + style = "display: flex; align-items: center;", + shiny::tags$h3( + "Latest Development Updates", + style = " + margin: 0; + font-weight: bold; + " + ), + # Spinning gear icon + shiny::tags$div( + style = " + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-left: 1rem; + ", + shiny::tags$i( + class = "fas fa-gear", + `aria-hidden` = "true", + style = " + color: #1d70b8; + font-size: 20px; + animation: rotateIcon 2s infinite linear; + " + ) + ) + ), + # Collapse toggle button + shiny::tags$button( + class = "btn btn-link", + type = "button", + `data-bs-toggle` = "collapse", + `data-bs-target` = paste0("#", ns("collapseBody")), + `aria-expanded` = "true", + `aria-controls` = ns("collapseBody"), + style = "font-size: 20px; color: #1d70b8;", + shiny::tags$i(class = "fas fa-chevron-down") + ) + ) + ), + # Card body with collapsible content + shiny::tags$div( + id = ns("collapseBody"), + class = "collapse show", # Default to expanded + shiny::tags$div( + class = "card-body", + # Animated text for description + shiny::tags$p( + "Below are the most recent development updates related to the tool:" + ), + # Latest development details + shiny::tags$div( + style = "margin-bottom: 10px;", + with_gov_spinner( + shiny::uiOutput(ns("latest_update_table")), + color = "#0b0c0c", + size = 0.7, + spinner_type = 7 + ) + ) + ) + ), + # Card footer with external link to GitHub + bslib::card_footer( + style = "border: none;", + shiny::HTML(paste0( + "For more information, please visit the ", + dfeshiny::external_link( + href = "https://github.com/dfe-analytical-services/local-authority-interactive-tool", + link_text = "LAIT GitHub", + add_warning = TRUE + ), + "." + )) + ), + # Add the keyframe animation for spinning + shiny::tags$style( + shiny::HTML(" + @keyframes rotateIcon { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + .btn-link { + text-decoration: none; + } + ") + ) + ) +} + + +LatestDevUpdateServer <- function(id, dev_update_log) { + moduleServer(id, function(input, output, session) { + # Extract the most recent development update + latest_dev_update <- dev_update_log |> + dplyr::filter(Date == max(Date)) |> + dplyr::slice_min(1) + + # Render the update content inside a styled card + output$latest_update_table <- shiny::renderUI({ + htmltools::tags$div( + style = "line-height: 1.6;", + htmltools::tags$p( + htmltools::tags$b("Type:"), + paste(latest_dev_update$Type) + ), + htmltools::tags$p( + htmltools::tags$b("Summary:"), + paste(latest_dev_update$Summary) + ), + htmltools::tags$p( + htmltools::tags$b("Details:"), + shiny::br(), + paste(latest_dev_update$Details) + ), + htmltools::tags$p( + htmltools::tags$b("Date Updated:"), + paste(latest_dev_update$Date) + ) + ) + }) + }) +} + + +UsefulLinksUI <- function(id) { + ns <- NS(id) + + # UI container for useful links + with_gov_spinner( + shiny::uiOutput(ns("useful_links_lst")), + spinner_type = 7, + color = "#0b0c0c" + ) +} + +UsefulLinksServer <- function(id, useful_links) { + moduleServer(id, function(input, output, session) { + # Prepare the data for display + useful_links_formatted <- useful_links |> + dplyr::rowwise() |> + dplyr::mutate(nice_useful_link = as.character( + dfeshiny::external_link( + href = Link, + link_text = Tool_Name, + add_warning = FALSE + ) + )) |> + dplyr::ungroup() + + # Render the UI + output$useful_links_lst <- shiny::renderUI({ + # Create a styled container for the links + htmltools::tags$div( + style = " + line-height: 1.6; + width: 100%; + min-width: 400px; + background-color: #f9f9f9; + border: 1px solid #ddd; + border-radius: 8px; + padding: 0 20px 20px 20px; + margin-bottom: 20px; + box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1); + ", + # Group by 'Type' and render each group in a single card + purrr::imap(unique(useful_links_formatted$Type), function(type, index) { + # Subset links of the same type + links_by_type <- useful_links_formatted |> dplyr::filter(Type == type) + + # Wrap the entire group in a card + htmltools::tags$div( + # Type Header with "Owner" inline for the first type + if (index == 1) { + htmltools::tags$div( + style = " + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + padding-top: 15px; + ", + htmltools::tags$div( + style = "flex: 2; font-weight: bold;", + type + ), + htmltools::tags$div( + style = "flex: 1; text-align: left; font-weight: bold;", + "Owner" + ) + ) + } else { + htmltools::tags$h3( + type, + style = "margin-bottom: 15px; padding-top: 15px;" + ) + }, + # List the links for the type + htmltools::tags$div( + purrr::map(seq_len(nrow(links_by_type)), function(i) { + htmltools::tags$div( + style = "display: flex; justify-content: space-between; align-items: center;", + # Left: The link + htmltools::tags$div( + style = "flex: 2;", + shiny::HTML(links_by_type$nice_useful_link[i]) + ), + # Right: The owner + htmltools::tags$div( + style = " + flex: 1; + text-align: left; + ", + links_by_type$Owner[i] + ) + ) + }) + ) + ) + }) + ) + }) + }) +} + +# nolint end diff --git a/R/lait_modules/mod_la_lvl_charts.R b/R/lait_modules/mod_la_lvl_charts.R index d2707df5..5ed972fc 100644 --- a/R/lait_modules/mod_la_lvl_charts.R +++ b/R/lait_modules/mod_la_lvl_charts.R @@ -1,368 +1,368 @@ -# nolint start: object_name -# -#' Line Chart UI Module -#' -#' Creates a user interface component for displaying a line chart with -#' download options. This UI module is designed to be used within a Shiny -#' application and provides a structured layout for presenting a line chart -#' alongside relevant download buttons. -#' -#' @param id A unique identifier for the module. This is used for namespacing -#' the UI elements within the Shiny app. -#' -#' @return A `shiny::tagList` containing a navigation panel with a line chart -#' display, download options, and a hidden static plot for copy-to-clipboard -#' functionality. -#' -#' @details -#' The UI includes: -#' - A navigation panel titled "Line chart". -#' - A flexbox layout that contains the line chart and download options, -#' styled for a cohesive appearance. -#' - A hidden plot used for copying the chart to the clipboard, ensuring -#' users can easily export the chart without additional steps. -#' -#' @examples -#' # Example usage in UI -#' LA_LineChartUI("line_chart_ui") -#' -LA_LineChartUI <- function(id) { - ns <- NS(id) - - bslib::nav_panel( - title = "Line chart", - div( - style = "display: flex; justify-content: space-between; align-items: center; background: white;", - # Line chart - create_chart_card_ui(ns("line_chart")), - # Download options - create_download_options_ui( - ns("download_btn"), - ns("copybtn") - ) - ), - # Hidden static plot for copy-to-clipboard - create_hidden_clipboard_plot(ns("copy_plot")) - ) -} - - -#' Local Authority Line Chart Server Module -#' -#' This module generates and renders an interactive line chart for -#' Local Authorities -#' using the ggiraph package, based on the selected inputs and data. -#' -#' @param id A unique identifier for the module instance. -#' @param app_inputs A reactive object containing the application inputs -#' (e.g., selected topic, indicator). -#' @param bds_metrics A data frame containing the metrics data for -#' various Local Authorities. -#' @param stat_n_la A data frame containing statistical data for the -#' Local Authorities. -#' -#' @return None (This function is used for its side effects). -#' -#' @details -#' This server module creates a reactive expression for generating the -#' line chart based on the filtered data. -#' -#' The line chart is constructed using `ggplot2` and made interactive -#' with `ggiraph`. -#' Custom tooltips, hover effects, and interactive elements are added for -#' enhanced user experience. -#' -#' The final chart is rendered using `ggiraph::renderGirafe` and displayed -#' in the `line_chart` UI output. -#' The chart is designed to be fully responsive and interactive, -#' allowing users to explore the data visually. -#' -LA_LineChartServer <- function(id, - app_inputs, - bds_metrics, - stat_n_la, - covid_affected_data) { - moduleServer(id, function(input, output, session) { - # Filter for selected topic and indicator - filtered_bds <- BDS_FilteredServer("filtered_bds", app_inputs, bds_metrics) - - # Long format LA data - la_long <- LA_LongDataServer( - "la_table_data", app_inputs, - bds_metrics, stat_n_la - ) - - # Build main static plot - line_chart <- reactive({ - # Generate the covid plot data if add_covid_plot is TRUE - covid_plot <- calculate_covid_plot( - la_long(), - covid_affected_data, - app_inputs$indicator(), - "line" - ) - - # Build plot - la_long() |> - # Set geog orders so selected LA is on top of plot - reorder_la_regions(reverse = TRUE) |> - ggplot2::ggplot() + - ggiraph::geom_line_interactive( - ggplot2::aes( - x = Years_num, - y = values_num, - color = `LA and Regions`, - data_id = `LA and Regions` - ), - na.rm = TRUE, - linewidth = 1 - ) + - # Only show point data where line won't appear (NAs) - ggplot2::geom_point( - data = subset( - create_show_point(la_long(), covid_affected_data, app_inputs$indicator()), - show_point - ), - ggplot2::aes(x = Years_num, y = values_num, color = `LA and Regions`), - shape = 15, - size = 1, - na.rm = TRUE - ) + - # Add COVID plot if indicator affected - add_covid_elements(covid_plot) + - format_axes(la_long()) + - set_plot_colours(la_long(), "colour", app_inputs$la()) + - set_plot_labs(filtered_bds()) + - custom_theme() + - # Revert order of the legend so goes from right to left - ggplot2::guides(color = ggplot2::guide_legend(reverse = TRUE)) - }) - - # Build interactive line chart - interactive_line_chart <- reactive({ - # Creating vertical geoms to make vertical hover tooltip - vertical_hover <- lapply( - get_years(la_long()), - tooltip_vlines, - la_long(), - get_indicator_dps(filtered_bds()), - app_inputs$la() - ) - - # Plotting interactive graph - ggiraph::girafe( - ggobj = (line_chart() + vertical_hover), - width_svg = 8.5, - options = generic_ggiraph_options( - opts_hover( - css = "stroke-dasharray:5,5;stroke:black;stroke-width:2px;" - ) - ), - fonts = list(sans = "Arial") - ) - }) - - # Line chart download ------------------------------------------------------ - # Initialise server logic for download button and modal - DownloadChartBtnServer("download_btn", id, "Line") - - # Set up the download handlers for the chart - Download_DataServer( - "chart_download", - reactive(input$file_type), - reactive(list("svg" = line_chart(), "html" = interactive_line_chart())), - reactive(c(app_inputs$la(), app_inputs$indicator(), "LA-Level-Line-Chart")) - ) - - # Plot used for copy to clipboard (hidden) - output$copy_plot <- shiny::renderPlot( - { - line_chart() - }, - res = 200, - width = 24 * 96, - height = 12 * 96 - ) - - # LA Level line chart plot ------------------------------------------------ - output$line_chart <- ggiraph::renderGirafe({ - interactive_line_chart() - }) - }) -} - - -#' Bar Chart UI Module -#' -#' Creates a user interface component for displaying a bar chart with -#' download options. This UI module is intended for use within a Shiny -#' application and provides a structured layout for presenting a bar chart -#' alongside relevant download buttons. -#' -#' @param id A unique identifier for the module. This is used for namespacing -#' the UI elements within the Shiny app. -#' -#' @return A `shiny::tagList` containing a navigation panel with a bar chart -#' display, download options, and a hidden static plot for copy-to-clipboard -#' functionality. -#' -#' @details -#' The UI includes: -#' - A navigation panel titled "Bar chart". -#' - A flexbox layout that contains the bar chart and download options, -#' styled for a cohesive appearance. -#' - A hidden plot used for copying the chart to the clipboard, allowing -#' users to easily export the chart without additional steps. -#' -#' @examples -#' # Example usage in UI -#' LA_BarChartUI("bar_chart_ui") -#' -LA_BarChartUI <- function(id) { - ns <- NS(id) - - bslib::nav_panel( - title = "Bar chart", - div( - style = "display: flex; justify-content: space-between; align-items: center; background: white;", - # Bar chart - create_chart_card_ui(ns("bar_chart")), - # Download options - create_download_options_ui( - ns("download_btn"), - ns("copybtn") - ) - ), - # Hidden static plot for copy-to-clipboard - create_hidden_clipboard_plot(ns("copy_plot")) - ) -} - - -#' Local Authority Bar Chart Server Module -#' -#' This module generates and renders an interactive bar chart for -#' Local Authorities -#' using the ggiraph package, based on the selected inputs and data. -#' -#' @param id A unique identifier for the module instance. -#' @param app_inputs A reactive object containing the application inputs -#' (e.g., selected topic, indicator). -#' @param bds_metrics A data frame containing the metrics data for various -#' Local Authorities. -#' @param stat_n_la A data frame containing statistical data for the -#' Local Authorities. -#' -#' @return None (This function is used for its side effects). -#' -#' @details -#' This server module creates a reactive expression for generating the -#' bar chart based on the filtered data. -#' -#' The bar chart is constructed using `ggplot2` and made interactive -#' with `ggiraph`. -#' Custom tooltips, hover effects, and interactive elements are added -#' for enhanced user experience. -#' -#' The final chart is rendered using `ggiraph::renderGirafe` and -#' displayed in the `bar_chart` UI output. -#' The chart is designed to be fully responsive and interactive, -#' allowing users to explore the data visually. -#' -LA_BarChartServer <- function(id, - app_inputs, - bds_metrics, - stat_n_la, - covid_affected_data) { - moduleServer(id, function(input, output, session) { - # Filter for selected topic and indicator - filtered_bds <- BDS_FilteredServer("filtered_bds", app_inputs, bds_metrics) - - # Long format LA data - la_long <- LA_LongDataServer( - "la_table_data", app_inputs, - bds_metrics, stat_n_la - ) - - # Build main static plot - bar_chart <- reactive({ - # Generate the covid plot data if add_covid_plot is TRUE - covid_plot <- calculate_covid_plot( - la_long(), - covid_affected_data, - app_inputs$indicator(), - "bar" - ) - - # Build plot - la_long() |> - ggplot2::ggplot() + - ggiraph::geom_col_interactive( - ggplot2::aes( - x = Years_num, - y = values_num, - fill = `LA and Regions`, - tooltip = tooltip_bar( - la_long(), - get_indicator_dps(filtered_bds()), - app_inputs$la() - ), - data_id = `LA and Regions` - ), - position = "dodge", - width = 0.6, - na.rm = TRUE, - colour = "black" - ) + - # Add COVID plot if indicator affected - add_covid_elements(covid_plot) + - format_axes(la_long()) + - set_plot_colours(la_long(), "fill", app_inputs$la()) + - set_plot_labs(filtered_bds()) + - custom_theme() - }) - - # Plotting interactive graph - interactive_bar_chart <- reactive({ - ggiraph::girafe( - ggobj = bar_chart(), - width_svg = 8.5, - options = generic_ggiraph_options( - opts_hover( - css = "stroke-dasharray:5,5;stroke:yellow;stroke-width:2px;" - ) - ), - fonts = list(sans = "Arial") - ) - }) - - # Bar chart download ------------------------------------------------------ - # Initialise server logic for download button and modal - DownloadChartBtnServer("download_btn", id, "Bar") - - # Set up the download handlers for the chart - Download_DataServer( - "chart_download", - reactive(input$file_type), - reactive(list("svg" = bar_chart(), "html" = interactive_bar_chart())), - reactive(c(app_inputs$la(), app_inputs$indicator(), "LA-Level-Bar-Chart")) - ) - - # Plot used for copy to clipboard (hidden) - output$copy_plot <- shiny::renderPlot( - { - bar_chart() - }, - res = 200, - width = 24 * 96, - height = 12 * 96 - ) - - # LA Level bar chart plot ------------------------------------------------- - output$bar_chart <- ggiraph::renderGirafe({ - interactive_bar_chart() - }) - }) -} - -# nolint end +# nolint start: object_name +# +#' Line Chart UI Module +#' +#' Creates a user interface component for displaying a line chart with +#' download options. This UI module is designed to be used within a Shiny +#' application and provides a structured layout for presenting a line chart +#' alongside relevant download buttons. +#' +#' @param id A unique identifier for the module. This is used for namespacing +#' the UI elements within the Shiny app. +#' +#' @return A `shiny::tagList` containing a navigation panel with a line chart +#' display, download options, and a hidden static plot for copy-to-clipboard +#' functionality. +#' +#' @details +#' The UI includes: +#' - A navigation panel titled "Line chart". +#' - A flexbox layout that contains the line chart and download options, +#' styled for a cohesive appearance. +#' - A hidden plot used for copying the chart to the clipboard, ensuring +#' users can easily export the chart without additional steps. +#' +#' @examples +#' # Example usage in UI +#' LA_LineChartUI("line_chart_ui") +#' +LA_LineChartUI <- function(id) { + ns <- NS(id) + + bslib::nav_panel( + title = "Line chart", + div( + style = "display: flex; justify-content: space-between; align-items: center; background: white;", + # Line chart + create_chart_card_ui(ns("line_chart")), + # Download options + create_download_options_ui( + ns("download_btn"), + ns("copybtn") + ) + ), + # Hidden static plot for copy-to-clipboard + create_hidden_clipboard_plot(ns("copy_plot")) + ) +} + + +#' Local Authority Line Chart Server Module +#' +#' This module generates and renders an interactive line chart for +#' Local Authorities +#' using the ggiraph package, based on the selected inputs and data. +#' +#' @param id A unique identifier for the module instance. +#' @param app_inputs A reactive object containing the application inputs +#' (e.g., selected topic, indicator). +#' @param bds_metrics A data frame containing the metrics data for +#' various Local Authorities. +#' @param stat_n_la A data frame containing statistical data for the +#' Local Authorities. +#' +#' @return None (This function is used for its side effects). +#' +#' @details +#' This server module creates a reactive expression for generating the +#' line chart based on the filtered data. +#' +#' The line chart is constructed using `ggplot2` and made interactive +#' with `ggiraph`. +#' Custom tooltips, hover effects, and interactive elements are added for +#' enhanced user experience. +#' +#' The final chart is rendered using `ggiraph::renderGirafe` and displayed +#' in the `line_chart` UI output. +#' The chart is designed to be fully responsive and interactive, +#' allowing users to explore the data visually. +#' +LA_LineChartServer <- function(id, + app_inputs, + bds_metrics, + stat_n_la, + covid_affected_data) { + moduleServer(id, function(input, output, session) { + # Filter for selected topic and indicator + filtered_bds <- BDS_FilteredServer("filtered_bds", app_inputs, bds_metrics) + + # Long format LA data + la_long <- LA_LongDataServer( + "la_table_data", app_inputs, + bds_metrics, stat_n_la + ) + + # Build main static plot + line_chart <- reactive({ + # Generate the covid plot data if add_covid_plot is TRUE + covid_plot <- calculate_covid_plot( + la_long(), + covid_affected_data, + app_inputs$indicator(), + "line" + ) + + # Build plot + la_long() |> + # Set geog orders so selected LA is on top of plot + reorder_la_regions(reverse = TRUE) |> + ggplot2::ggplot() + + ggiraph::geom_line_interactive( + ggplot2::aes( + x = Years_num, + y = values_num, + color = `LA and Regions`, + data_id = `LA and Regions` + ), + na.rm = TRUE, + linewidth = 1 + ) + + # Only show point data where line won't appear (NAs) + ggplot2::geom_point( + data = subset( + create_show_point(la_long(), covid_affected_data, app_inputs$indicator()), + show_point + ), + ggplot2::aes(x = Years_num, y = values_num, color = `LA and Regions`), + shape = 15, + size = 1, + na.rm = TRUE + ) + + # Add COVID plot if indicator affected + add_covid_elements(covid_plot) + + format_axes(la_long()) + + set_plot_colours(la_long(), "colour", app_inputs$la()) + + set_plot_labs(filtered_bds()) + + custom_theme() + + # Revert order of the legend so goes from right to left + ggplot2::guides(color = ggplot2::guide_legend(reverse = TRUE)) + }) + + # Build interactive line chart + interactive_line_chart <- reactive({ + # Creating vertical geoms to make vertical hover tooltip + vertical_hover <- lapply( + get_years(la_long()), + tooltip_vlines, + la_long(), + get_indicator_dps(filtered_bds()), + app_inputs$la() + ) + + # Plotting interactive graph + ggiraph::girafe( + ggobj = (line_chart() + vertical_hover), + width_svg = 8.5, + options = generic_ggiraph_options( + opts_hover( + css = "stroke-dasharray:5,5;stroke:black;stroke-width:2px;" + ) + ), + fonts = list(sans = "Arial") + ) + }) + + # Line chart download ------------------------------------------------------ + # Initialise server logic for download button and modal + DownloadChartBtnServer("download_btn", id, "Line") + + # Set up the download handlers for the chart + Download_DataServer( + "chart_download", + reactive(input$file_type), + reactive(list("svg" = line_chart(), "html" = interactive_line_chart())), + reactive(c(app_inputs$la(), app_inputs$indicator(), "LA-Level-Line-Chart")) + ) + + # Plot used for copy to clipboard (hidden) + output$copy_plot <- shiny::renderPlot( + { + line_chart() + }, + res = 200, + width = 24 * 96, + height = 12 * 96 + ) + + # LA Level line chart plot ------------------------------------------------ + output$line_chart <- ggiraph::renderGirafe({ + interactive_line_chart() + }) + }) +} + + +#' Bar Chart UI Module +#' +#' Creates a user interface component for displaying a bar chart with +#' download options. This UI module is intended for use within a Shiny +#' application and provides a structured layout for presenting a bar chart +#' alongside relevant download buttons. +#' +#' @param id A unique identifier for the module. This is used for namespacing +#' the UI elements within the Shiny app. +#' +#' @return A `shiny::tagList` containing a navigation panel with a bar chart +#' display, download options, and a hidden static plot for copy-to-clipboard +#' functionality. +#' +#' @details +#' The UI includes: +#' - A navigation panel titled "Bar chart". +#' - A flexbox layout that contains the bar chart and download options, +#' styled for a cohesive appearance. +#' - A hidden plot used for copying the chart to the clipboard, allowing +#' users to easily export the chart without additional steps. +#' +#' @examples +#' # Example usage in UI +#' LA_BarChartUI("bar_chart_ui") +#' +LA_BarChartUI <- function(id) { + ns <- NS(id) + + bslib::nav_panel( + title = "Bar chart", + div( + style = "display: flex; justify-content: space-between; align-items: center; background: white;", + # Bar chart + create_chart_card_ui(ns("bar_chart")), + # Download options + create_download_options_ui( + ns("download_btn"), + ns("copybtn") + ) + ), + # Hidden static plot for copy-to-clipboard + create_hidden_clipboard_plot(ns("copy_plot")) + ) +} + + +#' Local Authority Bar Chart Server Module +#' +#' This module generates and renders an interactive bar chart for +#' Local Authorities +#' using the ggiraph package, based on the selected inputs and data. +#' +#' @param id A unique identifier for the module instance. +#' @param app_inputs A reactive object containing the application inputs +#' (e.g., selected topic, indicator). +#' @param bds_metrics A data frame containing the metrics data for various +#' Local Authorities. +#' @param stat_n_la A data frame containing statistical data for the +#' Local Authorities. +#' +#' @return None (This function is used for its side effects). +#' +#' @details +#' This server module creates a reactive expression for generating the +#' bar chart based on the filtered data. +#' +#' The bar chart is constructed using `ggplot2` and made interactive +#' with `ggiraph`. +#' Custom tooltips, hover effects, and interactive elements are added +#' for enhanced user experience. +#' +#' The final chart is rendered using `ggiraph::renderGirafe` and +#' displayed in the `bar_chart` UI output. +#' The chart is designed to be fully responsive and interactive, +#' allowing users to explore the data visually. +#' +LA_BarChartServer <- function(id, + app_inputs, + bds_metrics, + stat_n_la, + covid_affected_data) { + moduleServer(id, function(input, output, session) { + # Filter for selected topic and indicator + filtered_bds <- BDS_FilteredServer("filtered_bds", app_inputs, bds_metrics) + + # Long format LA data + la_long <- LA_LongDataServer( + "la_table_data", app_inputs, + bds_metrics, stat_n_la + ) + + # Build main static plot + bar_chart <- reactive({ + # Generate the covid plot data if add_covid_plot is TRUE + covid_plot <- calculate_covid_plot( + la_long(), + covid_affected_data, + app_inputs$indicator(), + "bar" + ) + + # Build plot + la_long() |> + ggplot2::ggplot() + + ggiraph::geom_col_interactive( + ggplot2::aes( + x = Years_num, + y = values_num, + fill = `LA and Regions`, + tooltip = tooltip_bar( + la_long(), + get_indicator_dps(filtered_bds()), + app_inputs$la() + ), + data_id = `LA and Regions` + ), + position = "dodge", + width = 0.6, + na.rm = TRUE, + colour = "black" + ) + + # Add COVID plot if indicator affected + add_covid_elements(covid_plot) + + format_axes(la_long()) + + set_plot_colours(la_long(), "fill", app_inputs$la()) + + set_plot_labs(filtered_bds()) + + custom_theme() + }) + + # Plotting interactive graph + interactive_bar_chart <- reactive({ + ggiraph::girafe( + ggobj = bar_chart(), + width_svg = 8.5, + options = generic_ggiraph_options( + opts_hover( + css = "stroke-dasharray:5,5;stroke:yellow;stroke-width:2px;" + ) + ), + fonts = list(sans = "Arial") + ) + }) + + # Bar chart download ------------------------------------------------------ + # Initialise server logic for download button and modal + DownloadChartBtnServer("download_btn", id, "Bar") + + # Set up the download handlers for the chart + Download_DataServer( + "chart_download", + reactive(input$file_type), + reactive(list("svg" = bar_chart(), "html" = interactive_bar_chart())), + reactive(c(app_inputs$la(), app_inputs$indicator(), "LA-Level-Bar-Chart")) + ) + + # Plot used for copy to clipboard (hidden) + output$copy_plot <- shiny::renderPlot( + { + bar_chart() + }, + res = 200, + width = 24 * 96, + height = 12 * 96 + ) + + # LA Level bar chart plot ------------------------------------------------- + output$bar_chart <- ggiraph::renderGirafe({ + interactive_bar_chart() + }) + }) +} + +# nolint end diff --git a/R/lait_modules/mod_la_lvl_metadata.R b/R/lait_modules/mod_la_lvl_metadata.R index 25eaa099..ce4aafff 100644 --- a/R/lait_modules/mod_la_lvl_metadata.R +++ b/R/lait_modules/mod_la_lvl_metadata.R @@ -12,7 +12,7 @@ MetadataUI <- function(id) { shinycssloaders::withSpinner( uiOutput(ns("metadata")), type = 7, - color = "#1d70b8", + color = "#0b0c0c", size = 0.6, proxy.height = "10px" ) diff --git a/R/lait_modules/mod_la_lvl_table.R b/R/lait_modules/mod_la_lvl_table.R index d445014f..47e234b4 100644 --- a/R/lait_modules/mod_la_lvl_table.R +++ b/R/lait_modules/mod_la_lvl_table.R @@ -148,15 +148,14 @@ LA_LevelTableUI <- function(id) { id = "la_lvl_table_tabs", bslib::nav_panel( "Table", - bslib::card_header("Local Authority, Region and England"), with_gov_spinner( reactable::reactableOutput(ns("la_table")) ) ), bslib::nav_panel( "Download data", - file_type_input_btn(ns("file_type")), - Download_DataUI(ns("la_download"), "LA Table"), + shiny::uiOutput(ns("download_file_txt")), + Download_DataUI(ns("la_download"), "LA Table") ) ) ) @@ -203,6 +202,13 @@ LA_LevelTableServer <- function(id, app_inputs, bds_metrics, stat_n_la) { # LA table download ------------------------------------------------------- + # File download text - calculates file size + ns <- NS(id) + output$download_file_txt <- shiny::renderUI({ + file_type_input_btn(ns("file_type"), la_table()) + }) + + # Download dataset Download_DataServer( "la_download", reactive(input$file_type), @@ -250,7 +256,7 @@ LA_StatsTableUI <- function(id) { max_width = "100%" ), div( - bslib::card_header("General Statistics", style = "color: #0000;"), + bslib::card_header("Summary"), with_gov_spinner( reactable::reactableOutput(ns("la_stats")), size = 0.4 diff --git a/R/lait_modules/mod_region_table.R b/R/lait_modules/mod_region_table.R index f770bd6a..4f1f9349 100644 --- a/R/lait_modules/mod_region_table.R +++ b/R/lait_modules/mod_region_table.R @@ -215,39 +215,41 @@ RegionLevel_TableUI <- function(id) { div( class = "well", style = "overflow-y: visible;", - bslib::navset_tab( + bslib::navset_card_tab( id = "region_table_tabs", + # Tables tab bslib::nav_panel( title = "Tables", - bslib::card( - bslib::card_body( - # Region LA Table ------------------------------------------------- - bslib::card_header("Local Authorities"), - with_gov_spinner( - reactable::reactableOutput(ns("la_table")), - size = 2 - ), - # Region Table ---------------------------------------------------- - div( - # Add black border between the tables - style = "overflow-y: visible;border-top: 2px solid black; padding-top: 2.5rem;", - bslib::card_header("Regions"), - with_gov_spinner( - reactable::reactableOutput(ns("region_table")), - size = 1.6 - ) - ) + # Region LA Table ------------------------------------------------- + bslib::card_header("Local Authorities"), + with_gov_spinner( + reactable::reactableOutput(ns("la_table")), + size = 2 + ), + # Region Table ---------------------------------------------------- + div( + style = "overflow-y: visible; border-top: 2px solid black; padding-top: 2.5rem;", + bslib::card_header("Regions"), + with_gov_spinner( + reactable::reactableOutput(ns("region_table")), + size = 1.6 ) ), - br(), - # Region Stats Table -------------------------------------------------- - Region_StatsTableUI("region_stats_mod") + div( + style = "overflow-y: visible; border-top: 2px solid black; padding-top: 2.5rem;", + bslib::card_header("Summary"), + # Region Stats Table -------------------------------------------------- + Region_StatsTableUI("region_stats_mod") + ) ), + # Downloads tab bslib::nav_panel( title = "Download", - file_type_input_btn(ns("file_type")), - Download_DataUI(ns("la_download"), "LA Table"), - Download_DataUI(ns("region_download"), "Region Table") + div( + shiny::uiOutput(ns("download_file_txt")), + Download_DataUI(ns("la_download"), "LA Table"), + Download_DataUI(ns("region_download"), "Region Table") + ) ) ) ) @@ -301,13 +303,21 @@ RegionLA_TableServer <- function(id, app_inputs, bds_metrics, stat_n_geog) { # Pretty and order table ready for rendering region_la_table <- reactive({ region_la_table_raw() |> - dplyr::arrange(.data[[current_year()]], `LA and Regions`) + dplyr::arrange(.data[[current_year()]], `LA and Regions`) |> + dplyr::rename("LA" = `LA and Regions`) }) # Download ---------------------------------------------------------------- + # File download text - calculates file size + ns <- NS(id) + output$file_type <- shiny::renderUI({ + file_type_input_btn(ns("file_type"), region_la_table_raw()) + }) + + # Download dataset Download_DataServer( "la_download", - reactive(input$file_type), + reactive(input$download_file_txt), reactive(region_la_table_raw()), reactive(c(app_inputs$la(), app_inputs$indicator(), "LA-Regional-Level")) ) @@ -325,7 +335,7 @@ RegionLA_TableServer <- function(id, app_inputs, bds_metrics, stat_n_geog) { set_custom_default_col_widths() ), rowStyle = function(index) { - highlight_selected_row(index, region_la_table(), app_inputs$la()) + highlight_selected_row(index, region_la_table(), app_inputs$la(), "LA") }, pagination = FALSE ) @@ -508,7 +518,8 @@ Region_TableServer <- function(id, grepl("^England", `LA and Regions`), 1, 0 )) |> dplyr::arrange(is_england, .by_group = FALSE) |> - dplyr::select(-is_england) + dplyr::select(-is_england) |> + dplyr::rename("Region" = `LA and Regions`) }) # Get clean Regions @@ -540,7 +551,7 @@ Region_TableServer <- function(id, set_custom_default_col_widths() ), rowStyle = function(index) { - highlight_selected_row(index, region_table(), region_clean()) + highlight_selected_row(index, region_table(), region_clean(), "Region") }, pagination = FALSE ) @@ -566,13 +577,9 @@ Region_TableServer <- function(id, Region_StatsTableUI <- function(id) { ns <- NS(id) - bslib::card( - bslib::card_body( - with_gov_spinner( - reactable::reactableOutput(ns("stats_table")), - size = 0.6 - ) - ) + with_gov_spinner( + reactable::reactableOutput(ns("stats_table")), + size = 0.6 ) } diff --git a/R/lait_modules/mod_stat_n_table.R b/R/lait_modules/mod_stat_n_table.R index 7122eff4..e32a6132 100644 --- a/R/lait_modules/mod_stat_n_table.R +++ b/R/lait_modules/mod_stat_n_table.R @@ -254,35 +254,36 @@ StatN_TablesUI <- function(id) { div( class = "well", style = "overflow-y: visible;", - bslib::navset_tab( + bslib::navset_card_tab( id = "stat_n_tables_tabs", bslib::nav_panel( "Tables", - bslib::card( - # Statistical Neighbour LA SNs Table -------------------------------- - bslib::card_header("Statistical Neighbours"), + # Statistical Neighbour LA SNs Table -------------------------------- + bslib::card_header("Statistical Neighbours"), + with_gov_spinner( + reactable::reactableOutput(ns("statn_table")), + size = 1.6 + ), + # Statistical Neighbour LA Geog Compare Table ----------------------- + div( + # Add black border between the tables + style = "overflow-y: visible;border-top: 2px solid black; padding-top: 2.5rem;", + bslib::card_header("Other Geographies"), with_gov_spinner( - reactable::reactableOutput(ns("statn_table")), - size = 1.6 - ), - # Statistical Neighbour LA Geog Compare Table ----------------------- - div( - # Add black border between the tables - style = "overflow-y: visible;border-top: 2px solid black; padding-top: 2.5rem;", - bslib::card_header("Other Geographies"), - with_gov_spinner( - reactable::reactableOutput(ns("geog_table")), - size = 0.7 - ) + reactable::reactableOutput(ns("geog_table")), + size = 0.7 ) ), - br(), # Statistical Neighbour Statistics Table ------------------------------ - StatN_StatsTableUI("stat_n_stats_mod") + div( + style = "overflow-y: visible;border-top: 2px solid black; padding-top: 2.5rem;", + bslib::card_header("Summary"), + StatN_StatsTableUI("stat_n_stats_mod") + ) ), bslib::nav_panel( "Download", - file_type_input_btn(ns("file_type")), + shiny::uiOutput(ns("download_file_txt")), Download_DataUI(ns("statn_download"), "Statistical Neighbour Table"), Download_DataUI(ns("geog_download"), "Other Geographies Table") ) @@ -348,10 +349,18 @@ StatN_LASNsTableServer <- function(id, stat_n_sns_table <- reactive({ stat_n_table() |> dplyr::filter(`LA and Regions` %in% c(app_inputs$la(), stat_n_sns())) |> - dplyr::arrange(.data[[current_year()]], `LA and Regions`) + dplyr::arrange(.data[[current_year()]], `LA and Regions`) |> + dplyr::rename("LA" = `LA and Regions`) }) # Download ---------------------------------------------------------------- + # File download text - calculates file size + ns <- NS(id) + output$download_file_txt <- shiny::renderUI({ + file_type_input_btn(ns("file_type"), stat_n_sns_table()) + }) + + # Download dataset Download_DataServer( "statn_download", reactive(input$file_type), @@ -373,7 +382,7 @@ StatN_LASNsTableServer <- function(id, set_custom_default_col_widths() ), rowStyle = function(index) { - highlight_selected_row(index, stat_n_sns_table(), app_inputs$la()) + highlight_selected_row(index, stat_n_sns_table(), app_inputs$la(), "LA") }, pagination = FALSE ) diff --git a/R/ui_panels/accessibility_statement.R b/R/ui_panels/accessibility_statement.R index 041f8ab0..d28006cc 100644 --- a/R/ui_panels/accessibility_statement.R +++ b/R/ui_panels/accessibility_statement.R @@ -1,169 +1,157 @@ a11y_panel <- function() { - shiny::tabPanel( - "Accessibility", - shinyGovstyle::gov_main_layout( - shinyGovstyle::gov_row( - shiny::column( - width = 12, - shinyGovstyle::banner( - "beta banner", - "beta", - paste0( - "This page is in beta phase and we are still reviewing the - content. We will update the relevant missing information closer - to the end of development. (Where it says 'Not available' or - 'To be done'.)" - ) - ), - shiny::br(), - h1("Accessibility statement for LAIT"), # TODO - p( - "This accessibility statement applies to the - https://department-for-education.shinyapps.io/local-authority-interactive-tool/ - website. This website is run by the", # TODO - a( - href = "https://www.gov.uk/government/organisations/department-for-education", - "Department for Education (DfE)", - .noWS = "after" - ), ".", - "This statement does not cover any other services run by the Department for Education (DfE) or GOV.UK." - ), - h2("How you should be able to use this website"), - p("We want as many people as possible to be able to use this website. You should be able to:"), - tags$div(tags$ul( - tags$li("change colours, contrast levels and fonts using browser or device settings"), - tags$li("zoom in up to 400% without the text spilling off the screen"), - tags$li("navigate most of the website using a keyboard or speech recognition software"), - tags$li("listen to most of the website using a screen reader - (including the most recent versions of JAWS, NVDA and VoiceOver)") - )), - p("We’ve also made the website text as simple as possible to understand."), - p( - a(href = "https://mcmw.abilitynet.org.uk/", "AbilityNet"), - " has advice on making your device easier to use if you have a disability." - ), - h2("How accessible this website is"), - p("We know some parts of this website are not fully accessible:"), - tags$div(tags$ul( - tags$li("Not available yet") # TODO - )), - h2("Feedback and contact information"), - p( - "If you need information on this website in a different format please see the ", - a( - href = "https://www.gov.uk/government/publications/local-authority-interactive-tool-lait", # TODO - "LAIT GOV.UK website", # TODO - .noWS = "after" - ), - ". More details are available on that service for alternative formats of this data.", - ), - p("We’re always looking to improve the accessibility of this website. - If you find any problems not listed on this page or think we’re not meeting - accessibility requirements, contact us:"), - tags$ul(tags$li( - a( - href = "mailto:explore.statistics@education.gov.uk", - "explore.statistics@education.gov.uk" - ) - )), - h2("Enforcement procedure"), - p("The Equality and Human Rights Commission (EHRC) is responsible for enforcing the Public Sector Bodies - (Websites and Mobile Applications) (No. 2) Accessibility Regulations 2018 - (the ‘accessibility regulations’)."), - p( - "If you are not happy with how we respond to your complaint, ", - a( - href = "https://www.equalityadvisoryservice.com/", - "contact the Equality Advisory and Support Service (EASS)", - .noWS = "after" - ), - "." - ), - h2("Technical information about this website's accessibility"), - p("The Department for Education (DfE) is committed to making its website accessible, in accordance with the - Public Sector Bodies (Websites and Mobile Applications) (No. 2) Accessibility Regulations 2018."), - h3("Compliance status"), - p( - "This website is partially compliant with the", # TODO - a( - href = "https://www.w3.org/TR/WCAG21/", - "Web Content Accessibility Guidelines version 2.1 AA standard", - .noWS = "after" - ), - " due to the non-compliances listed below." - ), - h3("Non accessible content"), - p("The content listed below is non-accessible for the following reasons. - We will address these issues to ensure our content is accessible."), - tags$div(tags$ul( - tags$li("Not available yet") # TODO - )), - h3("Disproportionate burden"), - p("Not applicable."), - h2("How we tested this website"), - p( - "The template used for this website was last tested on 12 March 2024 against", - a( - href = "https://www.w3.org/TR/WCAG22/", - "Accessibility Guidelines WCAG2.2", - .noWS = "after" - ), - ". The test was carried out by the", - a( - href = "https://digitalaccessibilitycentre.org/", - "Digital accessibility centre (DAC)", - .noWS = "after" - ), - "." - ), - p("DAC tested a sample of pages to cover the core functionality of the service including:"), - tags$div(tags$ul( - tags$li("navigation"), - tags$li("interactive dropdown selections"), - tags$li("charts, maps, and tables") - )), - p( - "This specific website was was last tested on [To be done] against", # TODO - a( - href = "https://www.w3.org/TR/WCAG22/", - "Accessibility Guidelines WCAG2.2", - .noWS = "after" - ), - ". The test was carried out by the", - a( - href = "https://www.gov.uk/government/organisations/department-for-education", - "Department for Education (DfE)", - .noWS = "after" - ), - "." - ), - h2("What we're doing to improve accessibility"), - p("We plan to continually test the service for accessibility issues, and are working through a prioritised - list of issues to resolve."), - p( - "Our current list of issues to be resolved is available on our ", - a( - href = "https://github.com/dfe-analytical-services/local-authority-interactive-tool/issues", # TODO - "[GitHub issues page]", # TODO - .noWS = "after" - ), - "." - ), - h2("Preparation of this accessibility statement"), - p("This statement was prepared on 1st July 2024. It was last reviewed on [To be done]."), # TODO - p( - "The template used for this website was last testing in March 2024 against the WCAG 2.2 AA standard. - This test of a representative sample of pages was carried out by the", - a( - href = "https://digitalaccessibilitycentre.org/", - "Digital accessibility centre (DAC)", - .noWS = "after" - ), - "." - ), - p("We also used findings from our own testing when preparing this accessibility statement.") - ) + shiny::tags$div( + # Add in back link + actionLink( + class = "govuk-back-link", + style = "margin-top: 0.2rem; margin-bottom: 1.2rem;", + "accessibility_to_dashboard", + "Back to dashboard" + ), + shiny::tags$h1("Accessibility statement for LAIT"), # TODO + p( + "This accessibility statement applies to the + https://department-for-education.shinyapps.io/local-authority-interactive-tool/ + website. This website is run by the", # TODO + a( + href = "https://www.gov.uk/government/organisations/department-for-education", + "Department for Education (DfE)", + .noWS = "after" + ), ".", + "This statement does not cover any other services run by the Department for Education (DfE) or GOV.UK." + ), + h2("How you should be able to use this website"), + p("We want as many people as possible to be able to use this website. You should be able to:"), + tags$div(tags$ul( + tags$li("change colours, contrast levels and fonts using browser or device settings"), + tags$li("zoom in up to 400% without the text spilling off the screen"), + tags$li("navigate most of the website using a keyboard or speech recognition software"), + tags$li("listen to most of the website using a screen reader + (including the most recent versions of JAWS, NVDA and VoiceOver)") + )), + p("We’ve also made the website text as simple as possible to understand."), + p( + a(href = "https://mcmw.abilitynet.org.uk/", "AbilityNet"), + " has advice on making your device easier to use if you have a disability." + ), + h2("How accessible this website is"), + p("We know some parts of this website are not fully accessible:"), + tags$div(tags$ul( + tags$li("Not available yet") # TODO + )), + h2("Feedback and contact information"), + p( + "If you need information on this website in a different format please see the ", + a( + href = "https://www.gov.uk/government/publications/local-authority-interactive-tool-lait", # TODO + "LAIT GOV.UK website", # TODO + .noWS = "after" + ), + ". More details are available on that service for alternative formats of this data.", + ), + p("We’re always looking to improve the accessibility of this website. + If you find any problems not listed on this page or think we’re not meeting + accessibility requirements, contact us:"), + tags$ul(tags$li( + a( + href = "mailto:explore.statistics@education.gov.uk", + "explore.statistics@education.gov.uk" ) - ) + )), + h2("Enforcement procedure"), + p("The Equality and Human Rights Commission (EHRC) is responsible for enforcing the Public Sector Bodies + (Websites and Mobile Applications) (No. 2) Accessibility Regulations 2018 + (the ‘accessibility regulations’)."), + p( + "If you are not happy with how we respond to your complaint, ", + a( + href = "https://www.equalityadvisoryservice.com/", + "contact the Equality Advisory and Support Service (EASS)", + .noWS = "after" + ), + "." + ), + h2("Technical information about this website's accessibility"), + p("The Department for Education (DfE) is committed to making its website accessible, in accordance with the + Public Sector Bodies (Websites and Mobile Applications) (No. 2) Accessibility Regulations 2018."), + h3("Compliance status"), + p( + "This website is partially compliant with the", # TODO + a( + href = "https://www.w3.org/TR/WCAG21/", + "Web Content Accessibility Guidelines version 2.1 AA standard", + .noWS = "after" + ), + " due to the non-compliances listed below." + ), + h3("Non accessible content"), + p("The content listed below is non-accessible for the following reasons. + We will address these issues to ensure our content is accessible."), + tags$div(tags$ul( + tags$li("Not available yet") # TODO + )), + h3("Disproportionate burden"), + p("Not applicable."), + h2("How we tested this website"), + p( + "The template used for this website was last tested on 12 March 2024 against", + a( + href = "https://www.w3.org/TR/WCAG22/", + "Accessibility Guidelines WCAG2.2", + .noWS = "after" + ), + ". The test was carried out by the", + a( + href = "https://digitalaccessibilitycentre.org/", + "Digital accessibility centre (DAC)", + .noWS = "after" + ), + "." + ), + p("DAC tested a sample of pages to cover the core functionality of the service including:"), + tags$div(tags$ul( + tags$li("navigation"), + tags$li("interactive dropdown selections"), + tags$li("charts, maps, and tables") + )), + p( + "This specific website was was last tested on [To be done] against", # TODO + a( + href = "https://www.w3.org/TR/WCAG22/", + "Accessibility Guidelines WCAG2.2", + .noWS = "after" + ), + ". The test was carried out by the", + a( + href = "https://www.gov.uk/government/organisations/department-for-education", + "Department for Education (DfE)", + .noWS = "after" + ), + "." + ), + h2("What we're doing to improve accessibility"), + p("We plan to continually test the service for accessibility issues, and are working through a prioritised + list of issues to resolve."), + p( + "Our current list of issues to be resolved is available on our ", + a( + href = "https://github.com/dfe-analytical-services/local-authority-interactive-tool/issues", # TODO + "[GitHub issues page]", # TODO + .noWS = "after" + ), + "." + ), + h2("Preparation of this accessibility statement"), + p("This statement was prepared on 1st July 2024. It was last reviewed on [To be done]."), # TODO + p( + "The template used for this website was last testing in March 2024 against the WCAG 2.2 AA standard. + This test of a representative sample of pages was carried out by the", + a( + href = "https://digitalaccessibilitycentre.org/", + "Digital accessibility centre (DAC)", + .noWS = "after" + ), + "." + ), + p("We also used findings from our own testing when preparing this accessibility statement.") ) } diff --git a/R/ui_panels/all_la_level_panel.R b/R/ui_panels/all_la_level_panel.R new file mode 100644 index 00000000..ddc20827 --- /dev/null +++ b/R/ui_panels/all_la_level_panel.R @@ -0,0 +1,9 @@ +all_la_level_panel <- function() { + bslib::nav_panel( + "all_la_level", + PageHeaderUI("all_la_header"), + appInputsUI("all_la_inputs"), + AllLA_TableUI("all_la_table"), + LA_LevelMetaUI("all_la_meta") + ) +} diff --git a/R/ui_panels/create_your_own_panel.R b/R/ui_panels/create_your_own_panel.R new file mode 100644 index 00000000..7797be8f --- /dev/null +++ b/R/ui_panels/create_your_own_panel.R @@ -0,0 +1,43 @@ +create_your_own_panel <- function() { + bslib::nav_panel( + "create_your_own", + full_data_on_github_noti(), + h1("Create Your Own"), + p( + "On this page you can create a custom data table by making selections + across various options. To download your table, add your selections + by clicking the green 'Add selections' button. This data will also be + displayed as line/bar chart (max of 4 geographies and 3 indicators). + " + ), + div( + class = "well", + style = "overflow-y: visible; padding: 1rem;", + bslib::layout_column_wrap( + Create_MainInputsUI("create_inputs")["Main choices"] + ), + bslib::layout_column_wrap( + Create_MainInputsUI("create_inputs")["LA grouping"], + Create_MainInputsUI("create_inputs")["Other grouping"], + YearRangeUI("year_range"), + Create_MainInputsUI("create_inputs")["Clear all current selections"] + ) + ), + StagingTableUI("staging_table"), + QueryTableUI("query_table"), + CreateOwnTableUI("create_own_table"), + div( + class = "well", + style = "overflow-y: visible;", + shiny::h3( + "Output Charts", + create_tooltip_icon("Charts showing data from all the saved selections") + ), + shiny::p("Note a maximum of 4 geographies and 3 indicators can be shown."), + bslib::navset_card_tab( + CreateOwnLineChartUI("create_own_line"), + CreateOwnBarChartUI("create_own_bar") + ) + ) + ) +} diff --git a/R/ui_panels/example_tab_1.R b/R/ui_panels/example_tab_1.R deleted file mode 100644 index f9c19f56..00000000 --- a/R/ui_panels/example_tab_1.R +++ /dev/null @@ -1,130 +0,0 @@ -example_tab_1_panel <- function() { - shiny::tabPanel( - "Example tab 1", - shinyGovstyle::gov_main_layout( - shinyGovstyle::gov_row( - shiny::column( - width = 12, - h1("Overall content title for this dashboard page"), - ), - # Expandable section -------------------------------------------------- - shiny::column( - width = 12, - expandable( - input_id = "details", label = textOutput("dropdown_label"), - contents = - div( - id = "div_a", - # User selection dropdowns ------------------------------------ - shinyGovstyle::gov_row( - shiny::column( - width = 6, - shiny::selectizeInput("selectPhase", - "Select a school phase", - choices = choices_phase - ) - ), - shiny::column( - width = 6, - shiny::selectizeInput( - inputId = "selectArea", - label = "Choose an area:", - choices = choices_areas$area_name - ) - ), - # Download button ------------------------------------------- - shiny::column( - width = 12, - paste("Download the underlying data for this dashboard:"), - br(), - shiny::downloadButton( - outputId = "download_data", - label = "Download data", - icon = shiny::icon("download"), - class = "downloadButton" - ) - ) - ) - ) - ), - ), - # Tabset under dropdowns ---------------------------------------------- - shiny::column( - width = 12, - shiny::tabsetPanel( - id = "tabsetpanels", - # Value boxes tab ------------------------------------------------- - shiny::tabPanel( - "Valuebox example", - shiny::fluidRow( - shiny::column( - width = 12, - h2("Examples of producing value boxes in R-Shiny"), - shiny::fluidRow( - shiny::column( - width = 12, - shinydashboard::valueBoxOutput("box_balance_latest", width = 6), - shinydashboard::valueBoxOutput("box_balance_change", width = 6) - ) - ) - ) - ) - ), - # Timeseries tab -------------------------------------------------- - shiny::tabPanel( - "Line chart example", - shiny::fluidRow( - shiny::column( - width = 12, - h2("An example line chart using ggplot and ggiraph"), - ggiraph::girafeOutput("lineRevBal", width = "100%", height = "100%") - ) - ) - ), - # Benchmarking tab ------------------------------------------------ - shiny::tabPanel( - "Benchmarking example", - shiny::fluidRow( - shiny::column( - width = 12, - h2("An example bar chart using ggplot and ggiraph"), - p("This is the standard paragraph style for adding guiding - info around data content."), - # Bar chart for benchmarking -------------------------------- - shiny::column( - width = 6, - girafeOutput("colBenchmark", - width = "100%", height = "100%" - ) - ), - shiny::column( - width = 6, - div( - class = "well", - style = "min-height: 100%; height: 100%; overflow-y: - visible", - shiny::fluidRow( - # Benchmarking dropdown selection --------------------- - shiny::column( - width = 12, - shiny::selectizeInput("selectBenchLAs", - "Select benchmark local authorities", - choices = choices_las$area_name, - multiple = TRUE, - options = list(maxItems = 3) - ) - ) - ) - ), - # Benchmarking table -------------------------------------- - DT::dataTableOutput("tabBenchmark") - ) - ) - ) - ) - ) - ) - ) - ) - ) -} diff --git a/R/ui_panels/la_level_panel.R b/R/ui_panels/la_level_panel.R new file mode 100644 index 00000000..fcbe16ff --- /dev/null +++ b/R/ui_panels/la_level_panel.R @@ -0,0 +1,22 @@ +la_level_panel <- function() { + bslib::nav_panel( + "la_level", + PageHeaderUI("la_header"), + appInputsUI("la_inputs"), + LA_LevelTableUI("la_table"), + LA_StatsTableUI("la_stats"), + div( + class = "well", + style = "overflow-y: visible;", + role = "presentation", + `aria-label` = "Line and bar charts showing data from the first table including the + selected Local Authority, Region, Statistical Neighbour and England.", + bslib::navset_card_tab( + id = "la_charts", + LA_LineChartUI("la_line_chart"), + LA_BarChartUI("la_bar_chart") + ) + ), + LA_LevelMetaUI("la_meta") + ) +} diff --git a/R/ui_panels/region_level_panel.R b/R/ui_panels/region_level_panel.R new file mode 100644 index 00000000..816a14b0 --- /dev/null +++ b/R/ui_panels/region_level_panel.R @@ -0,0 +1,21 @@ +region_level_panel <- function() { + bslib::nav_panel( + "regional_level", + PageHeaderUI("region_header"), + appInputsUI("region_inputs"), + RegionLevel_TableUI("region_tables"), + div( + class = "well", + style = "overflow-y: visible;", + `aria-hidden` = "true", + bslib::navset_card_tab( + id = "region_charts", + Region_FocusLineChartUI("region_focus_line"), + Region_MultiLineChartUI("region_multi_line"), + Region_FocusBarChartUI("region_focus_bar"), + Region_MultiBarChartUI("region_multi_bar") + ) + ), + LA_LevelMetaUI("region_meta") + ) +} diff --git a/R/ui_panels/stat_n_level_panel.R b/R/ui_panels/stat_n_level_panel.R new file mode 100644 index 00000000..8a8d69a6 --- /dev/null +++ b/R/ui_panels/stat_n_level_panel.R @@ -0,0 +1,27 @@ +stat_n_level_panel <- function() { + bslib::nav_panel( + "statistical_neighbour_level", + PageHeaderUI("stat_n_header"), + appInputsUI("stat_n_inputs"), + StatN_TablesUI("stat_n_tables"), + div( + class = "well", + style = "overflow-y: visible;", + role = "region", + `aria-describedby` = "charts-description", + div( + id = "charts-description", + "This section contains line and bar charts created from the data in the above tables. + The selected Local Authority is compared against its statistical neighbours across different years." + ), + bslib::navset_card_tab( + id = "stat_n_charts", + StatN_FocusLineChartUI("stat_n_focus_line"), + StatN_MultiLineChartUI("stat_n_multi_line"), + StatN_FocusBarChartUI("stat_n_focus_bar"), + StatN_MultiBarChartUI("stat_n_multi_bar") + ) + ), + LA_LevelMetaUI("stat_n_meta") + ) +} diff --git a/R/ui_panels/support_panel.R b/R/ui_panels/support_panel.R new file mode 100644 index 00000000..64eb056f --- /dev/null +++ b/R/ui_panels/support_panel.R @@ -0,0 +1,49 @@ +support_panel <- function() { + bslib::nav_panel( + value = "support", + # Add in back link + actionLink( + class = "govuk-back-link", + style = "margin-top: 0.2rem; margin-bottom: 1.2rem;", + "support_to_dashboard", + "Back to dashboard" + ), + title = shiny::HTML("Support and feedback
(Feedback form)"), + dfeshiny::support_panel( + team_email = "Darlington.BRIDGE@education.gov.uk", + repo_name = "https://github.com/dfe-analytical-services/local-authority-interactive-tool", + ees_publication = FALSE, + alt_href = "https://www.gov.uk/government/publications/local-authority-interactive-tool-lait", + form_url = "https://forms.office.com/e/gTNw1EBgsn", + custom_data_info = HTML( + paste0( + "The full dataset is available in the ", + dfeshiny::external_link( + href = paste0( + "https://github.com/dfe-analytical-services/", + "local-authority-interactive-tool/tree/main/01_data/02_prod" + ), + link_text = "data directory of the LAIT GitHub repository", + add_warning = TRUE + ) + ), + ". The files beginning with 'bds_long' store the main dataset for the tool. ", + "You will also find several other datasets here. ", + "These help build the tool, feel free to check them out." + ), + extra_text = c( + section_tags( + heading = "Heading", + body = shiny::tagList( + "Please email results to ", + external_link( + href = paste0("mailto:", "team@education.gov.uk"), + link_text = "team@education.gov.uk", + add_warning = FALSE + ) + ) + ) + ) + ) + ) +} diff --git a/README.md b/README.md index b5de92a1..c3919c3e 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ You should also run `lintr::lint_dir()` regularly as lintr will check all pull r We welcome all suggestions and contributions to this template, and recommend [raising an issue in GitHub](https://github.com/dfe-analytical-services/local-authority-interactive-tool/issues/new/choose) to start discussions around potential additions or changes with the maintaining team. -Get in contact with jake.tufts@education.gov.uk to discuss contributions outside of GitHub. +Get in contact with jake.tufts@education.gov.uk (app designer) to discuss contributions outside of GitHub. ### Flagging issues @@ -153,6 +153,6 @@ Include as much detail on why you're making the suggestion and any thinking towa -Email app designer, Jake Tufts: jake.tufts@education.gov.uk +Email app owners, VCU Data team (Regions Group - Data Analysis Unit): Darlington.BRIDGE@education.gov.uk Email the Explore Education Statistics team: explore.statistics@education.gov.uk diff --git a/global.R b/global.R index ab8ad88c..0e55680b 100644 --- a/global.R +++ b/global.R @@ -68,18 +68,12 @@ lapply(list.files(here::here("R/ui_panels/"), full.names = TRUE), source) # Set admin global variables ================================================== site_title <- "Local Authority Interactive Tool (LAIT)" # name of app -parent_pub_name <- "LAIT publication" # name of source publication +parent_pub_name <- "LAIT GitHub repository (files named bds_long)" # link to source publication parent_publication <- "https://www.gov.uk/government/publications/local-authority-interactive-tool-lait" # Set the URLs that the site will be published to site_primary <- "https://department-for-education.shinyapps.io/local-authority-interactive-tool/" -site_overflow <- "https://department-for-education.shinyapps.io/local-authority-interactive-tool-overflow/" - -# Combine URLs into list for disconnect function -# We can add further mirrors where necessary. Each one can generally handle -# about 2,500 users simultaneously -sites_list <- c(site_primary, site_overflow) # Set the key for Google Analytics tracking google_analytics_key <- "Z967JJVQQX" diff --git a/renv.lock b/renv.lock index 16a3f89d..2eac6c26 100644 --- a/renv.lock +++ b/renv.lock @@ -16,23 +16,6 @@ "Repository": "CRAN", "Hash": "85bf3bd8fa58da21a22d84fd4f4ef0a8" }, - "DT": { - "Package": "DT", - "Version": "0.33", - "Source": "Repository", - "Repository": "RSPM", - "Requirements": [ - "crosstalk", - "htmltools", - "htmlwidgets", - "httpuv", - "jquerylib", - "jsonlite", - "magrittr", - "promises" - ], - "Hash": "64ff3427f559ce3f2597a4fe13255cb6" - }, "MASS": { "Package": "MASS", "Version": "7.3-61", @@ -482,19 +465,6 @@ ], "Hash": "09fd631e607a236f8cc7f9604db32cb8" }, - "crosstalk": { - "Package": "crosstalk", - "Version": "1.2.1", - "Source": "Repository", - "Repository": "CRAN", - "Requirements": [ - "R6", - "htmltools", - "jsonlite", - "lazyeval" - ], - "Hash": "ab12c7b080a57475248a30f4db6298c0" - }, "curl": { "Package": "curl", "Version": "6.0.1", @@ -595,14 +565,13 @@ }, "dfeshiny": { "Package": "dfeshiny", - "Version": "0.5.1", + "Version": "0.5.2", "Source": "GitHub", "RemoteType": "github", "RemoteHost": "api.github.com", - "RemoteUsername": "dfe-analytical-services", "RemoteRepo": "dfeshiny", - "RemoteRef": "bff6fef6be5049c7a4a41b350244dba6320ecd7c", - "RemoteSha": "bff6fef6be5049c7a4a41b350244dba6320ecd7c", + "RemoteUsername": "dfe-analytical-services", + "RemoteSha": "91f1eb3cbac6e1d2ba64300e22a36c95a73dda25", "Requirements": [ "R", "RCurl", @@ -616,7 +585,7 @@ "stringr", "styler" ], - "Hash": "5d235ed45e2b4c8a6892f157c3117b04" + "Hash": "e85991bf935df775e1caae6d0367f8d3" }, "diffobj": { "Package": "diffobj", @@ -1169,14 +1138,14 @@ }, "later": { "Package": "later", - "Version": "1.4.0", + "Version": "1.4.1", "Source": "Repository", - "Repository": "CRAN", + "Repository": "RSPM", "Requirements": [ "Rcpp", "rlang" ], - "Hash": "dd8a8b6833989ba10fba1bf1ee7d3860" + "Hash": "501744395cac0bab0fbcfab9375ae92c" }, "lattice": { "Package": "lattice", @@ -1550,7 +1519,7 @@ }, "promises": { "Package": "promises", - "Version": "1.3.0", + "Version": "1.3.2", "Source": "Repository", "Repository": "RSPM", "Requirements": [ @@ -1562,7 +1531,7 @@ "rlang", "stats" ], - "Hash": "434cd5388a3979e74be5c219bcd6e77d" + "Hash": "c84fd4f75ea1f5434735e08b7f50fbca" }, "ps": { "Package": "ps", @@ -1974,20 +1943,6 @@ ], "Hash": "2b45a467a30d6a88a1892a738c0900cf" }, - "shinydashboard": { - "Package": "shinydashboard", - "Version": "0.7.2", - "Source": "Repository", - "Repository": "CRAN", - "Requirements": [ - "R", - "htmltools", - "promises", - "shiny", - "utils" - ], - "Hash": "e418b532e9bb4eb22a714b9a9f1acee7" - }, "shinyjs": { "Package": "shinyjs", "Version": "2.1.0", diff --git a/server.R b/server.R index b555ec3c..900a4754 100644 --- a/server.R +++ b/server.R @@ -18,6 +18,55 @@ # # ----------------------------------------------------------------------------- server <- function(input, output, session) { + # Navigation ================================================================ + ## Main content left navigation --------------------------------------------- + observeEvent(input$la_level, { + bslib::nav_select("left_nav", selected = "la_level") + }) + observeEvent(input$regional_level, { + bslib::nav_select("left_nav", selected = "regional_level") + }) + observeEvent(input$statistical_neighbour_level, { + bslib::nav_select("left_nav", selected = "statistical_neighbour_level") + }) + observeEvent(input$all_la_level, { + bslib::nav_select("left_nav", selected = "all_la_level") + }) + observeEvent(input$create_your_own, { + bslib::nav_select("left_nav", selected = "create_your_own") + }) + observeEvent(input$user_guide, { + bslib::nav_select("left_nav", selected = "user_guide") + }) + observeEvent(input$information_page, { + bslib::nav_select("left_nav", selected = "information_page") + }) + + ## Footer links ------------------------------------------------------------- + observeEvent(input$dashboard, { + bslib::nav_select("pages", "dashboard") + }) + observeEvent(input$support, { + bslib::nav_select("pages", "support") + }) + observeEvent(input$accessibility_statement, { + bslib::nav_select("pages", "accessibility_statement") + }) + observeEvent(input$cookies_information, { + bslib::nav_select("pages", "cookies_information") + }) + + ## Back links to main dashboard --------------------------------------------- + observeEvent(input$support_to_dashboard, { + bslib::nav_select("pages", "dashboard") + }) + observeEvent(input$cookies_to_dashboard, { + bslib::nav_select("pages", "dashboard") + }) + observeEvent(input$accessibility_to_dashboard, { + bslib::nav_select("pages", "dashboard") + }) + # Bookmarking =============================================================== # This uses bookmarking to store input choices in the url. # All inputs are excluded by default, and inputs can be added explicitly @@ -33,7 +82,8 @@ server <- function(input, output, session) { "stat_n_inputs-indicator_name", "all_la_inputs-la_name", "all_la_inputs-indicator_name", - "navsetpillslist", + "pages", + "left_nav", "create_inputs-geog_input", "create_inputs-indicator", "create_inputs-la_group", @@ -64,43 +114,70 @@ server <- function(input, output, session) { }) - # Dynamically changes window title to be LAIT - page - LA - indicator - # (Selected by user) + # Update title ============================================================== + # This changes the title based on the tab selections and is important for accessibility + # If on the main dashboard it uses the active tab from left_nav, else it uses the page input + # Define the lookup vector for titles + nav_titles <- c( + "la_level" = "LA Level", + "regional_level" = "Regional Level", + "statistical_neighbour_level" = "Statistical Neighbour Level", + "all_la_level" = "All LA Level", + "create_your_own" = "Create Your Own", + "user_guide" = "User Guide", + "information_page" = "Information Page", + "support" = "Support and Feedback", + "accessibility_statement" = "Accessibility Statement", + "cookies_information" = "Cookies Information", + "dashboard" = "Dashboard" + ) + shiny::observe({ - if (input$navsetpillslist %in% c("LA Level", "Regional Level")) { - shinytitle::change_window_title( - session, - paste0( - site_title, " - ", - input$navsetpillslist, ": ", - la_app_inputs$la(), ", ", - la_app_inputs$indicator() + if (input$pages == "dashboard") { + if (input$left_nav %in% c( + "la_level", "regional_level", + "statistical_neighbour_level", "all_la_level" + )) { + shinytitle::change_window_title( + title = paste0( + site_title, + " - ", + nav_titles[input$left_nav], + ": ", + la_app_inputs$la(), + ", ", + la_app_inputs$indicator() + ) ) - ) + } else { + shinytitle::change_window_title( + title = paste0( + site_title, " - ", + nav_titles[input$left_nav] + ) + ) + } } else { shinytitle::change_window_title( - session, - paste0( + title = paste0( site_title, " - ", - input$navsetpillslist + nav_titles[input$pages] ) ) } }) # Cookies logic ============================================================= - output$cookie_status <- dfeshiny::cookies_banner_server( - "cookie-banner", + output$cookies_status <- cookies_banner_server_jt( input_cookies = shiny::reactive(input$cookies), parent_session = session, google_analytics_key = google_analytics_key, - cookies_link_panel = "cookies_panel_ui" + cookies_link_panel = "cookies_information" ) dfeshiny::cookies_panel_server( - id = "cookie-panel", input_cookies = shiny::reactive(input$cookies), - google_analytics_key = google_analytics_key + google_analytics_key = google_analytics_key, ) # =========================================================================== diff --git a/tests/testthat/_snaps/windows-4.4/UI-mod_la_lvl_table/la-charts-la_bar_chart.png b/tests/testthat/_snaps/windows-4.4/UI-mod_la_lvl_table/la-charts-la_bar_chart.png index 21cfff6625f473071521bb4e8399461cb40d0a4d..ff5809bb0807ee45ff750711988d74c75153f2cb 100644 GIT binary patch literal 23129 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 I)t`j_2R%=qQUCw| literal 23455 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 diff --git a/tests/testthat/_snaps/windows-4.4/UI-mod_la_lvl_table/la-charts-la_line_chart.png b/tests/testthat/_snaps/windows-4.4/UI-mod_la_lvl_table/la-charts-la_line_chart.png index 23881829a191d6b82bee5e47e338e35f952c1030..065f8ff5292db5f5d395e1dbd6e8c8d8b9c61d80 100644 GIT binary patch literal 25497 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 literal 25789 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 diff --git a/tests/testthat/test-UI-mod_la_lvl_table.R b/tests/testthat/test-UI-mod_la_lvl_table.R index d901958b..7ea1d6dc 100644 --- a/tests/testthat/test-UI-mod_la_lvl_table.R +++ b/tests/testthat/test-UI-mod_la_lvl_table.R @@ -159,11 +159,13 @@ test_that("Check LA charts behave as expected", { grepl("Average point score per entry A Level Cohort", cleaned_plot_str) ) + # nolint start: commented_code # Check visual of line chart - app$expect_screenshot( - selector = "#la_line_chart-line_chart", - name = "la_line_chart" - ) + # app$expect_screenshot( + # selector = "#la_line_chart-line_chart", + # name = "la_line_chart" + # ) + # nolint end # Change to different topic app$set_inputs( @@ -209,11 +211,13 @@ test_that("Check LA charts behave as expected", { ) ) + # nolint start: commented_code # Check visual of bar chart - app$expect_screenshot( - selector = "#la_bar_chart-bar_chart", - name = "la_bar_chart" - ) + # app$expect_screenshot( + # selector = "#la_bar_chart-bar_chart", + # name = "la_bar_chart" + # ) + # nolint end app$stop() }) diff --git a/ui.R b/ui.R index becea833..e8fb7206 100644 --- a/ui.R +++ b/ui.R @@ -20,9 +20,8 @@ # ----------------------------------------------------------------------------- ui <- function(input, output, session) { bslib::page_fillable( - # Set application metadata ------------------------------------------------ - tags$head(HTML("Local Authority Interactive Tool (LAIT)")), + tags$head(HTML(paste0("", site_title, ""))), tags$head(tags$link(rel = "shortcut icon", href = "dfefavicon.png")), tags$head(includeHTML(("google-analytics.html"))), shinytitle::use_shiny_title(), @@ -30,7 +29,7 @@ ui <- function(input, output, session) { # Add meta description for search engines metathis::meta() |> metathis::meta_general( - application_name = "Local Authority Interactive Tool (LAIT)", + application_name = site_title, description = "Local Authority Interactive Tool (LAIT)", robots = "index,follow", generator = "R-Shiny", @@ -42,9 +41,8 @@ ui <- function(input, output, session) { # Custom disconnect function ---------------------------------------------- # Variables used here are set in the global.R file dfeshiny::custom_disconnect_message( - links = sites_list, - publication_name = parent_pub_name, - publication_link = parent_publication + links = site_primary, + dashboard_title = site_title ), # Styling with CSS @@ -53,7 +51,6 @@ ui <- function(input, output, session) { gap = 0, # Load javascript dependencies -------------------------------------------- - shinyWidgets::useShinydashboard(), shinyjs::useShinyjs(), tags$head(htmltools::includeScript("www/custom_js.js")), reactable.extras::reactable_extras_dependency(), @@ -64,19 +61,11 @@ ui <- function(input, output, session) { # https://book.javascript-for-r.com/shiny-cookies.html dfeshiny::dfe_cookies_script(), dfeshiny::cookies_banner_ui( - "cookie-banner", - "Local Authority Interactive Tool (LAIT)" + name = site_title ), # Header ------------------------------------------------------------------ - shinyGovstyle::header( - main_text = "", - main_link = "https://www.gov.uk/government/organisations/department-for-education", - secondary_text = "Local Authority Interactive Tool (LAIT)", - logo = "images/DfE_logo_landscape.png", - logo_width = 150, - logo_height = 32 - ), + dfeshiny::header(site_title), # Beta banner ------------------------------------------------------------- shiny::tagList( @@ -96,241 +85,77 @@ ui <- function(input, output, session) { }, # Start of app ============================================================ + # Define the main layout with hidden navigation + shinyGovstyle::gov_main_layout( + bslib::navset_hidden( + id = "pages", + # Main dashboard content + bslib::nav_panel( + "dashboard", + bslib::layout_columns( + col_widths = bslib::breakpoints(sm = c(2, 10), md = c(2, 10), lg = c(2, 10)), + + # Left navigation + dfe_contents_links( + links_list = c( + "LA Level", + "Regional Level", + "Statistical Neighbour Level", + "All LA Level", + "Create Your Own", + "User Guide", + "Information Page" + ) + ), - # Nav panels -------------------------------------------------------------- - bslib::navset_pill_list( - "", - id = "navsetpillslist", - widths = c(2, 10), - well = FALSE, - - # ======================================================================= - # LA Level Page - # ======================================================================= - bslib::nav_panel( - shiny::hr(class = "mobile-only-hr"), - title = "LA Level", - value = "LA Level", - - # Tab header ========================================================== - PageHeaderUI("la_header"), - - # User Inputs ========================================================= - appInputsUI("la_inputs"), - - # LA Tables =========================================================== - # Main table - LA_LevelTableUI("la_table"), - - # Stats table - LA_StatsTableUI("la_stats"), - - # LA Charts =========================================================== - div( - class = "well", - style = "overflow-y: visible;", - bslib::navset_card_underline( - id = "la_charts", - LA_LineChartUI("la_line_chart"), - LA_BarChartUI("la_bar_chart") - ) - ), - - # LA Metadata ========================================================= - LA_LevelMetaUI("la_meta") - ), - - # ======================================================================= - # Regional Level Page - # ======================================================================= - bslib::nav_panel( - shiny::hr(class = "mobile-only-hr"), - title = "Regional Level", - value = "Regional Level", - - # Tab header ========================================================== - PageHeaderUI("region_header"), - - # User Inputs ========================================================= - appInputsUI("region_inputs"), - - # Region tables ======================================================= - RegionLevel_TableUI("region_tables"), - - # Region charts ======================================================= - div( - class = "well", - style = "overflow-y: visible;", - bslib::navset_card_underline( - id = "region_charts", - Region_FocusLineChartUI("region_focus_line"), - Region_MultiLineChartUI("region_multi_line"), - Region_FocusBarChartUI("region_focus_bar"), - Region_MultiBarChartUI("region_multi_bar") - ) - ), - - # Region Metadata ===================================================== - LA_LevelMetaUI("region_meta") - ), - - # ======================================================================= - # Statistical Neighbour Level Page - # ======================================================================= - bslib::nav_panel( - shiny::hr(class = "mobile-only-hr"), - title = "Statistical Neighbour Level", - value = "Statistical Neighbour Level", - - # Tab header ========================================================== - PageHeaderUI("stat_n_header"), - - # User Inputs ========================================================= - appInputsUI("stat_n_inputs"), - - # Statistical Neighbour tables ======================================== - StatN_TablesUI("stat_n_tables"), - - # Statistical Neighbour charts ======================================== - div( - class = "well", - style = "overflow-y: visible;", - bslib::navset_card_underline( - id = "stat_n_charts", - StatN_FocusLineChartUI("stat_n_focus_line"), - StatN_MultiLineChartUI("stat_n_multi_line"), - StatN_FocusBarChartUI("stat_n_focus_bar"), - StatN_MultiBarChartUI("stat_n_multi_bar") - ) - ), - - # Statistical Neighbour Metadata ====================================== - LA_LevelMetaUI("stat_n_meta") - ), - - # ======================================================================= - # All LA Level Page - # ======================================================================= - bslib::nav_panel( - shiny::hr(class = "mobile-only-hr"), - title = "All LA Level", - value = "All LA Level", - - # Tab header ========================================================== - PageHeaderUI("all_la_header"), - - # User Inputs ========================================================= - appInputsUI("all_la_inputs"), - - # All LA Tables ======================================================= - AllLA_TableUI("all_la_table"), - - # LA Metadata ========================================================= - LA_LevelMetaUI("all_la_meta") - ), - - # ======================================================================= - # Create Your Own Page - # ======================================================================= - bslib::nav_panel( - title = "Create Your Own", - value = "Create Your Own", - # Full dataset notification banner - full_data_on_github_noti(), - # User Inputs ========================================================= - div( - class = "well", - style = "overflow-y: visible; padding: 1rem;", - bslib::layout_column_wrap( - Create_MainInputsUI("create_inputs")["Main choices"], - ), - bslib::layout_column_wrap( - Create_MainInputsUI("create_inputs")["LA grouping"], - Create_MainInputsUI("create_inputs")["Other grouping"], - YearRangeUI("year_range"), - Create_MainInputsUI("create_inputs")["Clear all current selections"] + # Hidden dashboard panels + bslib::navset_hidden( + id = "left_nav", + # LA Level + la_level_panel(), + # Regional Level + region_level_panel(), + # Statistical Neighbour Level + stat_n_level_panel(), + # All LA Level + all_la_level_panel(), + # Create Your Own + create_your_own_panel(), + # User Guide + bslib::nav_panel("user_guide", user_guide_panel()), + # Info Page + bslib::nav_panel("information_page", info_page_panel()) + ) ) ), - - # Tables ============================================================== - # Staging table & Add selections btn ---------------------------------- - StagingTableUI("staging_table"), - # Query table --------------------------------------------------------- - QueryTableUI("query_table"), - # Create own table ---------------------------------------------------- - CreateOwnTableUI("create_own_table"), - # Charts ============================================================== - div( - class = "well", - style = "overflow-y: visible;", - h3( - "Output Charts", - create_tooltip_icon("Charts showing data from all the saved selections") + # Footer pages + support_panel(), + bslib::nav_panel("accessibility_statement", a11y_panel()), + bslib::nav_panel( + value = "cookies_information", + title = "Cookies", + # Add backlink + actionLink( + class = "govuk-back-link", + style = "margin-top: 0.2rem; margin-bottom: 1.2rem;", + "cookies_to_dashboard", + "Back to dashboard" ), - p("Note a maximum of 4 geographies and 3 indicators can be shown."), - bslib::navset_tab( - # Line chart ------------------------------------------------------ - CreateOwnLineChartUI("create_own_line"), - # Bar chart ------------------------------------------------------ - CreateOwnBarChartUI("create_own_bar") - ) + dfeshiny::cookies_panel_ui(google_analytics_key = google_analytics_key) ) - ), - - # ======================================================================= - # User guide - # ======================================================================= - user_guide_panel(), - - # ======================================================================= - # Information pages - # ======================================================================= - info_page_panel(), - - # ======================================================================= - # Accessibility - # ======================================================================= - a11y_panel(), - - # ======================================================================= - # Support and feedback - # ======================================================================= - bslib::nav_panel( - value = "support_panel", - shinyGovstyle::banner( - "beta banner", - "beta", - paste0( - "This page is in beta phase and we are still reviewing the content. - We are aware the links in Find more information on the data - section are currently incorrect. Please see the ", - dfeshiny::external_link( - href = parent_publication, - link_text = "LAIT website" - ), - " for more information." - ) - ), - shiny::br(), - title = shiny::HTML("Support and feedback
(Feedback form)"), - dfeshiny::support_panel( - team_email = "jake.tufts@education.gov.uk", - repo_name = "https://github.com/dfe-analytical-services/local-authority-interactive-tool", - form_url = "https://forms.office.com/e/gTNw1EBgsn" - ) - ), - - # ======================================================================= - # Cookies info - # ======================================================================= - bslib::nav_panel( - value = "cookies_panel_ui", - title = "Cookies", - dfeshiny::cookies_panel_ui(google_analytics_key = google_analytics_key) ) ), - - # Footer ================================================================== - shinyGovstyle::footer(full = TRUE) + tags$div( + style = "postion: relative; text-align: center; margin-bottom: 50px;", + tags$a(href = "#top", "Go to the top of the page") + ), + # Footer + dfe_footer( + links_list = c( + "Support", + "Accessibility Statement", + "Cookies Information" + ) + ) ) } diff --git a/www/cookie-consent.js b/www/cookie-consent.js index 3bdf02c0..dcab4c6a 100644 --- a/www/cookie-consent.js +++ b/www/cookie-consent.js @@ -1,26 +1,24 @@ -function getCookies(){ - var res = Cookies.get(); - Shiny.setInputValue('cookies', res); -} - -Shiny.addCustomMessageHandler('cookie-set', function(msg){ - Cookies.set(msg.name, msg.value); - getCookies(); -}) - -Shiny.addCustomMessageHandler('cookie-remove', function(msg){ - Cookies.remove(msg.name); - getCookies(); -}) - -$(document).on('shiny:connected', function(ev){ - getCookies(); -}) - -Shiny.addCustomMessageHandler('analytics-consent', function(msg){ - gtag('consent', 'update', { - 'analytics_storage': msg.value - }); -}) - - +function getCookies(){ + var res = Cookies.get(); + Shiny.setInputValue('cookies', res); +} + +Shiny.addCustomMessageHandler('cookie-set', function(msg){ + Cookies.set(msg.name, msg.value); + getCookies(); +}) + +Shiny.addCustomMessageHandler('cookie-clear', function(msg){ + Cookies.remove(msg.name); + getCookies(); +}) + +$(document).on('shiny:connected', function(ev){ + getCookies(); +}) + +Shiny.addCustomMessageHandler('analytics-consent', function(msg){ + gtag('consent', 'update', { + 'analytics_storage': msg.value + }); +}) diff --git a/www/dfe_shiny_gov_style.css b/www/dfe_shiny_gov_style.css index 217571f5..8d92a37c 100644 --- a/www/dfe_shiny_gov_style.css +++ b/www/dfe_shiny_gov_style.css @@ -106,6 +106,7 @@ a { text-underline-offset: .1578em; word-break: break-word; background-color: transparent; + color: #1d70b8; } a:visited { @@ -215,47 +216,6 @@ html { /* Card tabs CSS ----------------------------------------------------------- */ /* TODO - fix the borders and margins on the tabs */ -/* Custom disconnect CSS --------------------------------------------------- */ -#ss-connect-dialog { - display: none !important; -} - -#shiny-disconnected-overlay { - display: none !important; -} - -#ss-overlay { - background-color: #000000 !important; - opacity: 0.6 !important; - position: fixed !important; - top: 0 !important; - left: 0 !important; - bottom: 0 !important; - right: 0 !important; - z-index: 99998 !important; - overflow: hidden !important; - cursor: not-allowed !important; -} - -#custom-disconnect-dialog { - background: #000000 !important; - color: #FFFFFF !important; - width: full !important; - transform: translateX(-50%) translateY(-50%) !important; - top: 50% !important; - position: fixed !important; - bottom: auto !important; - font-size: 1.188rem !important; - left: 50% !important; - padding: 0.8em 1.5em !important; - text-align: center !important; - height: auto !important; - opacity: 1 !important; - z-index: 99999 !important; - border-radius: 0.188rem !important; - box-shadow: rgba(0, 0, 0, 0.3) 0.188rem 0.188rem 0.625rem !important; -} - /* GOV.UK button copied in from shiny gov style ---------------------------- */ .gov-uk-button { font-family: "Helvetica Neue", "Arial", sans-serif; @@ -983,12 +943,41 @@ screen and (forced-colors:active) { } -/* Custom style for the News tag */ +/* Custom style for the News banner */ #update-msg-banner .govuk-tag { color: #6e3619; background-color: #fcd6c3 } +/* Remove border for Beta banner when also New banner */ #beta-banner-no-border { border-bottom: none; } + +/* Make tab names not bold */ +.nav-link, .nav-tabs>li>a, .nav-pills>li>a, :where(ul.nav.navbar-nav > li)>a { + font-weight: normal; +} + +/* Consisten text styling */ +/* Year range input */ +.filter-option-inner-inner { + font-size: 1.188rem; +} + +/* Add selections button */ +#create_inputs-add_query { + font-size: 1.188rem; + font-weight: normal; + width: 90%; +} + +/* Setting clear all selections button same width as add selections */ +#create_inputs-clear_all { + width: 90%; +} + +/* Adding a margin to the bottom of the Create Your Own noti banner */ +#full_data_on_github { + margin: 1rem !important; +}