diff --git a/lib/oli/accounts.ex b/lib/oli/accounts.ex
index 3decaf11d4..c0737d9b8d 100644
--- a/lib/oli/accounts.ex
+++ b/lib/oli/accounts.ex
@@ -21,6 +21,8 @@ defmodule Oli.Accounts do
alias Oli.Repo.{Paging, Sorting}
alias Oli.AccountLookupCache
alias PowEmailConfirmation.Ecto.Context, as: EmailConfirmationContext
+ alias Oli.Delivery.Sections.Enrollment
+ alias Lti_1p3.DataProviders.EctoProvider
def browse_users(
%Paging{limit: limit, offset: offset},
@@ -318,6 +320,29 @@ defmodule Oli.Accounts do
end
end
+ @doc """
+ Updates the context role for a specific enrollment.
+ """
+
+ def update_user_context_role(enrollment, role) do
+ context_role = EctoProvider.Marshaler.to([role])
+
+ res =
+ enrollment
+ |> Repo.preload([:context_roles])
+ |> Enrollment.changeset(%{})
+ |> Ecto.Changeset.put_assoc(:context_roles, context_role)
+ |> Repo.update()
+
+ case res do
+ {:ok, %Enrollment{}} ->
+ res
+
+ error ->
+ error
+ end
+ end
+
@doc """
Links a User to Author account
diff --git a/lib/oli/delivery/sections.ex b/lib/oli/delivery/sections.ex
index 9f9e9161a7..c0920f0bbc 100644
--- a/lib/oli/delivery/sections.ex
+++ b/lib/oli/delivery/sections.ex
@@ -197,6 +197,18 @@ defmodule Oli.Delivery.Sections do
)
end
+ @doc """
+ Get the user's role in a given section.
+ """
+
+ def get_user_role_from_enrollment(enrollment) do
+ enrollment
+ |> Repo.preload(:context_roles)
+ |> Map.get(:context_roles)
+ |> List.first()
+ |> Map.get(:id)
+ end
+
@doc """
Determines if a user is a platform (institution) instructor.
"""
diff --git a/lib/oli_web/components/delivery/actions/actions.ex b/lib/oli_web/components/delivery/actions/actions.ex
new file mode 100644
index 0000000000..a62c0a23fe
--- /dev/null
+++ b/lib/oli_web/components/delivery/actions/actions.ex
@@ -0,0 +1,119 @@
+defmodule OliWeb.Components.Delivery.Actions do
+ use Surface.LiveComponent
+ use OliWeb.Common.Modal
+
+ alias Lti_1p3.Tool.ContextRoles
+ alias Oli.Accounts
+ alias OliWeb.Common.Confirm
+ alias Phoenix.LiveView.JS
+
+ prop(enrollment_info, :map, required: true)
+ prop(section_slug, :string, required: true)
+ prop(user, :map, required: true)
+
+ data(enrollment, :map, default: %{})
+ data(user_role_data, :list, default: [])
+ data(user_role_id, :integer, default: nil)
+
+ @user_role_data [
+ %{id: 3, name: :instructor, title: "Instructor"},
+ %{id: 4, name: :student, title: "Student"}
+ ]
+
+ def update(
+ %{user: user, section_slug: section_slug, enrollment_info: enrollment_info} = _assigns,
+ socket
+ ) do
+ {:ok,
+ assign(socket,
+ enrollment: enrollment_info.enrollment,
+ section_slug: section_slug,
+ user: user,
+ user_role_id: enrollment_info.user_role_id,
+ user_role_data: @user_role_data
+ )}
+ end
+
+ def render(assigns) do
+ ~F"""
+
+
+
Actions
+
+
+
+
+ Change enrolled user role
+ Select the role to change for the user in this section.
+
+
+
+
+ """
+ end
+
+ def handle_event(
+ "change_user_role",
+ %{"filter_by_role_id" => filter_by_role_id},
+ socket
+ ) do
+ context_role =
+ case String.to_integer(filter_by_role_id) do
+ 3 -> ContextRoles.get_role(:context_instructor)
+ 4 -> ContextRoles.get_role(:context_learner)
+ end
+
+ Accounts.update_user_context_role(
+ socket.assigns.enrollment,
+ context_role
+ )
+
+ {:noreply,
+ socket
+ |> assign(user_role_id: String.to_integer(filter_by_role_id))
+ |> hide_modal(modal_assigns: nil)}
+ end
+
+ def handle_event("display_confirm_modal", %{"filter_by_role_id" => filter_by_role_id}, socket) do
+ modal_assigns = %{
+ title: "Change role",
+ id: "change_role_modal",
+ ok:
+ JS.push("change_user_role",
+ target: socket.assigns.myself,
+ value: %{"filter_by_role_id" => filter_by_role_id}
+ ),
+ cancel:
+ JS.push("cancel_confirm_modal",
+ target: socket.assigns.myself,
+ value: %{"previous_role_id" => socket.assigns.user_role_id}
+ )
+ }
+
+ %{given_name: given_name, family_name: family_name} = socket.assigns.user
+
+ modal = fn assigns ->
+ ~F"""
+
+ Are you sure you want to change user role to {given_name} {family_name}?
+
+ """
+ end
+
+ send(self(), {:show_modal, modal, modal_assigns})
+
+ {:noreply, assign(socket, user_role_id: filter_by_role_id)}
+ end
+
+ def handle_event("cancel_confirm_modal", %{"previous_role_id" => previous_role_id}, socket) do
+ send(self(), {:hide_modal})
+
+ {:noreply, assign(socket, user_role_id: previous_role_id)}
+ end
+end
diff --git a/lib/oli_web/live/delivery/student_dashboard/components/helpers.ex b/lib/oli_web/live/delivery/student_dashboard/components/helpers.ex
index a7cb8a13cb..b4a6c753b3 100644
--- a/lib/oli_web/live/delivery/student_dashboard/components/helpers.ex
+++ b/lib/oli_web/live/delivery/student_dashboard/components/helpers.ex
@@ -76,6 +76,7 @@ defmodule OliWeb.Delivery.StudentDashboard.Components.Helpers do
{"Learning Objectives", path_for(:learning_objectives, @section_slug, @student_id, @preview_mode), nil, is_active_tab?(:learning_objectives, @active_tab)},
{"Quiz Scores", path_for(:quizz_scores, @section_slug, @student_id, @preview_mode), nil, is_active_tab?(:quizz_scores, @active_tab)},
{"Progress", path_for(:progress, @section_slug, @student_id, @preview_mode), nil, is_active_tab?(:progress, @active_tab)},
+ {"Actions", path_for(:actions, @section_slug, @student_id, @preview_mode), nil, is_active_tab?(:actions, @active_tab)},
] do %>
<.link patch={href}
diff --git a/lib/oli_web/live/delivery/student_dashboard/student_dashboard_live.ex b/lib/oli_web/live/delivery/student_dashboard/student_dashboard_live.ex
index 0032b6c324..3a92d368dd 100644
--- a/lib/oli_web/live/delivery/student_dashboard/student_dashboard_live.ex
+++ b/lib/oli_web/live/delivery/student_dashboard/student_dashboard_live.ex
@@ -1,10 +1,11 @@
defmodule OliWeb.Delivery.StudentDashboard.StudentDashboardLive do
use OliWeb, :live_view
+ use OliWeb.Common.Modal
import OliWeb.Common.Utils
alias OliWeb.Delivery.StudentDashboard.Components.Helpers
- alias alias Oli.Delivery.Sections
+ alias Oli.Delivery.Sections
alias Oli.Delivery.Metrics
alias Oli.Grading.GradebookRow
@@ -87,6 +88,23 @@ defmodule OliWeb.Delivery.StudentDashboard.StudentDashboardLive do
{:noreply, socket}
end
+ @impl Phoenix.LiveView
+ def handle_params(%{"active_tab" => "actions"} = params, _, socket) do
+ enrollment = Sections.get_enrollment(socket.assigns.section.slug, socket.assigns.student.id)
+
+ socket =
+ socket
+ |> assign(params: params, active_tab: String.to_existing_atom(params["active_tab"]))
+ |> assign_new(:enrollment_info, fn ->
+ %{
+ enrollment: enrollment,
+ user_role_id: Sections.get_user_role_from_enrollment(enrollment)
+ }
+ end)
+
+ {:noreply, socket}
+ end
+
@impl Phoenix.LiveView
def handle_params(params, _, socket) do
{:noreply,
@@ -99,6 +117,7 @@ defmodule OliWeb.Delivery.StudentDashboard.StudentDashboardLive do
@impl Phoenix.LiveView
def render(assigns) do
~H"""
+ <%= render_modal(assigns) %>
@@ -160,6 +179,18 @@ defmodule OliWeb.Delivery.StudentDashboard.StudentDashboardLive do
"""
end
+ defp render_tab(%{active_tab: :actions} = assigns) do
+ ~H"""
+ <.live_component
+ id="actions_table"
+ module={OliWeb.Components.Delivery.Actions}
+ user={@student}
+ section_slug={@section.slug}
+ enrollment_info={@enrollment_info}
+ />
+ """
+ end
+
@impl Phoenix.LiveView
def handle_event("breadcrumb-navigate", _unsigned_params, socket) do
if socket.assigns.preview_mode do
@@ -187,6 +218,21 @@ defmodule OliWeb.Delivery.StudentDashboard.StudentDashboardLive do
end
end
+ @impl Phoenix.LiveView
+ def handle_info({:hide_modal}, socket) do
+ {:noreply, hide_modal(socket)}
+ end
+
+ @impl Phoenix.LiveView
+ def handle_info({:show_modal, modal, modal_assigns}, socket) do
+ {:noreply,
+ show_modal(
+ socket,
+ modal,
+ modal_assigns: modal_assigns
+ )}
+ end
+
defp get_containers(section, student_id) do
{total_count, containers} = Sections.get_units_and_modules_containers(section.slug)
diff --git a/test/oli/accounts_test.exs b/test/oli/accounts_test.exs
index fed2fcc440..4f12cd3014 100644
--- a/test/oli/accounts_test.exs
+++ b/test/oli/accounts_test.exs
@@ -7,6 +7,8 @@ defmodule Oli.AccountsTest do
alias Oli.Accounts.{Author, AuthorPreferences, User, UserPreferences}
alias Oli.Groups
alias Oli.Groups.CommunityAccount
+ alias Oli.Delivery.Sections
+ alias Lti_1p3.Tool.ContextRoles
describe "authors" do
test "system role defaults to author", %{} do
@@ -351,6 +353,32 @@ defmodule Oli.AccountsTest do
assert {:ok, user} = Accounts.set_user_preference(user.id, :timezone, "America/Los_Angeles")
assert user.preferences.timezone == "America/Los_Angeles"
end
+
+ test "update_user_context_role/2 updates the context role for a specific enrollment" do
+ user = insert(:user)
+ section = insert(:section)
+
+ {:ok, enrollment} =
+ Sections.enroll(user.id, section.id, [
+ Lti_1p3.Tool.ContextRoles.get_role(:context_learner)
+ ])
+
+ user_role_id = Sections.get_user_role_from_enrollment(enrollment)
+
+ assert user_role_id == 4
+
+ Accounts.update_user_context_role(
+ enrollment,
+ ContextRoles.get_role(:context_instructor)
+ )
+
+ enrollment = Sections.get_enrollment(section.slug, user.id)
+
+ user_role_id_changed = Sections.get_user_role_from_enrollment(enrollment)
+
+ refute user_role_id_changed == 4
+ assert user_role_id_changed == 3
+ end
end
describe "communities accounts" do
diff --git a/test/oli_web/live/delivery/student_dashboard/components/actions_tab_test.exs b/test/oli_web/live/delivery/student_dashboard/components/actions_tab_test.exs
new file mode 100644
index 0000000000..c5cc637c8c
--- /dev/null
+++ b/test/oli_web/live/delivery/student_dashboard/components/actions_tab_test.exs
@@ -0,0 +1,187 @@
+defmodule OliWeb.Delivery.StudentDashboard.Components.ActionsTabTest do
+ use ExUnit.Case, async: true
+ use OliWeb.ConnCase
+
+ import Oli.Factory
+ import Phoenix.LiveViewTest
+
+ alias Lti_1p3.Tool.ContextRoles
+ alias Oli.Delivery.Sections
+
+ defp live_view_students_actions_route(
+ section_slug,
+ student_id,
+ tab
+ ) do
+ Routes.live_path(
+ OliWeb.Endpoint,
+ OliWeb.Delivery.StudentDashboard.StudentDashboardLive,
+ section_slug,
+ student_id,
+ tab
+ )
+ end
+
+ defp enrolled_student_and_instructor(%{section: section, instructor: instructor}) do
+ student = insert(:user)
+ Sections.enroll(student.id, section.id, [ContextRoles.get_role(:context_learner)])
+ Sections.enroll(instructor.id, section.id, [ContextRoles.get_role(:context_instructor)])
+ %{student: student}
+ end
+
+ describe "user" do
+ test "cannot access page when it is not logged in", %{conn: conn} do
+ section = insert(:section)
+ student = insert(:user)
+
+ redirect_path =
+ "/session/new?request_path=%2Fsections%2F#{section.slug}%2Fstudent_dashboard%2F#{student.id}%2Factions"
+
+ assert {:error, {:redirect, %{to: ^redirect_path}}} =
+ live(
+ conn,
+ live_view_students_actions_route(section.slug, student.id, :actions)
+ )
+ end
+ end
+
+ describe "student" do
+ setup [:user_conn]
+
+ test "cannot access page", %{user: user, conn: conn} do
+ section = insert(:section)
+ redirect_path = "/unauthorized"
+
+ assert {:error, {:redirect, %{to: ^redirect_path}}} =
+ live(
+ conn,
+ live_view_students_actions_route(section.slug, user.id, :actions)
+ )
+ end
+ end
+
+ describe "instructor" do
+ setup [:instructor_conn, :section_without_pages]
+
+ test "cannot access page if not enrolled to section", %{
+ conn: conn,
+ section: section
+ } do
+ student = insert(:user)
+ redirect_path = "/unauthorized"
+
+ assert {:error, {:redirect, %{to: ^redirect_path}}} =
+ live(
+ conn,
+ live_view_students_actions_route(section.slug, student.id, :actions)
+ )
+ end
+
+ test "can access page if enrolled to section", %{
+ conn: conn,
+ section: section,
+ instructor: instructor
+ } do
+ student = insert(:user)
+ Sections.enroll(instructor.id, section.id, [ContextRoles.get_role(:context_instructor)])
+ Sections.enroll(student.id, section.id, [ContextRoles.get_role(:context_learner)])
+
+ {:ok, view, _html} =
+ live(
+ conn,
+ live_view_students_actions_route(section.slug, student.id, :actions)
+ )
+
+ # Actions tab is the selected one
+ assert has_element?(
+ view,
+ ~s{a[href="#{live_view_students_actions_route(section.slug, student.id, :actions)}"].border-b-2},
+ "Actions"
+ )
+ end
+ end
+
+ describe "Actions tab" do
+ setup [:instructor_conn, :section_without_pages, :enrolled_student_and_instructor]
+
+ test "gets rendered correctly", %{
+ section: section,
+ conn: conn,
+ student: student
+ } do
+ {:ok, view, _html} =
+ live(conn, live_view_students_actions_route(section.slug, student.id, :actions))
+
+ # Actions tab is the selected one
+ assert has_element?(
+ view,
+ ~s{a[href="#{live_view_students_actions_route(section.slug, student.id, :actions)}"].border-b-2},
+ "Actions"
+ )
+
+ assert has_element?(view, "span", "Change enrolled user role")
+ assert has_element?(view, "select[name=filter_by_role_id]")
+ end
+
+ test "instructor can change student role", %{
+ section: section,
+ conn: conn,
+ student: student
+ } do
+ user_role_id =
+ Sections.get_user_role_from_enrollment(Sections.get_enrollment(section.slug, student.id))
+
+ {:ok, view, _html} =
+ live(conn, live_view_students_actions_route(section.slug, student.id, :actions))
+
+ assert view |> element("option[value=#{Integer.to_string(user_role_id)}]")
+
+ view
+ |> element("form[phx-change=\"display_confirm_modal\"")
+ |> render_change(%{filter_by_role_id: "3"})
+
+ assert view
+ |> element("div.modal-body")
+ |> render() =~
+ "Are you sure you want to change user role to #{student.given_name} #{student.family_name}"
+
+ view
+ |> element("button", "Ok")
+ |> render_click()
+
+ user_role_id_changed =
+ Sections.get_user_role_from_enrollment(Sections.get_enrollment(section.slug, student.id))
+
+ assert view |> element("option[value=#{Integer.to_string(user_role_id_changed)}]")
+ end
+
+ test "instructor can cancel the student's role change", %{
+ section: section,
+ conn: conn,
+ student: student
+ } do
+ user_role_id =
+ Sections.get_user_role_from_enrollment(Sections.get_enrollment(section.slug, student.id))
+
+ {:ok, view, _html} =
+ live(conn, live_view_students_actions_route(section.slug, student.id, :actions))
+
+ assert view |> element("option[value=#{Integer.to_string(user_role_id)}]")
+
+ view
+ |> element("form[phx-change=\"display_confirm_modal\"")
+ |> render_change(%{filter_by_role_id: "3"})
+
+ assert view
+ |> element("div.modal-body")
+ |> render() =~
+ "Are you sure you want to change user role to #{student.given_name} #{student.family_name}"
+
+ view
+ |> element("button", "Cancel")
+ |> render_click()
+
+ assert view |> element("option[value=#{Integer.to_string(user_role_id)}]")
+ end
+ end
+end