This article provides a complete Shiny application that uses brightspaceR to build an interactive LMS analytics dashboard. The app lets users explore enrollments, grades, and course activity through point-and-click filters.
Prerequisites
install.packages(c("shiny", "bslib", "DT"))
# brightspaceR must be installed and authenticated:
# bs_auth()The complete app
Save the code below as app.R and run with
shiny::runApp().
library(shiny)
library(bslib)
library(DT)
library(dplyr)
library(ggplot2)
library(lubridate)
library(brightspaceR)
# ── Data loading ──────────────────────────────────────────────────────────────
# Load once at startup. In production, wrap in a reactive timer to refresh
# periodically.
message("Loading Brightspace data...")
users <- bs_get_dataset("Users")
enrollments <- bs_get_dataset("User Enrollments")
org_units <- bs_get_dataset("Org Units")
roles <- bs_get_dataset("Role Details")
grades <- bs_get_dataset("Grade Results")
grade_objects <- bs_get_dataset("Grade Objects")
# Pre-join common combinations
enrollment_detail <- enrollments |>
bs_join_enrollments_roles(roles) |>
bs_join_enrollments_orgunits(org_units)
grade_detail <- grades |>
bs_join_grades_objects(grade_objects)
message("Data loaded.")
# ── UI ────────────────────────────────────────────────────────────────────────
ui <- page_sidebar(
title = "brightspaceR LMS Explorer",
theme = bs_theme(
preset = "shiny",
primary = "#f59e0b",
"navbar-bg" = "#1a1a2e"
),
sidebar = sidebar(
width = 280,
title = "Filters",
selectInput("role_filter", "Role",
choices = c("All", sort(unique(enrollment_detail$role_name))),
selected = "All"
),
selectInput("course_filter", "Course",
choices = c("All", sort(unique(
org_units$name[org_units$type == "Course Offering"]
))),
selected = "All"
),
dateRangeInput("date_range", "Enrollment Date",
start = Sys.Date() - 365,
end = Sys.Date()
),
hr(),
actionButton("refresh", "Refresh Data", class = "btn-outline-primary btn-sm")
),
# KPI cards
layout_columns(
col_widths = c(3, 3, 3, 3),
value_box(
title = "Total Users", value = textOutput("kpi_users"),
showcase = icon("users"), theme = "primary"
),
value_box(
title = "Enrollments", value = textOutput("kpi_enrollments"),
showcase = icon("graduation-cap"), theme = "info"
),
value_box(
title = "Courses", value = textOutput("kpi_courses"),
showcase = icon("book"), theme = "success"
),
value_box(
title = "Avg Grade", value = textOutput("kpi_grade"),
showcase = icon("chart-line"), theme = "warning"
)
),
# Charts row
layout_columns(
col_widths = c(6, 6),
card(
card_header("Enrollments by Role"),
plotOutput("role_chart", height = "300px")
),
card(
card_header("Monthly Enrollment Trend"),
plotOutput("trend_chart", height = "300px")
)
),
# Second charts row
layout_columns(
col_widths = c(6, 6),
card(
card_header("Grade Distribution"),
plotOutput("grade_chart", height = "300px")
),
card(
card_header("Top 10 Courses"),
plotOutput("course_chart", height = "300px")
)
),
# Data table
card(
card_header("Enrollment Detail"),
DTOutput("enrollment_table")
)
)
# ── Server ────────────────────────────────────────────────────────────────────
server <- function(input, output, session) {
# Filtered enrollment data
filtered_enrollments <- reactive({
df <- enrollment_detail
if (input$role_filter != "All") {
df <- df |> filter(role_name == input$role_filter)
}
if (input$course_filter != "All") {
df <- df |> filter(name == input$course_filter)
}
if (!is.null(input$date_range)) {
df <- df |> filter(
as.Date(enrollment_date) >= input$date_range[1],
as.Date(enrollment_date) <= input$date_range[2]
)
}
df
})
# Filtered grades
filtered_grades <- reactive({
df <- grade_detail |>
filter(!is.na(points_numerator), points_numerator >= 0)
if (input$course_filter != "All") {
course_ids <- org_units |>
filter(name == input$course_filter) |>
pull(org_unit_id)
df <- df |> filter(org_unit_id %in% course_ids)
}
df
})
# ── KPIs ──
output$kpi_users <- renderText({
format(nrow(users), big.mark = ",")
})
output$kpi_enrollments <- renderText({
format(nrow(filtered_enrollments()), big.mark = ",")
})
output$kpi_courses <- renderText({
n <- filtered_enrollments() |>
filter(type == "Course Offering") |>
distinct(org_unit_id) |>
nrow()
format(n, big.mark = ",")
})
output$kpi_grade <- renderText({
g <- filtered_grades()
if (nrow(g) == 0) return("--")
paste0(round(mean(g$points_numerator, na.rm = TRUE), 1), "%")
})
# ── Charts ──
chart_theme <- theme_minimal(base_size = 13) +
theme(
plot.background = element_rect(fill = "white", colour = NA),
panel.grid.minor = element_blank()
)
output$role_chart <- renderPlot({
filtered_enrollments() |>
count(role_name, sort = TRUE) |>
head(8) |>
ggplot(aes(x = reorder(role_name, n), y = n, fill = role_name)) +
geom_col(show.legend = FALSE) +
coord_flip() +
scale_fill_brewer(palette = "Set2") +
labs(x = NULL, y = "Count") +
chart_theme
})
output$trend_chart <- renderPlot({
filtered_enrollments() |>
mutate(month = floor_date(as.Date(enrollment_date), "month")) |>
count(month) |>
ggplot(aes(x = month, y = n)) +
geom_line(colour = "#818cf8", linewidth = 1) +
geom_point(colour = "#818cf8", size = 2) +
scale_x_date(date_labels = "%b %Y") +
labs(x = NULL, y = "New Enrollments") +
chart_theme
})
output$grade_chart <- renderPlot({
filtered_grades() |>
ggplot(aes(x = points_numerator)) +
geom_histogram(binwidth = 5, fill = "#38bdf8", colour = "white") +
labs(x = "Grade (%)", y = "Count") +
chart_theme
})
output$course_chart <- renderPlot({
filtered_enrollments() |>
filter(type == "Course Offering") |>
count(name, sort = TRUE) |>
head(10) |>
ggplot(aes(x = reorder(name, n), y = n)) +
geom_col(fill = "#f59e0b") +
coord_flip() +
labs(x = NULL, y = "Enrollments") +
chart_theme
})
# ── Data table ──
output$enrollment_table <- renderDT({
filtered_enrollments() |>
select(any_of(c(
"user_id", "role_name", "name", "type",
"enrollment_date"
))) |>
head(500)
}, options = list(pageLength = 15, scrollX = TRUE))
# ── Refresh button ──
observeEvent(input$refresh, {
showNotification("Refreshing data...", type = "message")
# In production, re-fetch from Brightspace here
})
}
shinyApp(ui, server)How it works
Data loading
The app loads six BDS datasets at startup and pre-joins them into two working tables:
-
enrollment_detail: enrollments joined with roles and org units – gives each enrollment row a human-readable role name and course name. -
grade_detail: grade results joined with grade objects – adds grade item names and max points to each score.
This front-loads the expensive I/O so the reactive filters are fast.
Filtering
Three filters (role, course, date range) drive all charts and the
data table through a single filtered_enrollments()
reactive. Changing any filter instantly updates the full dashboard.
Extending the app
Adding authentication
For multi-user deployments, wrap the data loading in a reactive that authenticates per session:
# In server:
bs_data <- reactive({
# Each user needs their own token
bs_auth_token(session$userData$token)
list(
users = bs_get_dataset("Users"),
enrollments = bs_get_dataset("User Enrollments")
)
})Adding a download button
Let users export the filtered data as CSV:
# In UI, inside the enrollment_table card:
downloadButton("download_csv", "Export CSV")
# In server:
output$download_csv <- downloadHandler(
filename = function() {
paste0("enrollments_", Sys.Date(), ".csv")
},
content = function(file) {
readr::write_csv(filtered_enrollments(), file)
}
)Scheduled data refresh
For always-fresh data, use reactiveTimer() to
periodically re-fetch:
# Re-fetch every 30 minutes
auto_refresh <- reactiveTimer(30 * 60 * 1000)
live_enrollments <- reactive({
auto_refresh()
bs_get_dataset("User Enrollments")
})Deploying to Posit Connect / shinyapps.io
- Store credentials as environment variables on the server
- Use
bs_auth_refresh()with a long-lived refresh token instead of the interactive browser flow - Pin datasets with the
pinspackage for faster startup:
# Write once:
board <- pins::board_connect()
pins::pin_write(board, bs_get_dataset("Users"), "brightspace_users")
# Read in app:
users <- pins::pin_read(board, "brightspace_users")