From f87aae97607c8e9c75f5f595425965c15f4c2d99 Mon Sep 17 00:00:00 2001 From: Darren Siegel Date: Wed, 11 Sep 2024 12:20:56 -0400 Subject: [PATCH 1/9] [PERFORMANCE] [MER-3551] Move author insight queries over to V2 analytics (#5084) * stashing * restore baseline functionality * Auto format --------- Co-authored-by: darrensiegel --- lib/oli/analytics/summary/browse_insights.ex | 294 ++++++++++ .../summary/browse_insights_options.ex | 23 + .../live/insights/activity_table_model.ex | 94 ++++ lib/oli_web/live/insights/common.ex | 17 + lib/oli_web/live/insights/insights.ex | 513 ++++++++--------- .../live/insights/objective_table_model.ex | 57 ++ lib/oli_web/live/insights/page_table_model.ex | 75 +++ lib/oli_web/live/insights/table_header.ex | 95 ---- lib/oli_web/live/insights/table_row.ex | 46 -- .../summary/browse_insights_test.exs | 514 ++++++++++++++++++ 10 files changed, 1301 insertions(+), 427 deletions(-) create mode 100644 lib/oli/analytics/summary/browse_insights.ex create mode 100644 lib/oli/analytics/summary/browse_insights_options.ex create mode 100644 lib/oli_web/live/insights/activity_table_model.ex create mode 100644 lib/oli_web/live/insights/common.ex create mode 100644 lib/oli_web/live/insights/objective_table_model.ex create mode 100644 lib/oli_web/live/insights/page_table_model.ex delete mode 100644 lib/oli_web/live/insights/table_header.ex delete mode 100644 lib/oli_web/live/insights/table_row.ex create mode 100644 test/oli/analytics/summary/browse_insights_test.exs diff --git a/lib/oli/analytics/summary/browse_insights.ex b/lib/oli/analytics/summary/browse_insights.ex new file mode 100644 index 00000000000..2405edc6112 --- /dev/null +++ b/lib/oli/analytics/summary/browse_insights.ex @@ -0,0 +1,294 @@ +defmodule Oli.Analytics.Summary.BrowseInsights do + alias Oli.Publishing.Publications.Publication + alias Oli.Publishing.PublishedResource + alias Oli.Analytics.Summary.BrowseInsightsOptions + alias Oli.Analytics.Summary.ResourceSummary + alias Oli.Resources.Revision + alias Oli.Repo.{Paging, Sorting} + import Ecto.Query, warn: false + alias Oli.Repo + + defp get_relative_difficulty_parameters() do + alpha = Application.get_env(:oli, :relative_difficulty_alpha, 0.5) + beta = Application.get_env(:oli, :relative_difficulty_beta, 0.3) + gamma = Application.get_env(:oli, :relative_difficulty_gamma, 0.2) + + {alpha, beta, gamma} + end + + def browse_insights( + %Paging{limit: limit, offset: offset}, + %Sorting{} = sorting, + %BrowseInsightsOptions{project_id: project_id, section_ids: section_ids} = options + ) do + where_by = build_where_by(options) + total_count = get_total_count(project_id, section_ids, where_by) + + # Now build the main query with limit, offset, and aggregations + query = + ResourceSummary + |> join(:left, [s], pub in Publication, on: pub.project_id == ^project_id) + |> join(:left, [s, pub], pr in PublishedResource, on: pr.publication_id == pub.id) + |> join(:left, [s, pub, pr], rev in Revision, on: rev.id == pr.revision_id) + |> where(^where_by) + |> add_select(total_count, options) + |> add_order_by(sorting, options) + |> limit(^limit) + |> offset(^offset) + + Repo.all(query) + end + + defp build_where_by(%BrowseInsightsOptions{ + project_id: project_id, + resource_type_id: resource_type_id, + section_ids: section_ids + }) do + case section_ids do + [] -> + dynamic( + [s, pub, pr, _], + s.project_id == ^project_id and + s.resource_id == pr.resource_id and + is_nil(pub.published) and + s.resource_type_id == ^resource_type_id and + s.section_id == -1 and + s.user_id == -1 and + s.publication_id == -1 + ) + + section_ids -> + dynamic( + [s, pub, pr, _], + s.resource_id == pr.resource_id and + is_nil(pub.published) and + s.resource_type_id == ^resource_type_id and + s.section_id in ^section_ids and + s.user_id == -1 and + s.publication_id == -1 + ) + end + end + + defp add_select(query, total_count, %BrowseInsightsOptions{section_ids: section_ids}) do + {alpha, beta, gamma} = get_relative_difficulty_parameters() + + case section_ids do + [] -> + query + |> select([s, pub, pr, rev], %{ + id: s.id, + total_count: fragment("?::int", ^total_count), + title: rev.title, + resource_id: s.resource_id, + slug: rev.slug, + part_id: s.part_id, + pub_id: pub.id, + activity_type_id: rev.activity_type_id, + pr_rev: pr.revision_id, + pr_resource: pr.resource_id, + num_attempts: s.num_attempts, + num_first_attempts: s.num_first_attempts, + eventually_correct: fragment("?::float8 / ?::float8", s.num_correct, s.num_attempts), + first_attempt_correct: + fragment("?::float8 / ?::float8", s.num_first_attempts_correct, s.num_first_attempts), + relative_difficulty: + fragment( + "?::float8 * (1.0 - ?::float8) + ?::float8 * (1.0 - ?::float8) + ?::float8 * ?::float8", + ^alpha, + s.num_first_attempts_correct / s.num_first_attempts, + ^beta, + s.num_correct / s.num_attempts, + ^gamma, + s.num_hints + ) + }) + + _section_ids -> + query + |> group_by([s, _, _, rev], [ + s.resource_id, + s.part_id, + rev.title, + rev.slug, + rev.activity_type_id + ]) + |> select([s, _, _, rev], %{ + # select id as a random GUID + id: fragment("gen_random_uuid()::text"), + total_count: fragment("?::int", ^total_count), + resource_id: s.resource_id, + title: rev.title, + slug: rev.slug, + part_id: s.part_id, + activity_type_id: rev.activity_type_id, + num_attempts: sum(s.num_attempts), + num_first_attempts: sum(s.num_first_attempts), + eventually_correct: + fragment("?::float8 / ?::float8", sum(s.num_correct), sum(s.num_attempts)), + first_attempt_correct: + fragment( + "?::float8 / ?::float8", + sum(s.num_first_attempts_correct), + sum(s.num_first_attempts) + ), + relative_difficulty: + fragment( + "?::float8 * (1.0 - (?::float8)) + ?::float8 * (1.0 - (?::float8)) + ?::float8 * (?::float8)", + ^alpha, + sum(s.num_first_attempts_correct) / sum(s.num_first_attempts), + ^beta, + sum(s.num_correct) / sum(s.num_attempts), + ^gamma, + sum(s.num_hints) + ) + }) + end + end + + defp add_order_by(query, %Sorting{direction: direction, field: field}, %BrowseInsightsOptions{ + section_ids: [] + }) do + {alpha, beta, gamma} = get_relative_difficulty_parameters() + + query = + case field do + :title -> + order_by(query, [_, _, _, rev], {^direction, rev.title}) + + :part_id -> + order_by(query, [s], {^direction, s.part_id}) + + :num_attempts -> + order_by(query, [s], {^direction, s.num_attempts}) + + :num_first_attempts -> + order_by(query, [s], {^direction, s.num_first_attempts}) + + :eventually_correct -> + order_by( + query, + [s], + {^direction, fragment("?::float8 / ?::float8", s.num_correct, s.num_attempts)} + ) + + :first_attempt_correct -> + order_by( + query, + [s], + {^direction, + fragment("?::float8 / ?::float8", s.num_first_attempts_correct, s.num_first_attempts)} + ) + + :relative_difficulty -> + order_by( + query, + [s], + {^direction, + fragment( + "?::float8 * (1.0 - ?::float8) + ?::float8 * (1.0 - ?::float8) + ?::float8 * ?::float8", + ^alpha, + s.num_first_attempts_correct / s.num_first_attempts, + ^beta, + s.num_correct / s.num_attempts, + ^gamma, + s.num_hints + )} + ) + + _ -> + order_by(query, [_, _, _, rev], {^direction, field(rev, ^field)}) + end + + # Ensure there is always a stable sort order based on id + order_by(query, [s], s.resource_id) + end + + defp add_order_by(query, %Sorting{direction: direction, field: field}, %BrowseInsightsOptions{ + section_ids: _ + }) do + {alpha, beta, gamma} = get_relative_difficulty_parameters() + + query = + case field do + :title -> + order_by(query, [_, _, _, rev], {^direction, rev.title}) + + :part_id -> + order_by(query, [s], {^direction, s.part_id}) + + :num_attempts -> + order_by(query, [s], {^direction, sum(s.num_attempts)}) + + :num_first_attempts -> + order_by(query, [s], {^direction, sum(s.num_first_attempts)}) + + :eventually_correct -> + order_by( + query, + [s], + {^direction, + fragment("?::float8 / ?::float8", sum(s.num_correct), sum(s.num_attempts))} + ) + + :first_attempt_correct -> + order_by( + query, + [s], + {^direction, + fragment( + "?::float8 / ?::float8", + sum(s.num_first_attempts_correct), + sum(s.num_first_attempts) + )} + ) + + :relative_difficulty -> + order_by( + query, + [s], + {^direction, + fragment( + "?::float8 * (1.0 - (?::float8)) + ?::float8 * (1.0 - (?::float8)) + ?::float8 * (?::float8)", + ^alpha, + sum(s.num_first_attempts_correct) / sum(s.num_first_attempts), + ^beta, + sum(s.num_correct) / sum(s.num_attempts), + ^gamma, + sum(s.num_hints) + )} + ) + + _ -> + order_by(query, [_, _, _, rev], {^direction, field(rev, ^field)}) + end + + # Ensure there is always a stable sort order based on id + order_by(query, [s], s.resource_id) + end + + defp get_total_count(project_id, section_ids, where_by) do + add_group_by = fn query, section_ids -> + case section_ids do + [] -> query + _section_ids -> query |> group_by([s, _, _, _], [s.resource_id, s.part_id]) + end + end + + # First, we compute the total count separately + total_count_query = + ResourceSummary + |> join(:left, [s], pub in Publication, on: pub.project_id == ^project_id) + |> join(:left, [s, pub], pr in PublishedResource, on: pr.publication_id == pub.id) + |> where(^where_by) + |> add_group_by.(section_ids) + |> select([s, _], fragment("count(*) OVER() as total_count")) + |> limit(1) + + Repo.one(total_count_query) + |> case do + nil -> 0 + count -> count + end + end +end diff --git a/lib/oli/analytics/summary/browse_insights_options.ex b/lib/oli/analytics/summary/browse_insights_options.ex new file mode 100644 index 00000000000..3625d6b914c --- /dev/null +++ b/lib/oli/analytics/summary/browse_insights_options.ex @@ -0,0 +1,23 @@ +defmodule Oli.Analytics.Summary.BrowseInsightsOptions do + @moduledoc """ + Params for browse insights queries. + """ + + @enforce_keys [ + :project_id, + :section_ids, + :resource_type_id + ] + + defstruct [ + :project_id, + :section_ids, + :resource_type_id + ] + + @type t() :: %__MODULE__{ + project_id: integer(), + section_ids: list(), + resource_type_id: integer() + } +end diff --git a/lib/oli_web/live/insights/activity_table_model.ex b/lib/oli_web/live/insights/activity_table_model.ex new file mode 100644 index 00000000000..94efc8f2b18 --- /dev/null +++ b/lib/oli_web/live/insights/activity_table_model.ex @@ -0,0 +1,94 @@ +defmodule OliWeb.Insights.ActivityTableModel do + alias OliWeb.Common.Table.{ColumnSpec, SortableTableModel} + use Phoenix.Component + + import OliWeb.Live.Insights.Common + alias OliWeb.Router.Helpers, as: Routes + + def new(rows, activity_types_map, parent_pages, project_slug, ctx) do + SortableTableModel.new( + rows: rows, + column_specs: [ + %ColumnSpec{ + name: :title, + label: "Activity", + render_fn: &__MODULE__.render_title/3 + }, + %ColumnSpec{ + name: :activity_type_id, + label: "Type", + render_fn: &__MODULE__.render_activity_type/3 + }, + %ColumnSpec{ + name: :part_id, + label: "Part" + }, + %ColumnSpec{ + name: :num_attempts, + label: "# Attempts" + }, + %ColumnSpec{ + name: :num_first_attempts, + label: "# First Attempts" + }, + %ColumnSpec{ + name: :first_attempt_correct, + label: "First Attempt Correct%", + render_fn: &render_percentage/3 + }, + %ColumnSpec{ + name: :eventually_correct, + label: "Eventually Correct%", + render_fn: &render_percentage/3 + }, + %ColumnSpec{ + name: :relative_difficulty, + label: "Relative Difficulty", + render_fn: &render_float/3 + } + ], + event_suffix: "", + id_field: [:id], + data: %{ + activity_types_map: activity_types_map, + parent_pages: parent_pages, + project_slug: project_slug, + ctx: ctx + } + ) + end + + def render_title(%{parent_pages: parent_pages} = data, row, assigns) do + case Map.has_key?(parent_pages, row.resource_id) do + true -> render_with_link(data, row, assigns) + false -> render_without_link(data, row, assigns) + end + end + + defp render_with_link(%{parent_pages: parent_pages, project_slug: project_slug}, row, assigns) do + parent_page = Map.get(parent_pages, row.resource_id) + assigns = Map.put(assigns, :project_slug, project_slug) + assigns = Map.put(assigns, :parent_page, parent_page) + assigns = Map.put(assigns, :row, row) + + ~H""" + + <%= @row.title %> + + """ + end + + defp render_without_link(_, row, _assigns) do + row.title + end + + def render_activity_type(%{activity_types_map: activity_types_map}, row, _) do + Map.get(activity_types_map, row.activity_type_id).petite_label + end + + def render(assigns) do + ~H""" +
nothing
+ """ + end +end diff --git a/lib/oli_web/live/insights/common.ex b/lib/oli_web/live/insights/common.ex new file mode 100644 index 00000000000..011aead4ae2 --- /dev/null +++ b/lib/oli_web/live/insights/common.ex @@ -0,0 +1,17 @@ +defmodule OliWeb.Live.Insights.Common do + def truncate(float_or_nil) when is_nil(float_or_nil), do: nil + def truncate(float_or_nil) when is_float(float_or_nil), do: Float.round(float_or_nil, 2) + + def format_percent(float_or_nil) when is_nil(float_or_nil), do: nil + + def format_percent(float_or_nil) when is_float(float_or_nil), + do: "#{round(100 * float_or_nil)}%" + + def render_percentage(_, row, %OliWeb.Common.Table.ColumnSpec{name: field}) do + row[field] |> format_percent() + end + + def render_float(_, row, %OliWeb.Common.Table.ColumnSpec{name: field}) do + row[field] |> truncate() + end +end diff --git a/lib/oli_web/live/insights/insights.ex b/lib/oli_web/live/insights/insights.ex index c65aedb5dad..02514010d4c 100644 --- a/lib/oli_web/live/insights/insights.ex +++ b/lib/oli_web/live/insights/insights.ex @@ -1,24 +1,36 @@ defmodule OliWeb.Insights do + alias Oli.Analytics.Summary.BrowseInsights use OliWeb, :live_view + import Ecto.Query + + import OliWeb.Common.Params + import OliWeb.DelegatedEvents + alias Oli.Delivery.Sections alias OliWeb.Common.MultiSelectInput alias OliWeb.Common.MultiSelect.Option alias Oli.{Accounts, Publishing} - alias OliWeb.Insights.{TableHeader, TableRow} + alias OliWeb.Common.{Breadcrumb, PagedTable, TextSearch, SessionContext} + alias OliWeb.Common.Table.SortableTableModel + alias Oli.Repo.{Paging, Sorting} alias Oli.Authoring.Course alias OliWeb.Common.Breadcrumb alias OliWeb.Components.Project.AsyncExporter alias Oli.Authoring.Broadcaster alias Oli.Authoring.Broadcaster.Subscriber alias OliWeb.Common.SessionContext + alias Oli.Analytics.Summary.BrowseInsights + alias Oli.Analytics.Summary.BrowseInsightsOptions + alias OliWeb.Insights.ActivityTableModel + alias OliWeb.Insights.PageTableModel + alias OliWeb.Insights.ObjectiveTableModel + + @limit 25 def mount(%{"project_id" => project_slug}, session, socket) do ctx = SessionContext.init(socket, session) - by_activity_rows = - Oli.Analytics.ByActivity.query_against_project_slug(project_slug, []) - project = Course.get_project_by_slug(project_slug) {sections, products} = @@ -31,12 +43,36 @@ defmodule OliWeb.Insights do end end) - parent_pages = - Enum.map(by_activity_rows, fn r -> r.slice.resource_id end) - |> parent_pages(project_slug) + sections_by_product_id = get_sections_by_product_id(project.id) + + activity_type_id = Oli.Resources.ResourceType.get_id_by_type("activity") + + options = %BrowseInsightsOptions{ + project_id: project.id, + resource_type_id: activity_type_id, + section_ids: [] + } + + insights = + BrowseInsights.browse_insights( + %Paging{offset: 0, limit: @limit}, + %Sorting{direction: :desc, field: :first_attempt_correct}, + options + ) latest_publication = Publishing.get_latest_published_publication_by_slug(project.slug) + parent_pages = parent_pages(project.slug) + + activity_types_map = + Oli.Activities.list_activity_registrations() + |> Enum.reduce(%{}, fn a, m -> Map.put(m, a.id, a) end) + + total_count = determine_total(insights) + + {:ok, table_model} = + ActivityTableModel.new(insights, activity_types_map, parent_pages, project.slug, ctx) + {analytics_export_status, analytics_export_url, analytics_export_timestamp} = case Course.analytics_export_status(project) do {:available, url, timestamp} -> {:available, url, timestamp} @@ -51,18 +87,12 @@ defmodule OliWeb.Insights do assign(socket, breadcrumbs: [Breadcrumb.new(%{full_title: "Insights"})], active: :insights, + sections_by_product_id: sections_by_product_id, ctx: ctx, is_admin?: Accounts.is_system_admin?(ctx.author), project: project, - by_page_rows: nil, - by_activity_rows: by_activity_rows, - by_objective_rows: nil, parent_pages: parent_pages, selected: :by_activity, - active_rows: apply_filter_sort(:by_activity, by_activity_rows, "", "title", :asc), - query: "", - sort_by: "title", - sort_order: :asc, latest_publication: latest_publication, analytics_export_status: analytics_export_status, analytics_export_url: analytics_export_url, @@ -73,38 +103,72 @@ defmodule OliWeb.Insights do section_ids: [], product_ids: [], form_uuid_for_product: "", - form_uuid_for_section: "" + form_uuid_for_section: "", + table_model: table_model, + options: options, + offset: 0, + total_count: total_count, + active_rows: insights, + query: "", + limit: @limit )} end - defp parent_pages(resource_ids, project_slug) do - publication = Oli.Publishing.project_working_publication(project_slug) - Oli.Publishing.determine_parent_pages(resource_ids, publication.id) + # Runs a query to find all sections for this project which have a + # product associated with them. (blueprint_id) + defp get_sections_by_product_id(project_id) do + query = + from s in Oli.Delivery.Sections.Section, + where: + s.base_project_id == ^project_id and not is_nil(s.blueprint_id) and + s.type == :enrollable, + select: {s.id, s.blueprint_id} + + Oli.Repo.all(query) + |> Enum.reduce(%{}, fn {id, blueprint_id}, m -> + case Map.get(m, blueprint_id) do + nil -> Map.put(m, blueprint_id, [id]) + ids -> Map.put(m, blueprint_id, [id | ids]) + end + end) end - defp arrange_rows_into_objective_hierarchy(rows) do - by_id = Enum.reduce(rows, %{}, fn r, m -> Map.put(m, r.slice.resource_id, r) end) + defp determine_total(items) do + case items do + [] -> 0 + [hd | _] -> hd.total_count + end + end - parents = - Enum.reduce(rows, %{}, fn r, m -> - Enum.reduce(r.slice.children, m, fn id, m -> Map.put(m, id, r.slice.resource_id) end) - end) + def handle_params(params, _, socket) do + table_model = SortableTableModel.update_from_params(socket.assigns.table_model, params) - Enum.filter(rows, fn r -> !Map.has_key?(parents, r.slice.resource_id) end) - |> Enum.map(fn parent -> - child_rows = - Enum.map(parent.slice.children, fn c -> Map.get(by_id, c) |> Map.put(:is_child, true) end) + offset = get_int_param(params, "offset", 0) - Map.put(parent, :child_rows, child_rows) - end) + options = socket.assigns.options + + insights = + BrowseInsights.browse_insights( + %Paging{offset: offset, limit: @limit}, + %Sorting{direction: table_model.sort_order, field: table_model.sort_by_spec.name}, + options + ) + + table_model = Map.put(table_model, :rows, insights) + total_count = determine_total(insights) + + {:noreply, + assign(socket, + offset: offset, + table_model: table_model, + total_count: total_count, + options: options + )} end - defp get_active_original(assigns) do - case assigns.selected do - :by_page -> assigns.by_page_rows - :by_activity -> assigns.by_activity_rows - _ -> assigns.by_objective_rows - end + defp parent_pages(project_slug) do + publication = Oli.Publishing.project_working_publication(project_slug) + Oli.Publishing.determine_parent_pages(publication.id) end def render(assigns) do @@ -207,26 +271,13 @@ defmodule OliWeb.Insights do end %> - <%= if !is_loading?(assigns) do %> - - - - <%= for row <- @active_rows do %> - - <% end %> - -
- <% else %> -
- -
- <% end %> + """ @@ -236,113 +287,128 @@ defmodule OliWeb.Insights do is_nil(assigns.active_rows) end - defp apply_filter_sort(:by_objective, rows, query, sort_by, sort_order) do - filter(rows, query) - |> sort(sort_by, sort_order) - |> Enum.reduce([], fn p, all -> - p.child_rows ++ [p] ++ all - end) - |> Enum.reverse() - end + def patch_with(socket, changes) do + # convert param keys from atoms to strings + changes = Enum.into(changes, %{}, fn {k, v} -> {Atom.to_string(k), v} end) + # convert atom values to string values + changes = + Enum.into(changes, %{}, fn {k, v} -> + case v do + atom when is_atom(atom) -> {k, Atom.to_string(v)} + _ -> {k, v} + end + end) - defp apply_filter_sort(_, rows, query, sort_by, sort_order) do - filter(rows, query) - |> sort(sort_by, sort_order) - end + table_model = SortableTableModel.update_from_params(socket.assigns.table_model, changes) - defp filter(rows, query) do - rows |> Enum.filter(&String.match?(&1.slice.title, ~r/#{String.trim(query)}/i)) - end + options = socket.assigns.options + offset = get_int_param(changes, "offset", 0) - def handle_event("filter_" <> filter_criteria, _event, socket) do - selected = String.to_existing_atom(filter_criteria) + insights = + BrowseInsights.browse_insights( + %Paging{offset: offset, limit: @limit}, + %Sorting{direction: table_model.sort_order, field: table_model.sort_by_spec.name}, + options + ) - # do the query and assign the results in an async way - filter_type(selected) + table_model = Map.put(table_model, :rows, insights) + total_count = determine_total(insights) - {:noreply, assign(socket, selected: selected, active_rows: nil)} + {:noreply, + assign(socket, + offset: offset, + table_model: table_model, + total_count: total_count, + options: options + )} end - # search - def handle_event("search", %{"query" => query}, socket) do - active_rows = - apply_filter_sort( - socket.assigns.selected, - get_active_original(socket.assigns), - query, - socket.assigns.sort_by, - socket.assigns.sort_order + defp filter_by(socket, resource_type_id, by_type, table_model) do + options = %BrowseInsightsOptions{ + project_id: socket.assigns.options.project_id, + resource_type_id: resource_type_id, + section_ids: socket.assigns.options.section_ids + } + + insights = + BrowseInsights.browse_insights( + %Paging{offset: 0, limit: @limit}, + %Sorting{direction: table_model.sort_order, field: table_model.sort_by_spec.name}, + options ) - {:noreply, assign(socket, query: query, active_rows: active_rows)} + table_model = Map.put(table_model, :rows, insights) + total_count = determine_total(insights) + + {:noreply, + assign(socket, + offset: 0, + table_model: table_model, + total_count: total_count, + options: options, + selected: by_type + )} end - # sorting + defp change_section_ids(socket, section_ids) do + options = %BrowseInsightsOptions{socket.assigns.options | section_ids: section_ids} + table_model = socket.assigns.table_model - # CLick same column -> reverse sort order - def handle_event( - "sort", - %{"sort-by" => column} = event, - %{assigns: %{sort_by: sort_by, sort_order: :asc}} = socket + insights = + BrowseInsights.browse_insights( + %Paging{offset: 0, limit: @limit}, + %Sorting{direction: table_model.sort_order, field: table_model.sort_by_spec.name}, + options ) - when column == sort_by do + + table_model = Map.put(table_model, :rows, insights) + total_count = determine_total(insights) + {:noreply, - if click_or_enter_key?(event) do - active_rows = - apply_filter_sort( - socket.assigns.selected, - get_active_original(socket.assigns), - socket.assigns.query, - socket.assigns.sort_by, - :desc - ) - - assign(socket, sort_by: sort_by, sort_order: :desc, active_rows: active_rows) - else - socket - end} + assign(socket, + offset: 0, + table_model: table_model, + total_count: total_count, + options: options + )} end - def handle_event( - "sort", - %{"sort-by" => column} = event, - %{assigns: %{sort_by: sort_by, sort_order: :desc}} = socket + def handle_event("filter_by_activity", _params, socket) do + activity_types_map = + Oli.Activities.list_activity_registrations() + |> Enum.reduce(%{}, fn a, m -> Map.put(m, a.id, a) end) + + {:ok, table_model} = + ActivityTableModel.new( + [], + activity_types_map, + socket.assigns.parent_pages, + socket.assigns.project.slug, + socket.assigns.ctx ) - when column == sort_by do - {:noreply, - if click_or_enter_key?(event) do - active_rows = - apply_filter_sort( - socket.assigns.selected, - get_active_original(socket.assigns), - socket.assigns.query, - socket.assigns.sort_by, - :asc - ) - - assign(socket, sort_by: sort_by, sort_order: :asc, active_rows: active_rows) - else - socket - end} + + filter_by( + socket, + Oli.Resources.ResourceType.get_id_by_type("activity"), + :by_activity, + table_model + ) end - # Click new column - def handle_event("sort", %{"sort-by" => column} = event, socket) do - {:noreply, - if click_or_enter_key?(event) do - active_rows = - apply_filter_sort( - socket.assigns.selected, - get_active_original(socket.assigns), - socket.assigns.query, - column, - socket.assigns.sort_order - ) - - assign(socket, sort_by: column, active_rows: active_rows) - else - socket - end} + def handle_event("filter_by_page", _params, socket) do + {:ok, table_model} = PageTableModel.new([], socket.assigns.project.slug, socket.assigns.ctx) + filter_by(socket, Oli.Resources.ResourceType.get_id_by_type("page"), :by_page, table_model) + end + + def handle_event("filter_by_objective", _params, socket) do + {:ok, table_model} = ObjectiveTableModel.new([], socket.assigns.ctx) + + filter_by( + socket, + Oli.Resources.ResourceType.get_id_by_type("objective"), + :by_objective, + table_model + ) end def handle_event("generate_analytics_snapshot", _params, socket) do @@ -363,6 +429,13 @@ defmodule OliWeb.Insights do end end + def handle_event(event, params, socket) do + delegate_to( + {event, params, socket, &OliWeb.Insights.patch_with/2}, + [&TextSearch.handle_delegated/4, &PagedTable.handle_delegated/4] + ) + end + def handle_info({:option_selected, "section_selected", selected_ids}, socket) do socket = assign(socket, @@ -372,8 +445,7 @@ defmodule OliWeb.Insights do is_product: false ) - filter_type(socket.assigns.selected) - {:noreply, socket} + change_section_ids(socket, selected_ids) end def handle_info({:option_selected, "product_selected", selected_ids}, socket) do @@ -385,8 +457,15 @@ defmodule OliWeb.Insights do is_product: true ) - filter_type(socket.assigns.selected) - {:noreply, socket} + section_ids = + Enum.reduce(selected_ids, MapSet.new(), fn id, all -> + Map.get(socket.assigns.sections_by_product_id, id) + |> MapSet.new() + |> MapSet.union(all) + end) + |> Enum.to_list() + + change_section_ids(socket, section_ids) end def handle_info( @@ -416,140 +495,10 @@ defmodule OliWeb.Insights do {:noreply, assign(socket, analytics_export_status: status)} end - def handle_info(:init_by_page, socket) do - by_page_rows = - get_rows_by(socket, :by_page) - - active_rows = - apply_filter_sort( - :by_page, - by_page_rows, - socket.assigns.query, - socket.assigns.sort_by, - socket.assigns.sort_order - ) - - {:noreply, assign(socket, by_page_rows: by_page_rows, active_rows: active_rows)} - end - - def handle_info(:init_by_objective, socket) do - by_objective_rows = - get_rows_by(socket, :by_objective) - |> arrange_rows_into_objective_hierarchy() - - active_rows = - apply_filter_sort( - :by_objective, - by_objective_rows, - socket.assigns.query, - socket.assigns.sort_by, - socket.assigns.sort_order - ) - - {:noreply, assign(socket, by_objective_rows: by_objective_rows, active_rows: active_rows)} - end - - def handle_info(:init_by_activity, socket) do - by_activity_rows = - get_rows_by(socket, :by_activity) - - active_rows = - apply_filter_sort( - :by_activity, - by_activity_rows, - socket.assigns.query, - socket.assigns.sort_by, - socket.assigns.sort_order - ) - - {:noreply, assign(socket, by_activity_rows: by_activity_rows, active_rows: active_rows)} - end - - defp get_rows_by(socket, :by_activity) do - if socket.assigns.is_product do - section_by_product_ids = - Oli.Publishing.DeliveryResolver.get_sections_for_products(socket.assigns.product_ids) - - Oli.Analytics.ByActivity.query_against_project_slug( - socket.assigns.project.slug, - section_by_product_ids - ) - else - Oli.Analytics.ByActivity.query_against_project_slug( - socket.assigns.project.slug, - socket.assigns.section_ids - ) - end - end - - defp get_rows_by(socket, :by_objective) do - if socket.assigns.is_product do - section_by_product_ids = - Oli.Publishing.DeliveryResolver.get_sections_for_products(socket.assigns.product_ids) - - Oli.Analytics.ByObjective.query_against_project_slug( - socket.assigns.project.slug, - section_by_product_ids - ) - else - Oli.Analytics.ByObjective.query_against_project_slug( - socket.assigns.project.slug, - socket.assigns.section_ids - ) - end - end - - defp get_rows_by(socket, :by_page) do - if socket.assigns.is_product do - section_by_product_ids = - Oli.Publishing.DeliveryResolver.get_sections_for_products(socket.assigns.product_ids) - - Oli.Analytics.ByPage.query_against_project_slug( - socket.assigns.project.slug, - section_by_product_ids - ) - else - Oli.Analytics.ByPage.query_against_project_slug( - socket.assigns.project.slug, - socket.assigns.section_ids - ) - end - end - defp generate_uuid do UUID.uuid4() end - defp filter_type(selected) do - case selected do - :by_page -> - send(self(), :init_by_page) - - :by_activity -> - send(self(), :init_by_activity) - - :by_objective -> - send(self(), :init_by_objective) - end - end - - defp click_or_enter_key?(event) do - event["key"] == nil or event["key"] == "Enter" - end - - defp sort(rows, "title", :asc), do: rows |> Enum.sort(&(&1.slice.title > &2.slice.title)) - defp sort(rows, "title", :desc), do: rows |> Enum.sort(&(&1.slice.title <= &2.slice.title)) - - defp sort(rows, sort_by, :asc) do - sort_by_as_atom = String.to_existing_atom(sort_by) - Enum.sort(rows, &(&1[sort_by_as_atom] > &2[sort_by_as_atom])) - end - - defp sort(rows, sort_by, :desc) do - sort_by_as_atom = String.to_existing_atom(sort_by) - Enum.sort(rows, &(&1[sort_by_as_atom] <= &2[sort_by_as_atom])) - end - defp is_disabled(selected, title) do if selected == title do [disabled: true] @@ -557,12 +506,4 @@ defmodule OliWeb.Insights do [] end end - - def truncate(float_or_nil) when is_nil(float_or_nil), do: nil - def truncate(float_or_nil) when is_float(float_or_nil), do: Float.round(float_or_nil, 2) - - def format_percent(float_or_nil) when is_nil(float_or_nil), do: nil - - def format_percent(float_or_nil) when is_float(float_or_nil), - do: "#{round(100 * float_or_nil)}%" end diff --git a/lib/oli_web/live/insights/objective_table_model.ex b/lib/oli_web/live/insights/objective_table_model.ex new file mode 100644 index 00000000000..a6d794857ab --- /dev/null +++ b/lib/oli_web/live/insights/objective_table_model.ex @@ -0,0 +1,57 @@ +defmodule OliWeb.Insights.ObjectiveTableModel do + alias OliWeb.Common.Table.{ColumnSpec, SortableTableModel} + use Phoenix.Component + + import OliWeb.Live.Insights.Common + + def new(rows, ctx) do + SortableTableModel.new( + rows: rows, + column_specs: [ + %ColumnSpec{ + name: :title, + label: "Objective", + render_fn: &__MODULE__.render_title/3 + }, + %ColumnSpec{ + name: :num_attempts, + label: "# Attempts" + }, + %ColumnSpec{ + name: :num_first_attempts, + label: "# First Attempts" + }, + %ColumnSpec{ + name: :first_attempt_correct, + label: "First Attempt Correct%", + render_fn: &render_percentage/3 + }, + %ColumnSpec{ + name: :eventually_correct, + label: "Eventually Correct%", + render_fn: &render_percentage/3 + }, + %ColumnSpec{ + name: :relative_difficulty, + label: "Relative Difficulty", + render_fn: &render_float/3 + } + ], + event_suffix: "", + id_field: [:id], + data: %{ + ctx: ctx + } + ) + end + + def render_title(_, row, _assigns) do + row.title + end + + def render(assigns) do + ~H""" +
nothing
+ """ + end +end diff --git a/lib/oli_web/live/insights/page_table_model.ex b/lib/oli_web/live/insights/page_table_model.ex new file mode 100644 index 00000000000..58c8b4f2ecf --- /dev/null +++ b/lib/oli_web/live/insights/page_table_model.ex @@ -0,0 +1,75 @@ +defmodule OliWeb.Insights.PageTableModel do + alias OliWeb.Common.Table.{ColumnSpec, SortableTableModel} + use Phoenix.Component + + import OliWeb.Live.Insights.Common + alias OliWeb.Router.Helpers, as: Routes + + def new(rows, project_slug, ctx) do + SortableTableModel.new( + rows: rows, + column_specs: [ + %ColumnSpec{ + name: :title, + label: "Page", + render_fn: &__MODULE__.render_title/3 + }, + %ColumnSpec{ + name: :num_attempts, + label: "# Attempts" + }, + %ColumnSpec{ + name: :num_first_attempts, + label: "# First Attempts" + }, + %ColumnSpec{ + name: :first_attempt_correct, + label: "First Attempt Correct%", + render_fn: &render_percentage/3 + }, + %ColumnSpec{ + name: :eventually_correct, + label: "Eventually Correct%", + render_fn: &render_percentage/3 + }, + %ColumnSpec{ + name: :relative_difficulty, + label: "Relative Difficulty", + render_fn: &render_float/3 + } + ], + event_suffix: "", + id_field: [:id], + data: %{ + ctx: ctx, + project_slug: project_slug + } + ) + end + + def render_title(%{project_slug: project_slug}, row, assigns) do + assigns = Map.put(assigns, :project_slug, project_slug) + assigns = Map.put(assigns, :row, row) + + ~H""" + + <%= @row.title %> + + """ + end + + def render_type(%{graded: graded}, _row, assigns) do + assigns = assign(assigns, :graded, graded) + + case assigns.graded do + true -> "Graded" + false -> "Practice" + end + end + + def render(assigns) do + ~H""" +
nothing
+ """ + end +end diff --git a/lib/oli_web/live/insights/table_header.ex b/lib/oli_web/live/insights/table_header.ex deleted file mode 100644 index c3dd4c4e0f0..00000000000 --- a/lib/oli_web/live/insights/table_header.ex +++ /dev/null @@ -1,95 +0,0 @@ -defmodule OliWeb.Insights.TableHeader do - use OliWeb, :html - - def th(assigns, sort_by, title, tooltip) do - assigns = - assigns - |> assign(:sort_by, sort_by) - |> assign(:title, title) - |> assign(:tooltip, tooltip) - - ~H""" - - <%= @title %> - <%= sort_order_icon(@sort_by, @sort_by, @sort_order) %> - - """ - end - - attr :selected, :atom - attr :sort_by, :string - attr :sort_order, :atom - - def render(assigns) do - ~H""" - - - - <%= case @selected do - :by_page -> "Page Title" - :by_activity -> "Activity Title" - :by_objective -> "Objective" - _ -> "Objective" - end %> - <%= sort_order_icon("title", @sort_by, @sort_order) %> - - <%= if @selected == :by_page do %> - Activity - <% end %> - <%= th( - assigns, - "number_of_attempts", - "Number of Attempts", - "Number of total student submissions" - ) %> - <%= th( - assigns, - "relative_difficulty", - "Relative Difficulty", - "(Number of hints requested + Number of incorrect submissions) / Total submissions" - ) %> - <%= th( - assigns, - "eventually_correct", - "Eventually Correct", - "Ratio of the time a student with at least one submission eventually gets the correct answer" - ) %> - <%= th( - assigns, - "first_try_correct", - "First Try Correct", - "Ratio of the time a student gets the correct answer on their first submission" - ) %> - - - """ - end - - defp sort_order_icon(column, sort_by, :asc) when column == sort_by, do: "▲" - defp sort_order_icon(column, sort_by, :desc) when column == sort_by, do: "▼" - defp sort_order_icon(_, _, _), do: "" -end diff --git a/lib/oli_web/live/insights/table_row.ex b/lib/oli_web/live/insights/table_row.ex deleted file mode 100644 index a19667dd36f..00000000000 --- a/lib/oli_web/live/insights/table_row.ex +++ /dev/null @@ -1,46 +0,0 @@ -defmodule OliWeb.Insights.TableRow do - use OliWeb, :html - alias OliWeb.Common.Links - alias OliWeb.Insights - - attr :row, :map - attr :parent_pages, :list - attr :project, :any - attr :selected, :atom - - def render(assigns) do - # slice is a page, activity, or objective revision - assigns = - assigns - |> assign(:row, assigns.row) - |> assign(:activity, Map.get(assigns.row, :activity)) - - ~H""" - - - <%= if Map.get(assigns.row, :is_child, false) do %> -   - <% end %> - <%= Links.resource_link(@row.slice, assigns.parent_pages, assigns.project) %> - - <%= if @selected == :by_page do %> - - <%= if !is_nil(@activity) do - @row.activity.title - end %> - - <% end %> - - <%= if @row.number_of_attempts == nil do - "No attempts" - else - @row.number_of_attempts - end %> - - <%= Insights.truncate(@row.relative_difficulty) %> - <%= Insights.format_percent(@row.eventually_correct) %> - <%= Insights.format_percent(@row.first_try_correct) %> - - """ - end -end diff --git a/test/oli/analytics/summary/browse_insights_test.exs b/test/oli/analytics/summary/browse_insights_test.exs new file mode 100644 index 00000000000..72a653d2298 --- /dev/null +++ b/test/oli/analytics/summary/browse_insights_test.exs @@ -0,0 +1,514 @@ +defmodule Oli.Analytics.Summary.BrowseInsightsTest do + use Oli.DataCase + + alias Oli.Analytics.Summary.BrowseInsights + + describe "browse insights query, section_id = -1" do + setup do + map = + Seeder.base_project_with_resource2() + |> Seeder.create_section() + |> Seeder.add_objective("A", :o1) + |> Seeder.add_objective("B", :o2) + |> Seeder.add_objective("C", :o3) + |> Seeder.add_activity(%{title: "A", content: %{}}, :a1) + |> Seeder.add_activity(%{title: "B", content: %{}}, :a2) + |> Seeder.add_activity(%{title: "C", content: %{}}, :a3) + |> Seeder.add_user(%{}, :user1) + |> Seeder.add_user(%{}, :user2) + + map = Seeder.publish_project(map) + + a1 = map.a1 + a2 = map.a2 + a3 = map.a3 + o1 = map.o1 + o2 = map.o2 + o3 = map.o3 + page1 = map.page1 + page2 = map.page2 + + insert_summaries(map.project.id, [ + [a1.resource.id, a1.revision.resource_type_id, -1, -1, "part1", 1, 2, 0, 1, 1], + [a1.resource.id, a1.revision.resource_type_id, -1, -1, "part2", 2, 3, 0, 1, 1], + [a2.resource.id, a2.revision.resource_type_id, -1, -1, "part1", 3, 4, 0, 1, 1], + [a2.resource.id, a2.revision.resource_type_id, -1, -1, "part2", 4, 5, 0, 1, 1], + [a3.resource.id, a3.revision.resource_type_id, -1, -1, "part1", 5, 6, 0, 1, 1], + [a3.resource.id, a3.revision.resource_type_id, -1, -1, "part2", 6, 7, 0, 1, 1], + [ + a1.resource.id, + a1.revision.resource_type_id, + map.publication.id, + -1, + "part1", + 1, + 2, + 0, + 1, + 1 + ], + [ + a1.resource.id, + a1.revision.resource_type_id, + map.publication.id, + -1, + "part2", + 2, + 3, + 0, + 1, + 1 + ], + [ + a2.resource.id, + a2.revision.resource_type_id, + map.publication.id, + -1, + "part1", + 3, + 4, + 0, + 1, + 1 + ], + [ + a2.resource.id, + a2.revision.resource_type_id, + map.publication.id, + -1, + "part2", + 4, + 5, + 0, + 1, + 1 + ], + [ + a3.resource.id, + a3.revision.resource_type_id, + map.publication.id, + -1, + "part1", + 5, + 6, + 0, + 1, + 1 + ], + [ + a3.resource.id, + a3.revision.resource_type_id, + map.publication.id, + -1, + "part2", + 6, + 7, + 0, + 1, + 1 + ], + [o1.resource.id, o1.revision.resource_type_id, -1, -1, nil, 1, 2, 1, 1, 1], + [o2.resource.id, o2.revision.resource_type_id, -1, -1, nil, 1, 3, 1, 1, 1], + [o3.resource.id, o3.revision.resource_type_id, -1, -1, nil, 1, 4, 1, 1, 1], + [page1.id, 1, -1, -1, nil, 1, 2, 1, 1, 1], + [page2.id, 1, -1, -1, nil, 1, 2, 1, 1, 1] + ]) + + map + end + + test "browsing activities, basic query operation, paging", %{ + project: project + } do + activity_type_id = Oli.Resources.ResourceType.get_id_by_type("activity") + + results = + BrowseInsights.browse_insights( + %Oli.Repo.Paging{limit: 4, offset: 0}, + %Oli.Repo.Sorting{direction: :asc, field: :title}, + %Oli.Analytics.Summary.BrowseInsightsOptions{ + project_id: project.id, + section_ids: [], + resource_type_id: activity_type_id + } + ) + + assert length(results) == 4 + assert Enum.at(results, 0).total_count == 6 + assert Enum.at(results, 0).title == "A" + + results = + BrowseInsights.browse_insights( + %Oli.Repo.Paging{limit: 4, offset: 4}, + %Oli.Repo.Sorting{direction: :asc, field: :title}, + %Oli.Analytics.Summary.BrowseInsightsOptions{ + project_id: project.id, + section_ids: [], + resource_type_id: activity_type_id + } + ) + + assert length(results) == 2 + assert Enum.at(results, 0).total_count == 6 + end + + test "sorting", %{ + project: project + } do + objective_type_id = Oli.Resources.ResourceType.get_id_by_type("objective") + + results = + BrowseInsights.browse_insights( + %Oli.Repo.Paging{limit: 4, offset: 0}, + %Oli.Repo.Sorting{direction: :asc, field: :title}, + %Oli.Analytics.Summary.BrowseInsightsOptions{ + project_id: project.id, + section_ids: [], + resource_type_id: objective_type_id + } + ) + + assert length(results) == 3 + assert Enum.at(results, 0).total_count == 3 + assert Enum.at(results, 0).title == "A" + assert Enum.at(results, 1).title == "B" + assert Enum.at(results, 2).title == "C" + + results = + BrowseInsights.browse_insights( + %Oli.Repo.Paging{limit: 4, offset: 0}, + %Oli.Repo.Sorting{direction: :desc, field: :title}, + %Oli.Analytics.Summary.BrowseInsightsOptions{ + project_id: project.id, + section_ids: [], + resource_type_id: objective_type_id + } + ) + + assert length(results) == 3 + assert Enum.at(results, 0).total_count == 3 + assert Enum.at(results, 0).title == "C" + assert Enum.at(results, 1).title == "B" + assert Enum.at(results, 2).title == "A" + + results = + BrowseInsights.browse_insights( + %Oli.Repo.Paging{limit: 4, offset: 0}, + %Oli.Repo.Sorting{direction: :desc, field: :relative_difficulty}, + %Oli.Analytics.Summary.BrowseInsightsOptions{ + project_id: project.id, + section_ids: [], + resource_type_id: objective_type_id + } + ) + + assert length(results) == 3 + assert Enum.at(results, 0).total_count == 3 + assert Enum.at(results, 0).title == "C" + assert Enum.at(results, 1).title == "B" + assert Enum.at(results, 2).title == "A" + + results = + BrowseInsights.browse_insights( + %Oli.Repo.Paging{limit: 4, offset: 0}, + %Oli.Repo.Sorting{direction: :asc, field: :relative_difficulty}, + %Oli.Analytics.Summary.BrowseInsightsOptions{ + project_id: project.id, + section_ids: [], + resource_type_id: objective_type_id + } + ) + + assert length(results) == 3 + assert Enum.at(results, 0).total_count == 3 + assert Enum.at(results, 0).title == "A" + assert Enum.at(results, 1).title == "B" + assert Enum.at(results, 2).title == "C" + end + end + + describe "browse insights query, specific section_ids" do + setup do + map = + Seeder.base_project_with_resource2() + |> Seeder.create_section() + |> Seeder.add_objective("A", :o1) + |> Seeder.add_objective("B", :o2) + |> Seeder.add_objective("C", :o3) + |> Seeder.add_activity(%{title: "A", content: %{}}, :a1) + |> Seeder.add_activity(%{title: "B", content: %{}}, :a2) + |> Seeder.add_activity(%{title: "C", content: %{}}, :a3) + |> Seeder.add_user(%{}, :user1) + |> Seeder.add_user(%{}, :user2) + + map = Seeder.publish_project(map) + + a1 = map.a1 + a2 = map.a2 + a3 = map.a3 + o1 = map.o1 + o2 = map.o2 + o3 = map.o3 + page1 = map.page1 + page2 = map.page2 + + insert_summaries(map.project.id, [ + [a1.resource.id, a1.revision.resource_type_id, -1, 1, "part1", 1, 2, 0, 1, 1], + [a1.resource.id, a1.revision.resource_type_id, -1, 1, "part2", 2, 3, 0, 1, 1], + [a2.resource.id, a2.revision.resource_type_id, -1, 1, "part1", 3, 4, 0, 1, 1], + [a2.resource.id, a2.revision.resource_type_id, -1, 1, "part2", 4, 5, 0, 1, 1], + [a3.resource.id, a3.revision.resource_type_id, -1, 1, "part1", 5, 6, 0, 1, 1], + [a3.resource.id, a3.revision.resource_type_id, -1, 1, "part2", 6, 7, 0, 1, 1], + [ + a1.resource.id, + a1.revision.resource_type_id, + map.publication.id, + 1, + "part1", + 1, + 2, + 0, + 1, + 1 + ], + [ + a1.resource.id, + a1.revision.resource_type_id, + map.publication.id, + 1, + "part2", + 2, + 3, + 0, + 1, + 1 + ], + [ + a2.resource.id, + a2.revision.resource_type_id, + map.publication.id, + 1, + "part1", + 3, + 4, + 0, + 1, + 1 + ], + [ + a2.resource.id, + a2.revision.resource_type_id, + map.publication.id, + 1, + "part2", + 4, + 5, + 0, + 1, + 1 + ], + [ + a3.resource.id, + a3.revision.resource_type_id, + map.publication.id, + 1, + "part1", + 5, + 6, + 0, + 1, + 1 + ], + [ + a3.resource.id, + a3.revision.resource_type_id, + map.publication.id, + 1, + "part2", + 6, + 7, + 0, + 1, + 1 + ], + [o1.resource.id, o1.revision.resource_type_id, -1, 1, nil, 1, 2, 1, 1, 1], + [o2.resource.id, o2.revision.resource_type_id, -1, 1, nil, 1, 3, 1, 1, 1], + [o3.resource.id, o3.revision.resource_type_id, -1, 1, nil, 1, 4, 1, 1, 1], + [page1.id, 1, -1, 1, nil, 1, 2, 1, 1, 1], + [page2.id, 1, -1, 1, nil, 1, 2, 1, 1, 1], + [a1.resource.id, a1.revision.resource_type_id, -1, 2, "part1", 1, 2, 0, 1, 3], + [a1.resource.id, a1.revision.resource_type_id, -1, 2, "part2", 2, 3, 0, 1, 1], + [a2.resource.id, a2.revision.resource_type_id, -1, 2, "part1", 3, 4, 0, 1, 1], + [a2.resource.id, a2.revision.resource_type_id, -1, 2, "part2", 4, 5, 0, 1, 1], + [a3.resource.id, a3.revision.resource_type_id, -1, 2, "part1", 5, 6, 0, 1, 1], + [a3.resource.id, a3.revision.resource_type_id, -1, 2, "part2", 6, 7, 0, 1, 1], + [ + a1.resource.id, + a1.revision.resource_type_id, + map.publication.id, + 2, + "part1", + 1, + 2, + 0, + 1, + 1 + ], + [ + a1.resource.id, + a1.revision.resource_type_id, + map.publication.id, + 2, + "part2", + 2, + 3, + 0, + 1, + 1 + ], + [ + a2.resource.id, + a2.revision.resource_type_id, + map.publication.id, + 2, + "part1", + 3, + 4, + 0, + 1, + 1 + ], + [ + a2.resource.id, + a2.revision.resource_type_id, + map.publication.id, + 2, + "part2", + 4, + 5, + 0, + 1, + 1 + ], + [ + a3.resource.id, + a3.revision.resource_type_id, + map.publication.id, + 2, + "part1", + 5, + 6, + 0, + 1, + 1 + ], + [ + a3.resource.id, + a3.revision.resource_type_id, + map.publication.id, + 2, + "part2", + 6, + 7, + 0, + 1, + 1 + ], + [o1.resource.id, o1.revision.resource_type_id, -1, 2, nil, 1, 2, 1, 1, 1], + [o2.resource.id, o2.revision.resource_type_id, -1, 2, nil, 1, 3, 1, 1, 1], + [o3.resource.id, o3.revision.resource_type_id, -1, 2, nil, 1, 4, 1, 1, 1], + [page1.id, 1, -1, 2, nil, 1, 2, 1, 1, 1], + [page2.id, 1, -1, 2, nil, 1, 2, 1, 1, 1] + ]) + + map + end + + test "browsing activities, basic query operation, paging", %{ + project: project + } do + activity_type_id = Oli.Resources.ResourceType.get_id_by_type("activity") + + results = + BrowseInsights.browse_insights( + %Oli.Repo.Paging{limit: 4, offset: 0}, + %Oli.Repo.Sorting{direction: :asc, field: :title}, + %Oli.Analytics.Summary.BrowseInsightsOptions{ + project_id: project.id, + section_ids: [1], + resource_type_id: activity_type_id + } + ) + + assert length(results) == 4 + assert Enum.at(results, 0).total_count == 6 + assert Enum.at(results, 0).title == "A" + assert Enum.at(results, 0).first_attempt_correct == 1.0 + + results = + BrowseInsights.browse_insights( + %Oli.Repo.Paging{limit: 4, offset: 4}, + %Oli.Repo.Sorting{direction: :asc, field: :title}, + %Oli.Analytics.Summary.BrowseInsightsOptions{ + project_id: project.id, + section_ids: [1], + resource_type_id: activity_type_id + } + ) + + assert length(results) == 2 + assert Enum.at(results, 0).total_count == 6 + + results = + BrowseInsights.browse_insights( + %Oli.Repo.Paging{limit: 4, offset: 0}, + %Oli.Repo.Sorting{direction: :asc, field: :title}, + %Oli.Analytics.Summary.BrowseInsightsOptions{ + project_id: project.id, + section_ids: [1, 2], + resource_type_id: activity_type_id + } + ) + + assert length(results) == 4 + assert Enum.at(results, 0).total_count == 6 + assert Enum.at(results, 0).title == "A" + + # Verify that it has aggregate the results from both sections + assert Enum.at(results, 0).first_attempt_correct == 0.5 + end + end + + defp insert_summaries(project_id, entries) do + augmented_entries = + Enum.map(entries, fn [ + resource_id, + resource_type_id, + pub_id, + section_id, + part_id, + num_correct, + num_attempts, + num_hints, + num_first_attempts_correct, + num_first_attempts + ] -> + %{ + project_id: project_id, + publication_id: pub_id, + section_id: section_id, + user_id: -1, + resource_id: resource_id, + resource_type_id: resource_type_id, + part_id: part_id, + num_correct: num_correct, + num_attempts: num_attempts, + num_hints: num_hints, + num_first_attempts: num_first_attempts, + num_first_attempts_correct: num_first_attempts_correct + } + end) + + Oli.Repo.insert_all(Oli.Analytics.Summary.ResourceSummary, augmented_entries) + end +end From f0a8eac1728bf96342275e0965f0a788ba9778b4 Mon Sep 17 00:00:00 2001 From: Francisco-Castro <47334502+Francisco-Castro@users.noreply.github.com> Date: Wed, 11 Sep 2024 14:50:35 -0400 Subject: [PATCH 2/9] [MER-3596] Add handle_params to Bibliography (#5090) --- .../live/workspaces/course_author/bibliography_live.ex | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/oli_web/live/workspaces/course_author/bibliography_live.ex b/lib/oli_web/live/workspaces/course_author/bibliography_live.ex index bd33f0d68ed..50435c63b0a 100644 --- a/lib/oli_web/live/workspaces/course_author/bibliography_live.ex +++ b/lib/oli_web/live/workspaces/course_author/bibliography_live.ex @@ -40,6 +40,11 @@ defmodule OliWeb.Workspaces.CourseAuthor.BibliographyLive do end end + @impl Phoenix.LiveView + def handle_params(_params, _url, socket) do + {:noreply, socket} + end + @impl Phoenix.LiveView def render(assigns) do ~H""" From 024803c8d8bf959249d0081eb07bf2cbd60724dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Cirio?= Date: Wed, 11 Sep 2024 15:53:16 -0300 Subject: [PATCH 3/9] [BUG FIX] [MER-3704] Wrong scheduling type label on graded pages and top level pages (#5088) * fix scheduling type label on graded pages rendered at lists and pages rendered at the top level of the curriculum * update tests --- lib/oli_web/live/delivery/student/learn_live.ex | 8 ++++++-- test/oli_web/live/delivery/student/learn_live_test.exs | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/oli_web/live/delivery/student/learn_live.ex b/lib/oli_web/live/delivery/student/learn_live.ex index c15466ad306..77676437605 100644 --- a/lib/oli_web/live/delivery/student/learn_live.ex +++ b/lib/oli_web/live/delivery/student/learn_live.ex @@ -828,7 +828,7 @@ defmodule OliWeb.Delivery.Student.LearnLive do
- Due: + <%= Utils.label_for_scheduling_type(@unit["section_resource"].scheduling_type) %> <%= format_date( @unit["section_resource"].end_date, @@ -1699,7 +1699,11 @@ defmodule OliWeb.Delivery.Student.LearnLive do
- Due: <%= format_date(@due_date, @ctx, "{WDshort} {Mshort} {D}, {YYYY}") %> + <%= Utils.label_for_scheduling_type(@parent_scheduling_type) %><%= format_date( + @due_date, + @ctx, + "{WDshort} {Mshort} {D}, {YYYY}" + ) %>
diff --git a/test/oli_web/live/delivery/student/learn_live_test.exs b/test/oli_web/live/delivery/student/learn_live_test.exs index 06d6ca12f2d..0dbba4b14d5 100644 --- a/test/oli_web/live/delivery/student/learn_live_test.exs +++ b/test/oli_web/live/delivery/student/learn_live_test.exs @@ -1175,7 +1175,7 @@ defmodule OliWeb.Delivery.Student.ContentLiveTest do assert has_element?( view, ~s{button[role="page 4 details"] div[role="due date and score"]}, - "Due: Fri Nov 3, 2023" + "Read by: Fri Nov 3, 2023" ) end @@ -1231,7 +1231,7 @@ defmodule OliWeb.Delivery.Student.ContentLiveTest do assert has_element?( view, ~s{button[role="page 4 details"] div[role="due date and score"]}, - "Due: Fri Nov 3, 2023" + "Read by: Fri Nov 3, 2023" ) # and correct score summary From b14fc20cba088751d019e40089dffb472f4e90b3 Mon Sep 17 00:00:00 2001 From: Raphael Date: Thu, 12 Sep 2024 11:53:56 -0400 Subject: [PATCH 4/9] fix container grouping error (#5096) Co-authored-by: Raphael Gachuhi --- lib/oli/delivery/sections.ex | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/oli/delivery/sections.ex b/lib/oli/delivery/sections.ex index 6b59fb0b7b5..557842cfa38 100644 --- a/lib/oli/delivery/sections.ex +++ b/lib/oli/delivery/sections.ex @@ -2194,7 +2194,11 @@ defmodule Oli.Delivery.Sections do section_resources |> Enum.group_by(&{&1.end_date, {&1.module_id, &1.unit_id}}) |> Enum.sort(fn {{end_date1, {_, _}}, _}, {{end_date2, {_, _}}, _} -> - DateTime.compare(end_date1, end_date2) == :lt + cond do + end_date1 == nil -> false + end_date2 == nil -> true + true -> DateTime.compare(end_date1, end_date2) == :lt + end end) end From e0c046321d059736d3c882b44668c6a74bb81b6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Cirio?= Date: Thu, 12 Sep 2024 15:47:25 -0300 Subject: [PATCH 5/9] [FEATURE] [MER-3785] Extend instructor dashboard query (#5098) * sort open and free sections by enrollment date desc * test function * fix test module name - was already used in another file --- lib/oli/delivery/sections.ex | 4 +- test/oli/delivery/sections_test.exs | 40 +++++++++++++++++++ .../course_author/bibliography_test.exs | 2 +- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/lib/oli/delivery/sections.ex b/lib/oli/delivery/sections.ex index 557842cfa38..0f0799b52cc 100644 --- a/lib/oli/delivery/sections.ex +++ b/lib/oli/delivery/sections.ex @@ -569,7 +569,8 @@ defmodule Oli.Delivery.Sections do end @doc """ - Returns a listing of all open and free sections for a given user. + Returns a listing of all open and free sections for a given user, + ordered by the most recently enrolled. """ def list_user_open_and_free_sections(%{id: user_id} = _user) do query = @@ -580,6 +581,7 @@ defmodule Oli.Delivery.Sections do where: e.user_id == ^user_id and s.open_and_free == true and s.status == :active and e.status == :enrolled, + order_by: [desc: e.inserted_at], select: s ) diff --git a/test/oli/delivery/sections_test.exs b/test/oli/delivery/sections_test.exs index 3a6edeb8d42..fdebd511377 100644 --- a/test/oli/delivery/sections_test.exs +++ b/test/oli/delivery/sections_test.exs @@ -2341,4 +2341,44 @@ defmodule Oli.Delivery.SectionsTest do assert Sections.get_sections_containing_resources_of_given_project(-1) == [] end end + + describe "list_user_open_and_free_sections/1" do + test "lists the courses the user is enrolled to, sorted by enrollment date descending" do + user = insert(:user) + + # Create sections + section_1 = insert(:section, title: "Elixir", open_and_free: true) + section_2 = insert(:section, title: "Phoenix", open_and_free: true) + section_3 = insert(:section, title: "LiveView", open_and_free: true) + + # Enroll user to sections in a different order as sections were created + insert(:enrollment, %{ + section: section_2, + user: user, + inserted_at: ~U[2023-01-01 00:00:00Z], + updated_at: ~U[2023-01-01 00:00:00Z] + }) + + insert(:enrollment, %{ + section: section_3, + user: user, + inserted_at: ~U[2023-01-02 00:00:00Z], + updated_at: ~U[2023-01-02 00:00:00Z] + }) + + insert(:enrollment, %{ + section: section_1, + user: user, + inserted_at: ~U[2023-01-03 00:00:00Z], + updated_at: ~U[2023-01-03 00:00:00Z] + }) + + # function returns sections sorted by enrollment date descending + [s1, s3, s2] = Sections.list_user_open_and_free_sections(user) + + assert s1.title == "Elixir" + assert s3.title == "LiveView" + assert s2.title == "Phoenix" + end + end end diff --git a/test/oli_web/live/workspace/course_author/bibliography_test.exs b/test/oli_web/live/workspace/course_author/bibliography_test.exs index a7a19f51ff8..fa201b4c614 100644 --- a/test/oli_web/live/workspace/course_author/bibliography_test.exs +++ b/test/oli_web/live/workspace/course_author/bibliography_test.exs @@ -1,4 +1,4 @@ -defmodule OliWeb.Workspaces.CourseAuthor.ObjectivesLiveTest do +defmodule OliWeb.Workspaces.CourseAuthor.BibliographyLiveTest do use OliWeb.ConnCase import Oli.Factory From fb60c7cfeab48e4d37ad959aa589e527df809c80 Mon Sep 17 00:00:00 2001 From: Francisco-Castro <47334502+Francisco-Castro@users.noreply.github.com> Date: Thu, 12 Sep 2024 14:52:58 -0400 Subject: [PATCH 6/9] [BUG FIX] [MER-3330] students see deadline message with suggested by dates (#5094) * [MER-3330] Add a conditional branch to check_end_date to allow students to attempt the activity when it is set to Suggested By * [MER-3330] Fix tests --- lib/oli/delivery/settings.ex | 17 ++++++--- test/oli/delivery/attempts/was_late_test.exs | 37 +++++++++++--------- test/oli/delivery/settings_test.exs | 6 ++-- 3 files changed, 37 insertions(+), 23 deletions(-) diff --git a/lib/oli/delivery/settings.ex b/lib/oli/delivery/settings.ex index 4b3f023ded0..158cd0bfd3c 100644 --- a/lib/oli/delivery/settings.ex +++ b/lib/oli/delivery/settings.ex @@ -268,11 +268,18 @@ defmodule Oli.Delivery.Settings do def check_end_date(%Combined{end_date: end_date} = effective_settings) do effective_end_date = DateTime.add(end_date, effective_settings.grace_period, :minute) - if DateTime.compare(effective_end_date, DateTime.utc_now()) == :gt or - effective_settings.late_start == :allow do - {:allowed} - else - {:end_date_passed} + cond do + DateTime.compare(effective_end_date, DateTime.utc_now()) == :gt -> + {:allowed} + + effective_settings.late_start == :allow -> + {:allowed} + + effective_settings.scheduling_type == :read_by -> + {:allowed} + + true -> + {:end_date_passed} end end diff --git a/test/oli/delivery/attempts/was_late_test.exs b/test/oli/delivery/attempts/was_late_test.exs index d89224eaef8..ac15159e709 100644 --- a/test/oli/delivery/attempts/was_late_test.exs +++ b/test/oli/delivery/attempts/was_late_test.exs @@ -1,11 +1,12 @@ defmodule Oli.Delivery.Attempts.WasLateTest do use Oli.DataCase - alias Oli.Delivery.Attempts.PageLifecycle - alias Oli.Delivery.Attempts.Core alias Lti_1p3.Tool.ContextRoles + alias Oli.Delivery.Attempts.Core + alias Oli.Delivery.Attempts.PageLifecycle alias Oli.Delivery.Attempts.PageLifecycle.AttemptState alias Oli.Delivery.Attempts.PageLifecycle.FinalizationSummary + alias Oli.Delivery.Sections.SectionResource @content_manual %{ "stem" => "2", @@ -188,11 +189,6 @@ defmodule Oli.Delivery.Attempts.WasLateTest do yesterday = DateTime.utc_now() |> DateTime.add(-1, :day) - effective_settings = %Oli.Delivery.Settings.Combined{ - end_date: yesterday, - late_start: :disallow - } - sr = Oli.Delivery.Sections.get_section_resource(section.id, page.resource.id) Oli.Delivery.Sections.update_section_resource(sr, %{ @@ -204,15 +200,24 @@ defmodule Oli.Delivery.Attempts.WasLateTest do activity_provider = &Oli.Delivery.ActivityProvider.provide/6 - assert {:error, {:end_date_passed}} == - PageLifecycle.start( - page.revision.slug, - section.slug, - datashop_session_id_user1, - user, - effective_settings, - activity_provider - ) + scheduling_types = + Ecto.Enum.values(SectionResource, :scheduling_type) |> List.delete(:read_by) + + for scheduling_type <- scheduling_types do + assert {:error, {:end_date_passed}} == + PageLifecycle.start( + page.revision.slug, + section.slug, + datashop_session_id_user1, + user, + %Oli.Delivery.Settings.Combined{ + end_date: yesterday, + late_start: :disallow, + scheduling_type: scheduling_type + }, + activity_provider + ) + end end end end diff --git a/test/oli/delivery/settings_test.exs b/test/oli/delivery/settings_test.exs index ebca7e8ff0e..d52b7b78ce6 100644 --- a/test/oli/delivery/settings_test.exs +++ b/test/oli/delivery/settings_test.exs @@ -15,14 +15,16 @@ defmodule Oli.Delivery.SettingsTest do assert {:no_attempts_remaining} == Settings.new_attempt_allowed(%Combined{max_attempts: 5}, 5, []) - assert {:blocking_gates} == Settings.new_attempt_allowed(%Combined{max_attempts: 5}, 1, [1]) + assert {:blocking_gates} == + Settings.new_attempt_allowed(%Combined{max_attempts: 5}, 1, [1]) assert {:end_date_passed} == Settings.new_attempt_allowed( %Combined{ max_attempts: 5, late_start: :disallow, - end_date: ~U[2020-01-01 00:00:00Z] + end_date: ~U[2020-01-01 00:00:00Z], + scheduling_type: :due_by }, 1, [] From a64c79c04df1fa4bb8d3f02f06953bb99ce0dd25 Mon Sep 17 00:00:00 2001 From: Francisco-Castro <47334502+Francisco-Castro@users.noreply.github.com> Date: Thu, 12 Sep 2024 19:04:53 -0400 Subject: [PATCH 7/9] [FEATURE] [MER-3602] Migrate authoring Products LiveView to new workspace layout and LiveSession (#5069) * [MER-3602] Move content to ProductsLive from ProductsView * [MER-3602] Add include archived product selector and Fix redirect after creating a new product * [MER-3602] Change the product's URL in products_live to point to details_live * [MER-3602] Adapt sidebar to handle url params * [MER-3602] Re-route the base project url to workspace's overview * [MER-3602] Adjust error and info messages to include a close button * [MER-3602] Adjust flash message width to use the remaining screen space. * [MER-3602] Fix tests * [MER-3602] Split workspaces sessions * [MER-3602] Fix a duplicate and incorrect test module name * [MER-3602] Fix spec param type * [MER-3602] Move AuthorizeProject up to the live session * [MER-3602] Add SetProjectOrSection * Rename file to fix typo in name * [MER-3602] Add testing for ProductsLive module * [MER-3602] Pluralized folder name from 'workspace' to 'workspaces'. * [MER-3602] Fix sidebar in Product workspace * [MER-3602] Add testing for DetailsLive page * [MER-3602] Rename live_session to workspaces --- lib/oli/authoring/editing/resource_editor.ex | 2 +- lib/oli_web/components/delivery/layouts.ex | 48 ++- .../components/layouts/workspace.html.heex | 38 ++- ...tabel_model.ex => products_table_model.ex} | 25 +- .../course_author/activities_live.ex | 2 - .../course_author/activity_bank_live.ex | 2 - .../course_author/bibliography_live.ex | 2 - .../course_author/curriculum_live.ex | 2 - .../course_author/experiments_live.ex | 2 - .../course_author/objectives_live.ex | 2 - .../course_author/products/details_live.ex | 302 ++++++++++++++++++ .../workspaces/course_author/products_live.ex | 215 ++++++++++++- .../workspaces/course_author/review_live.ex | 2 - .../live_session_plugs/authorize_project.ex | 7 +- .../set_project_or_section.ex | 18 ++ lib/oli_web/live_session_plugs/set_sidebar.ex | 14 +- lib/oli_web/router.ex | 9 +- .../course_author/bibliography_test.exs | 0 .../course_author/curriculum_live_test.exs | 0 .../course_author/experiments_live_test.exs | 0 .../course_author/objectives_live_test.exs | 18 +- .../products/details_live_test.exs | 102 ++++++ .../course_author/products_live_test.exs | 212 ++++++++++++ .../course_author/publish_live_test.exs | 0 .../course_author/qa_logic_test.exs | 0 .../course_author_test.exs | 0 .../instructor/dashboard_live_test.exs | 0 .../instructor_test.exs | 0 .../student_test.exs | 0 29 files changed, 950 insertions(+), 74 deletions(-) rename lib/oli_web/live/products/{products_tabel_model.ex => products_table_model.ex} (63%) create mode 100644 lib/oli_web/live/workspaces/course_author/products/details_live.ex create mode 100644 lib/oli_web/live_session_plugs/set_project_or_section.ex rename test/oli_web/live/{workspace => workspaces}/course_author/bibliography_test.exs (100%) rename test/oli_web/live/{workspace => workspaces}/course_author/curriculum_live_test.exs (100%) rename test/oli_web/live/{workspace => workspaces}/course_author/experiments_live_test.exs (100%) rename test/oli_web/live/{workspace => workspaces}/course_author/objectives_live_test.exs (97%) create mode 100644 test/oli_web/live/workspaces/course_author/products/details_live_test.exs create mode 100644 test/oli_web/live/workspaces/course_author/products_live_test.exs rename test/oli_web/live/{workspace => workspaces}/course_author/publish_live_test.exs (100%) rename test/oli_web/live/{workspace => workspaces}/course_author/qa_logic_test.exs (100%) rename test/oli_web/live/{workspace => workspaces}/course_author_test.exs (100%) rename test/oli_web/live/{workspace => workspaces}/instructor/dashboard_live_test.exs (100%) rename test/oli_web/live/{workspace => workspaces}/instructor_test.exs (100%) rename test/oli_web/live/{workspace => workspaces}/student_test.exs (100%) diff --git a/lib/oli/authoring/editing/resource_editor.ex b/lib/oli/authoring/editing/resource_editor.ex index 7651792cf53..927020a7756 100644 --- a/lib/oli/authoring/editing/resource_editor.ex +++ b/lib/oli/authoring/editing/resource_editor.ex @@ -19,7 +19,7 @@ defmodule Oli.Authoring.Editing.ResourceEditor do .`{:ok, [%Revision{}]}` when the resources are retrieved .`{:error, {:not_found}}` if the project is not found """ - @spec list(String.t(), any(), Integer.t()) :: + @spec list(String.t(), any(), integer()) :: {:ok, [%Revision{}]} | {:error, {:not_found}} def list(project_slug, author, resource_type_id) do with {:ok, project} <- Course.get_project_by_slug(project_slug) |> trap_nil(), diff --git a/lib/oli_web/components/delivery/layouts.ex b/lib/oli_web/components/delivery/layouts.ex index 24cd6a69112..61ee0cf2ea7 100644 --- a/lib/oli_web/components/delivery/layouts.ex +++ b/lib/oli_web/components/delivery/layouts.ex @@ -3,6 +3,7 @@ defmodule OliWeb.Components.Delivery.Layouts do This module contains the layout components for the delivery UI. """ use OliWeb, :html + use OliWeb, :verified_routes import OliWeb.Components.Utils @@ -226,6 +227,8 @@ defmodule OliWeb.Components.Delivery.Layouts do attr(:active_view, :atom, default: nil) attr(:resource_slug, :string, default: nil) attr(:active_tab, :atom, default: nil) + attr(:url_params, :map, default: %{}) + attr(:uri, :string, default: "") def workspace_sidebar_toggler(assigns) do ~H""" @@ -238,7 +241,9 @@ defmodule OliWeb.Components.Delivery.Layouts do @active_view, @sidebar_expanded, @resource_slug, - @active_tab + @active_tab, + @url_params, + @uri ) ) |> JS.hide(to: "div[role='expandable_submenu']") @@ -262,6 +267,8 @@ defmodule OliWeb.Components.Delivery.Layouts do attr(:resource_title, :string) attr(:resource_slug, :string) attr(:active_tab, :atom) + attr(:url_params, :map, default: %{}) + attr(:uri, :string, default: "") def workspace_sidebar_nav(assigns) do ~H""" @@ -309,6 +316,8 @@ defmodule OliWeb.Components.Delivery.Layouts do sidebar_expanded={@sidebar_expanded} resource_slug={@resource_slug} active_tab={@active_tab} + url_params={@url_params} + uri={@uri} />

OliWeb.Workspaces.CourseAuthor - :instructor -> OliWeb.Workspaces.Instructor - :student -> OliWeb.Workspaces.Student - _ -> raise "Unknown workspace: #{active_workspace}" - end - - item_view = active_view |> Atom.to_string() |> Macro.camelize() - - view_module = - Module.concat([base_module, item_view <> "Live"]) + params = Map.merge(url_params, %{sidebar_expanded: !sidebar_expanded}) case {active_workspace, active_view} do {:course_author, nil} -> ~p"/workspaces/course_author?#{params}" {:course_author, _view_slug} -> - Routes.live_path(OliWeb.Endpoint, view_module, resource_slug, params) + decode_uri(uri, params) {:instructor, nil} -> ~p"/workspaces/instructor?#{params}" @@ -551,6 +549,22 @@ defmodule OliWeb.Components.Delivery.Layouts do end end + defp decode_uri(uri, params) do + url_path = uri |> URI.parse() |> Map.get(:path) + split_path = if url_path, do: String.split(url_path, "/"), else: uri + + case split_path do + ["", "workspaces", "course_author", project_slug, "products", product_slug] -> + ~p"/workspaces/course_author/#{project_slug}/products/#{product_slug}?#{params}" + + ["", "workspaces", "course_author", project_slug, "products"] -> + ~p"/workspaces/course_author/#{project_slug}/products?#{params}" + + _ -> + ~p"/" + end + end + defp path_for_workspace(target_workspace, sidebar_expanded) do url_params = %{ sidebar_expanded: sidebar_expanded diff --git a/lib/oli_web/components/layouts/workspace.html.heex b/lib/oli_web/components/layouts/workspace.html.heex index 20be4c493d9..5b49268542a 100644 --- a/lib/oli_web/components/layouts/workspace.html.heex +++ b/lib/oli_web/components/layouts/workspace.html.heex @@ -29,6 +29,8 @@ resource_title={assigns[:resource_title]} resource_slug={assigns[:resource_slug]} active_tab={assigns[:active_tab]} + url_params={assigns[:url_params] || %{}} + uri={assigns[:uri] || ""} /> -
+
<%= if Phoenix.Flash.get(@flash, :info) do %> -
- <% end %> <%= if Phoenix.Flash.get(@flash, :error) do %> -
- <% end %>
diff --git a/lib/oli_web/live/products/products_tabel_model.ex b/lib/oli_web/live/products/products_table_model.ex similarity index 63% rename from lib/oli_web/live/products/products_tabel_model.ex rename to lib/oli_web/live/products/products_table_model.ex index 6ec9fefda45..df3f5d9acb6 100644 --- a/lib/oli_web/live/products/products_tabel_model.ex +++ b/lib/oli_web/live/products/products_table_model.ex @@ -1,15 +1,17 @@ defmodule OliWeb.Products.ProductsTableModel do + use OliWeb, :verified_routes alias OliWeb.Common.Table.{ColumnSpec, Common, SortableTableModel} alias OliWeb.Router.Helpers, as: Routes - def new(products, ctx) do + def new(products, ctx, project_slug \\ "") do SortableTableModel.new( rows: products, column_specs: [ %ColumnSpec{ name: :title, label: "Product Title", - render_fn: &__MODULE__.render_title_column/3 + render_fn: + &__MODULE__.render_title_column(Map.put(&1, :project_slug, project_slug), &2, &3) }, %ColumnSpec{name: :status, label: "Status"}, %ColumnSpec{ @@ -20,7 +22,8 @@ defmodule OliWeb.Products.ProductsTableModel do %ColumnSpec{ name: :base_project_id, label: "Base Project", - render_fn: &__MODULE__.render_project_column/3 + render_fn: + &__MODULE__.render_project_column(Map.put(&1, :project_slug, project_slug), &2, &3) }, %ColumnSpec{ name: :inserted_at, @@ -30,9 +33,7 @@ defmodule OliWeb.Products.ProductsTableModel do ], event_suffix: "", id_field: [:id], - data: %{ - ctx: ctx - } + data: %{ctx: ctx} ) end @@ -48,13 +49,21 @@ defmodule OliWeb.Products.ProductsTableModel do end def render_title_column(assigns, %{title: title, slug: slug}, _) do - route_path = Routes.live_path(OliWeb.Endpoint, OliWeb.Products.DetailsView, slug) + route_path = + case Map.get(assigns, :project_slug) do + "" -> Routes.live_path(OliWeb.Endpoint, OliWeb.Products.DetailsView, slug) + project_slug -> ~p"/workspaces/course_author/#{project_slug}/products/#{slug}" + end + SortableTableModel.render_link_column(assigns, title, route_path) end def render_project_column(assigns, %{base_project: base_project}, _) do route_path = - Routes.live_path(OliWeb.Endpoint, OliWeb.Projects.OverviewLive, base_project.slug) + case Map.get(assigns, :project_slug) do + "" -> Routes.live_path(OliWeb.Endpoint, OliWeb.Projects.OverviewLive, base_project.slug) + _project_slug -> ~p"/workspaces/course_author/#{base_project}/overview" + end SortableTableModel.render_link_column(assigns, base_project.title, route_path) end diff --git a/lib/oli_web/live/workspaces/course_author/activities_live.ex b/lib/oli_web/live/workspaces/course_author/activities_live.ex index 39cdbc8e7bf..f1edd63e995 100644 --- a/lib/oli_web/live/workspaces/course_author/activities_live.ex +++ b/lib/oli_web/live/workspaces/course_author/activities_live.ex @@ -13,8 +13,6 @@ defmodule OliWeb.Workspaces.CourseAuthor.ActivitiesLive do alias OliWeb.Router.Helpers, as: Routes alias OliWeb.Workspaces.CourseAuthor.Activities.ActivitiesTableModel - on_mount {OliWeb.LiveSessionPlugs.AuthorizeProject, :default} - @limit 25 @default_options %ActivityBrowseOptions{ activity_type_id: nil, diff --git a/lib/oli_web/live/workspaces/course_author/activity_bank_live.ex b/lib/oli_web/live/workspaces/course_author/activity_bank_live.ex index ad1205c929c..497c9a11c07 100644 --- a/lib/oli_web/live/workspaces/course_author/activity_bank_live.ex +++ b/lib/oli_web/live/workspaces/course_author/activity_bank_live.ex @@ -6,8 +6,6 @@ defmodule OliWeb.Workspaces.CourseAuthor.ActivityBankLive do alias OliWeb.Common.React alias OliWeb.Router.Helpers, as: Routes - on_mount {OliWeb.LiveSessionPlugs.AuthorizeProject, :default} - @impl Phoenix.LiveView def mount(_params, _session, socket) do %{project: project, current_author: author, ctx: ctx} = socket.assigns diff --git a/lib/oli_web/live/workspaces/course_author/bibliography_live.ex b/lib/oli_web/live/workspaces/course_author/bibliography_live.ex index 50435c63b0a..68d387d6f96 100644 --- a/lib/oli_web/live/workspaces/course_author/bibliography_live.ex +++ b/lib/oli_web/live/workspaces/course_author/bibliography_live.ex @@ -4,8 +4,6 @@ defmodule OliWeb.Workspaces.CourseAuthor.BibliographyLive do alias Oli.Accounts alias OliWeb.Common.React - on_mount {OliWeb.LiveSessionPlugs.AuthorizeProject, :default} - @impl Phoenix.LiveView def mount(_params, _session, socket) do project = socket.assigns.project diff --git a/lib/oli_web/live/workspaces/course_author/curriculum_live.ex b/lib/oli_web/live/workspaces/course_author/curriculum_live.ex index 251f2492c98..862ae0ec5b1 100644 --- a/lib/oli_web/live/workspaces/course_author/curriculum_live.ex +++ b/lib/oli_web/live/workspaces/course_author/curriculum_live.ex @@ -39,8 +39,6 @@ defmodule OliWeb.Workspaces.CourseAuthor.CurriculumLive do alias OliWeb.Components.Modal alias OliWeb.Curriculum.Container.ContainerLiveHelpers - on_mount {OliWeb.LiveSessionPlugs.AuthorizeProject, :default} - @impl Phoenix.LiveView def mount(params, session, socket) do project = socket.assigns.project diff --git a/lib/oli_web/live/workspaces/course_author/experiments_live.ex b/lib/oli_web/live/workspaces/course_author/experiments_live.ex index 9e0ad823024..ce6243b921d 100644 --- a/lib/oli_web/live/workspaces/course_author/experiments_live.ex +++ b/lib/oli_web/live/workspaces/course_author/experiments_live.ex @@ -15,8 +15,6 @@ defmodule OliWeb.Workspaces.CourseAuthor.ExperimentsLive do alias OliWeb.Common.Modal.{DeleteModal, FormModal} alias OliWeb.Router.Helpers, as: Routes - on_mount {OliWeb.LiveSessionPlugs.AuthorizeProject, :default} - @alternatives_type_id ResourceType.id_for_alternatives() @default_error_message "Something went wrong. Please refresh the page and try again." diff --git a/lib/oli_web/live/workspaces/course_author/objectives_live.ex b/lib/oli_web/live/workspaces/course_author/objectives_live.ex index fa56b169536..43c04ee554b 100644 --- a/lib/oli_web/live/workspaces/course_author/objectives_live.ex +++ b/lib/oli_web/live/workspaces/course_author/objectives_live.ex @@ -25,8 +25,6 @@ defmodule OliWeb.Workspaces.CourseAuthor.ObjectivesLive do TableModel } - on_mount {OliWeb.LiveSessionPlugs.AuthorizeProject, :default} - @table_filter_fn &__MODULE__.filter_rows/3 @table_push_patch_path &__MODULE__.live_path/2 diff --git a/lib/oli_web/live/workspaces/course_author/products/details_live.ex b/lib/oli_web/live/workspaces/course_author/products/details_live.ex new file mode 100644 index 00000000000..93a8e75454d --- /dev/null +++ b/lib/oli_web/live/workspaces/course_author/products/details_live.ex @@ -0,0 +1,302 @@ +defmodule OliWeb.Workspaces.CourseAuthor.Products.DetailsLive do + use OliWeb, :live_view + use OliWeb.Common.Modal + + alias Oli.Accounts + alias Oli.Authoring.Course + alias Oli.Delivery.Paywall + alias Oli.Delivery.Sections + alias Oli.Delivery.Sections.Blueprint + alias Oli.Delivery.Sections.Section + alias Oli.Inventories + alias Oli.Utils.S3Storage + alias OliWeb.Common.Confirm + alias OliWeb.Common.SessionContext + alias OliWeb.Products.Details.Actions + alias OliWeb.Products.Details.Content + alias OliWeb.Products.Details.Edit + alias OliWeb.Products.Details.ImageUpload + alias OliWeb.Products.ProductsToTransferCodes + alias OliWeb.Sections.Mount + + require Logger + + def mount(%{"product_id" => product_slug}, session, socket) do + case Mount.for(product_slug, session) do + {:error, e} -> + Mount.handle_error(socket, {:error, e}) + + {_, _, product} -> + ctx = SessionContext.init(socket, session) + + author = socket.assigns.ctx.author + base_project = Course.get_project!(product.base_project_id) + publishers = Inventories.list_publishers() + is_admin = Accounts.has_admin_role?(author) + changeset = Section.changeset(product, %{}) + project = socket.assigns.project + + latest_publications = + Sections.check_for_available_publication_updates(product) + + {:ok, + assign(socket, + publishers: publishers, + updates: latest_publications, + author: author, + product: product, + is_admin: is_admin, + changeset: changeset, + title: "Edit Product", + show_confirm: false, + base_project: base_project, + ctx: ctx, + resource_slug: project.slug, + resource_title: project.title, + active_workspace: :course_author, + active_view: :products + ) + |> Phoenix.LiveView.allow_upload(:cover_image, + accept: ~w(.jpg .jpeg .png), + max_entries: 1, + auto_upload: true, + max_file_size: 5_000_000 + )} + end + end + + def render(assigns) do + ~H""" + <%= render_modal(assigns) %> +
+
+
+

Details

+
+ The Product title and description will be shown + to instructors when they create their course section. +
+
+
+ +
+
+
+
+

Content

+
+ Manage and customize the presentation of content in this product. +
+
+
+ +
+
+ +
+
+

Cover Image

+
+ Manage the cover image for this product. Max file size is 5 MB. +
+
+
+ +
+
+ +
+
+

Actions

+
+
+ +
+
+ <%= if @show_confirm do %> + + Are you sure that you wish to duplicate this product? + + <% end %> +
+ """ + end + + # Needed to handle sidebar_expaded event + def handle_params(_params, _uri, socket) do + {:noreply, socket} + end + + def handle_event("validate", %{"section" => params}, socket) do + changeset = Sections.change_section(socket.assigns.product, params) + {:noreply, assign(socket, changeset: changeset)} + end + + def handle_event("request_duplicate", _, socket) do + {:noreply, assign(socket, show_confirm: true)} + end + + def handle_event("cancel_modal", _, socket) do + {:noreply, assign(socket, show_confirm: false)} + end + + def handle_event("_bsmodal.unmount", _, socket) do + {:noreply, assign(socket, show_confirm: false)} + end + + def handle_event("duplicate", _, socket) do + case Blueprint.duplicate(socket.assigns.product) do + {:ok, duplicate} -> + {:noreply, + redirect(socket, + to: + ~p"/workspaces/course_author/#{socket.assigns.base_project}/products/#{duplicate.slug}" + )} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Could not duplicate product")} + end + end + + def handle_event("save", %{"section" => params}, socket) do + socket = clear_flash(socket) + + case Sections.update_section(socket.assigns.product, decode_welcome_title(params)) do + {:ok, section} -> + socket = put_flash(socket, :info, "Product changes saved") + + {:noreply, assign(socket, product: section, changeset: Section.changeset(section, %{}))} + + {:error, %Ecto.Changeset{} = changeset} -> + socket = put_flash(socket, :error, "Couldn't update product title") + {:noreply, assign(socket, changeset: changeset)} + end + end + + def handle_event("validate_image", _, socket) do + {:noreply, socket} + end + + def handle_event("update_image", _, socket) do + bucket_name = Application.fetch_env!(:oli, :s3_media_bucket_name) + + uploaded_files = + consume_uploaded_entries(socket, :cover_image, fn meta, entry -> + temp_file_path = meta.path + section_path = "sections/#{socket.assigns.product.slug}" + image_file_name = "#{entry.uuid}.#{ext(entry)}" + upload_path = "#{section_path}/#{image_file_name}" + + S3Storage.upload_file(bucket_name, upload_path, temp_file_path) + end) + + with uploaded_path <- Enum.at(uploaded_files, 0), + {:ok, section} <- + Sections.update_section(socket.assigns.product, %{cover_image: uploaded_path}) do + socket = put_flash(socket, :info, "Product changes saved") + {:noreply, assign(socket, product: section, changeset: Section.changeset(section, %{}))} + else + {:error, %Ecto.Changeset{} = changeset} -> + socket = put_flash(socket, :info, "Couldn't update product image") + {:noreply, assign(socket, changeset: changeset)} + + {:error, payload} -> + Logger.error("Error uploading product image to S3: #{inspect(payload)}") + socket = put_flash(socket, :info, "Couldn't update product image") + {:noreply, socket} + end + end + + def handle_event("cancel_upload", %{"ref" => ref}, socket) do + {:noreply, cancel_upload(socket, :cover_image, ref)} + end + + def handle_event("show_products_to_transfer", _, socket) do + product_id = socket.assigns.product.id + base_project_id = socket.assigns.base_project.id + + sections = Sections.get_sections_by(base_project_id: base_project_id, type: :blueprint) + + products_to_transfer = Enum.filter(sections, &(&1.id != product_id)) + + modal_assigns = %{ + id: "products_to_transfer_modal", + products_to_transfer: products_to_transfer, + changeset: socket.assigns.changeset + } + + modal = fn assigns -> + ~H""" + + """ + end + + {:noreply, show_modal(socket, modal, modal_assigns: modal_assigns)} + end + + def handle_event("submit_transfer_payment_codes", %{"product_id" => product_id}, socket) do + socket = clear_flash(socket) + + socket = + case Paywall.transfer_payment_codes(socket.assigns.product.id, product_id) do + {0, _nil} -> + put_flash(socket, :error, "Could not transfer payment codes") + + {_count, _} -> + socket = put_flash(socket, :info, "Payment codes transferred successfully") + + redirect(socket, + to: ~p"/authoring/products/#{socket.assigns.product.slug}" + ) + end + + {:noreply, hide_modal(socket, modal_assigns: nil)} + end + + def handle_event("welcome_title_change", %{"values" => welcome_title}, socket) do + changeset = + Ecto.Changeset.put_change(socket.assigns.changeset, :welcome_title, %{ + "type" => "p", + "children" => welcome_title + }) + + {:noreply, assign(socket, changeset: changeset)} + end + + defp ext(entry) do + [ext | _] = MIME.extensions(entry.client_type) + ext + end + + defp decode_welcome_title(%{"welcome_title" => wt} = project_params) when wt in [nil, ""], + do: project_params + + defp decode_welcome_title(project_params) do + Map.update(project_params, "welcome_title", nil, &Poison.decode!(&1)) + end +end diff --git a/lib/oli_web/live/workspaces/course_author/products_live.ex b/lib/oli_web/live/workspaces/course_author/products_live.ex index 4a0aee0aa45..29a2bf06ad5 100644 --- a/lib/oli_web/live/workspaces/course_author/products_live.ex +++ b/lib/oli_web/live/workspaces/course_author/products_live.ex @@ -1,30 +1,231 @@ defmodule OliWeb.Workspaces.CourseAuthor.ProductsLive do use OliWeb, :live_view + use OliWeb, :verified_routes + + import Phoenix.Component + import OliWeb.DelegatedEvents + + alias __MODULE__ + alias Oli.Delivery.Sections.Blueprint + alias Oli.Publishing + alias Oli.Repo.Paging + alias Oli.Repo.Sorting + alias OliWeb.Common.Check + alias OliWeb.Common.PagedTable + alias OliWeb.Common.Params + alias OliWeb.Common.SessionContext + alias OliWeb.Common.Table.SortableTableModel + alias OliWeb.Products.ProductsTableModel + + @max_items_per_page 20 + @initial_offset 0 + @initial_create_form to_form(%{"product_title" => ""}, as: "product_form") @impl Phoenix.LiveView - def mount(_params, _session, socket) do + def mount(params, session, socket) do project = socket.assigns.project + include_archived = Params.get_boolean_param(params, "include_archived", false) + + products = get_products(socket.assigns) + + ctx = SessionContext.init(socket, session) + {:ok, table_model} = ProductsTableModel.new(products, ctx, project.slug) + published? = Publishing.project_published?(project.slug) {:ok, assign(socket, resource_slug: project.slug, resource_title: project.title, active_workspace: :course_author, - active_view: :products + active_view: :products, + published?: published?, + is_admin_view: false, + include_archived: include_archived, + limit: @max_items_per_page, + offset: @initial_offset, + table_model: table_model, + create_product_form: @initial_create_form, + ctx: ctx )} end @impl Phoenix.LiveView - def handle_params(_params, _url, socket) do - {:noreply, socket} + def handle_params(params, _, socket) do + # If the sidebar was toggled, we don't need to update the table model + sidebar_was_toggled = Map.keys(socket.assigns.__changed__) == [:sidebar_expanded] + + if sidebar_was_toggled do + {:noreply, socket} + else + table_model = + SortableTableModel.update_from_params(socket.assigns.table_model, params) + + offset = Params.get_int_param(params, "offset", @initial_offset) + include_archived = Params.get_boolean_param(params, "include_archived", false) + + products = get_products(socket.assigns) + + table_model = Map.put(table_model, :rows, products) + + {:noreply, + assign(socket, + offset: offset, + table_model: table_model, + include_archived: include_archived + )} + end end @impl Phoenix.LiveView def render(assigns) do ~H""" -

- Placeholder for Products -

+
+ <%= if @published? do %> + <.form + :let={f} + for={@create_product_form} + as={:create_product_form} + class="full flex items-end w-full gap-1" + phx-submit="create" + > +
+ <.input + class="full" + field={f[:product_title]} + label="Create a new product with title:" + required + /> +
+ + + + + Include archived Products + + +
+ + + <% else %> +
Products cannot be created until project is published.
+ <% end %> +
""" end + + @impl Phoenix.LiveView + def handle_event( + "create", + %{"create_product_form" => %{"product_title" => product_title}}, + socket + ) do + %{customizations: customizations, slug: project_slug} = socket.assigns.project + + customizations = + case customizations do + nil -> nil + labels -> Map.from_struct(labels) + end + + blueprint = Blueprint.create_blueprint(project_slug, product_title, customizations) + + case blueprint do + {:ok, _blueprint} -> + products = get_products(socket.assigns) + + {:ok, table_model} = ProductsTableModel.new(products, socket.assigns.ctx, project_slug) + + {:noreply, + socket + |> put_flash(:info, "Product successfully created.") + |> assign(table_model: table_model)} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Could not create product")} + end + end + + def handle_event("include_archived", __params, socket) do + project_id = if socket.assigns.project === nil, do: nil, else: socket.assigns.project.id + + include_archived = !socket.assigns.include_archived + + %{offset: offset, limit: limit, table_model: table_model} = socket.assigns + + products = + Blueprint.browse( + %Paging{offset: offset, limit: limit}, + %Sorting{direction: table_model.sort_order, field: table_model.sort_by_spec.name}, + include_archived: include_archived, + project_id: project_id + ) + + table_model = Map.put(table_model, :rows, products) + + socket = + assign(socket, + include_archived: include_archived, + products: products, + table_model: table_model + ) + + patch_with(socket, %{include_archived: include_archived}) + end + + def handle_event("hide_overview", _, socket) do + {:noreply, assign(socket, show_feature_overview: false)} + end + + def handle_event(event, params, socket) do + delegate_to({event, params, socket, &ProductsLive.patch_with/2}, [ + &PagedTable.handle_delegated/4 + ]) + end + + def live_path(socket, params) do + ~p"/workspaces/course_author/#{socket.assigns.project.slug}/products?#{params}" + end + + def patch_with(socket, changes) do + path = live_path(socket, Map.merge(url_params(socket.assigns), changes)) + + {:noreply, push_patch(socket, to: path, replace: true)} + end + + defp url_params(assigns) do + %{ + sort_by: assigns.table_model.sort_by_spec.name, + sort_order: assigns.table_model.sort_order, + offset: assigns.offset, + include_archived: assigns.include_archived, + sidebar_expanded: assigns.sidebar_expanded + } + end + + defp get_products(assigns) do + offset = assigns[:offset] || @initial_offset + limit = assigns[:limit] || @max_items_per_page + + table_model = assigns[:table_model] + direction = if table_model, do: assigns.table_model.sort_order, else: :asc + field = if table_model, do: assigns.table_model.sort_by_spec.name, else: :title + + include_archived = get_in(assigns, [:include_archived]) || false + + Blueprint.browse( + %Paging{offset: offset, limit: limit}, + %Sorting{direction: direction, field: field}, + include_archived: include_archived, + project_id: assigns.project.id + ) + end end diff --git a/lib/oli_web/live/workspaces/course_author/review_live.ex b/lib/oli_web/live/workspaces/course_author/review_live.ex index f8498e3a7a2..339a4980b1c 100644 --- a/lib/oli_web/live/workspaces/course_author/review_live.ex +++ b/lib/oli_web/live/workspaces/course_author/review_live.ex @@ -8,8 +8,6 @@ defmodule OliWeb.Workspaces.CourseAuthor.ReviewLive do alias OliWeb.Common.Utils alias OliWeb.Workspaces.CourseAuthor.Qa.{State, WarningFilter, WarningSummary, WarningDetails} - on_mount {OliWeb.LiveSessionPlugs.AuthorizeProject, :default} - @impl Phoenix.LiveView def mount(_params, _session, socket) do %{project: project, ctx: ctx} = socket.assigns diff --git a/lib/oli_web/live_session_plugs/authorize_project.ex b/lib/oli_web/live_session_plugs/authorize_project.ex index 28086041d45..6df6a574b06 100644 --- a/lib/oli_web/live_session_plugs/authorize_project.ex +++ b/lib/oli_web/live_session_plugs/authorize_project.ex @@ -6,7 +6,8 @@ defmodule OliWeb.LiveSessionPlugs.AuthorizeProject do alias Oli.Accounts alias Oli.Accounts.Author - def on_mount(:default, _params, _session, socket) do + def on_mount(:default, %{"project_id" => project_id}, _session, socket) + when not is_nil(project_id) do with {:author, %Author{} = author} <- {:author, Map.get(socket.assigns, :current_author)}, {:project, %Project{} = project} <- {:project, Map.get(socket.assigns, :project)}, {:access, true} <- {:access, Accounts.can_access?(author, project)}, @@ -20,6 +21,10 @@ defmodule OliWeb.LiveSessionPlugs.AuthorizeProject do end end + def on_mount(:default, _params, _session, socket) do + {:cont, socket} + end + defp halt(message, socket) do {:halt, socket |> put_flash(:error, message) |> redirect(to: ~p"/workspaces/course_author")} end diff --git a/lib/oli_web/live_session_plugs/set_project_or_section.ex b/lib/oli_web/live_session_plugs/set_project_or_section.ex new file mode 100644 index 00000000000..ca9bad6bda1 --- /dev/null +++ b/lib/oli_web/live_session_plugs/set_project_or_section.ex @@ -0,0 +1,18 @@ +defmodule OliWeb.LiveSessionPlugs.SetProjectOrSection do + use OliWeb, :verified_routes + + alias OliWeb.LiveSessionPlugs.SetProject + alias OliWeb.LiveSessionPlugs.SetSection + + def on_mount(:default, %{"project_id" => _project_id} = params, session, socket) do + SetProject.on_mount(:default, params, session, socket) + end + + def on_mount(:default, %{"section_slug" => _section_slug} = params, session, socket) do + SetSection.on_mount(:default, params, session, socket) + end + + def on_mount(:default, _params, _session, socket) do + {:cont, socket} + end +end diff --git a/lib/oli_web/live_session_plugs/set_sidebar.ex b/lib/oli_web/live_session_plugs/set_sidebar.ex index bacd70c8d9f..6a9d424c7ad 100644 --- a/lib/oli_web/live_session_plugs/set_sidebar.ex +++ b/lib/oli_web/live_session_plugs/set_sidebar.ex @@ -22,13 +22,13 @@ defmodule OliWeb.LiveSessionPlugs.SetSidebar do if connected?(socket) do socket = - socket - |> attach_hook(:sidebar_hook, :handle_params, fn - params, _uri, socket -> - {:cont, - assign(socket, - sidebar_expanded: Oli.Utils.string_to_boolean(params["sidebar_expanded"] || "true") - )} + attach_hook(socket, :sidebar_hook, :handle_params, fn + params, uri, socket -> + sidebar_expanded = Oli.Utils.string_to_boolean(params["sidebar_expanded"] || "true") + + socket = assign(socket, uri: uri, sidebar_expanded: sidebar_expanded) + + {:cont, socket} end) {:cont, socket} diff --git a/lib/oli_web/router.ex b/lib/oli_web/router.ex index d73777249f6..93f8f3c3c65 100644 --- a/lib/oli_web/router.ex +++ b/lib/oli_web/router.ex @@ -796,8 +796,8 @@ defmodule OliWeb.Router do OliWeb.LiveSessionPlugs.SetUser, OliWeb.LiveSessionPlugs.SetSidebar, OliWeb.LiveSessionPlugs.SetPreviewMode, - OliWeb.LiveSessionPlugs.SetProject, - OliWeb.LiveSessionPlugs.SetSection + OliWeb.LiveSessionPlugs.SetProjectOrSection, + OliWeb.LiveSessionPlugs.AuthorizeProject ] do scope "/course_author", CourseAuthor do live("/", IndexLive) @@ -814,6 +814,7 @@ defmodule OliWeb.Router do live("/:project_id/review", ReviewLive) live("/:project_id/publish", PublishLive) live("/:project_id/products", ProductsLive) + live("/:project_id/products/:product_id", Products.DetailsLive) live("/:project_id/insights", InsightsLive) end @@ -823,7 +824,9 @@ defmodule OliWeb.Router do live("/:section_slug/:view/:active_tab", DashboardLive) end - live("/student", Student) + scope "/student" do + live("/", Student) + end end end diff --git a/test/oli_web/live/workspace/course_author/bibliography_test.exs b/test/oli_web/live/workspaces/course_author/bibliography_test.exs similarity index 100% rename from test/oli_web/live/workspace/course_author/bibliography_test.exs rename to test/oli_web/live/workspaces/course_author/bibliography_test.exs diff --git a/test/oli_web/live/workspace/course_author/curriculum_live_test.exs b/test/oli_web/live/workspaces/course_author/curriculum_live_test.exs similarity index 100% rename from test/oli_web/live/workspace/course_author/curriculum_live_test.exs rename to test/oli_web/live/workspaces/course_author/curriculum_live_test.exs diff --git a/test/oli_web/live/workspace/course_author/experiments_live_test.exs b/test/oli_web/live/workspaces/course_author/experiments_live_test.exs similarity index 100% rename from test/oli_web/live/workspace/course_author/experiments_live_test.exs rename to test/oli_web/live/workspaces/course_author/experiments_live_test.exs diff --git a/test/oli_web/live/workspace/course_author/objectives_live_test.exs b/test/oli_web/live/workspaces/course_author/objectives_live_test.exs similarity index 97% rename from test/oli_web/live/workspace/course_author/objectives_live_test.exs rename to test/oli_web/live/workspaces/course_author/objectives_live_test.exs index a61b1289fe3..f9f91bb4862 100644 --- a/test/oli_web/live/workspace/course_author/objectives_live_test.exs +++ b/test/oli_web/live/workspaces/course_author/objectives_live_test.exs @@ -299,7 +299,7 @@ defmodule OliWeb.Workspaces.CourseAuthor.ObjectivesLiveTest do |> render_submit(%{"revision" => %{"title" => title, "parent_slug" => ""}}) assert view - |> element("p.alert.alert-info") + |> element(~s{div[role="alert"].alert-info}) |> render() =~ "Objective successfully created" @@ -330,7 +330,7 @@ defmodule OliWeb.Workspaces.CourseAuthor.ObjectivesLiveTest do |> render_submit(%{"revision" => %{"title" => title, "slug" => obj.slug}}) assert view - |> element("p.alert.alert-info") + |> element(~s{div[role="alert"].alert-info}) |> render() =~ "Objective successfully updated" @@ -366,7 +366,7 @@ defmodule OliWeb.Workspaces.CourseAuthor.ObjectivesLiveTest do |> render_click(%{"slug" => obj_a.slug}) assert view - |> element("p.alert.alert-danger") + |> element(~s(div[role="alert"].alert-danger)) |> render() =~ "Could not remove objective if it has sub-objectives associated" @@ -412,7 +412,7 @@ defmodule OliWeb.Workspaces.CourseAuthor.ObjectivesLiveTest do |> render_click(%{"slug" => obj_c.slug, "parent_slug" => ""}) assert view - |> element("p.alert.alert-info") + |> element(~s{div[role="alert"].alert-info}) |> render() =~ "Objective successfully removed" @@ -499,7 +499,7 @@ defmodule OliWeb.Workspaces.CourseAuthor.ObjectivesLiveTest do assert has_element?(view, ".collapse", "#{sub_obj_b.title}") assert view - |> element("p.alert.alert-info") + |> element(~s{div[role="alert"].alert-info}) |> render() =~ "Sub-objective successfully added" @@ -527,7 +527,7 @@ defmodule OliWeb.Workspaces.CourseAuthor.ObjectivesLiveTest do |> render_submit(%{"revision" => %{"title" => title, "parent_slug" => obj.slug}}) assert view - |> element("p.alert.alert-info") + |> element(~s{div[role="alert"].alert-info}) |> render() =~ "Objective successfully created" @@ -563,7 +563,7 @@ defmodule OliWeb.Workspaces.CourseAuthor.ObjectivesLiveTest do |> render_submit(%{"revision" => %{"title" => title, "slug" => sub_obj.slug}}) assert view - |> element("p.alert.alert-info") + |> element(~s{div[role="alert"].alert-info}) |> render() =~ "Objective successfully updated" @@ -599,7 +599,7 @@ defmodule OliWeb.Workspaces.CourseAuthor.ObjectivesLiveTest do |> render_click(%{"slug" => sub_obj.slug, "parent_slug" => obj.slug}) assert view - |> element("p.alert.alert-info") + |> element(~s{div[role="alert"].alert-info}) |> render() =~ "Objective successfully removed" @@ -641,7 +641,7 @@ defmodule OliWeb.Workspaces.CourseAuthor.ObjectivesLiveTest do |> render_click(%{"slug" => sub_obj.slug, "parent_slug" => obj_a.slug}) assert view - |> element("p.alert.alert-info") + |> element(~s{div[role="alert"].alert-info}) |> render() =~ "Objective successfully removed" diff --git a/test/oli_web/live/workspaces/course_author/products/details_live_test.exs b/test/oli_web/live/workspaces/course_author/products/details_live_test.exs new file mode 100644 index 00000000000..a7881da7235 --- /dev/null +++ b/test/oli_web/live/workspaces/course_author/products/details_live_test.exs @@ -0,0 +1,102 @@ +defmodule OliWeb.Workspaces.CourseAuthor.Products.DetailsLiveTest do + use OliWeb.ConnCase + + import Oli.Factory + import Phoenix.LiveViewTest + + alias Oli.Delivery.Sections.Blueprint + + # Testing for the edit form is located in OliWeb.Sections.EditLiveTest + + defp live_view_route(project_slug, product_slug, params), + do: ~p"/workspaces/course_author/#{project_slug}/products/#{product_slug}/?#{params}" + + describe "user cannot access when is not logged in" do + setup [:create_project, :publish_project, :create_product] + + test "redirects to new session when accessing product details view", ctx do + %{conn: conn, project: project, product: product} = ctx + + {:error, {:redirect, %{to: redirect_path, flash: %{"error" => error_msg}}}} = + live(conn, live_view_route(project.slug, product.slug, %{})) + + assert redirect_path == "/workspaces/course_author" + assert error_msg == "You must be logged in to access that project" + + {:ok, _view, html} = live(conn, redirect_path) + + assert html =~ + "\n Welcome to\n \n OLI Torus\n " + end + end + + describe "user cannot access when is logged in as an author but is not an author of the project" do + setup [:author_conn, :create_project, :publish_project, :create_product] + + test "redirects to projects view when accessing the bibliography view", ctx do + %{conn: conn, project: project, product: product} = ctx + + {:error, {:redirect, %{to: redirect_path, flash: %{"error" => error_msg}}}} = + live(conn, live_view_route(project.slug, product.slug, %{})) + + assert redirect_path == "/workspaces/course_author" + assert error_msg == "You don't have access to that project" + + {:ok, view, _html} = live(conn, redirect_path) + + assert render(element(view, "#button-new-project")) =~ "New Project" + end + end + + ##### HELPER FUNCTIONS ##### + alias Oli.Delivery.Sections.Blueprint + alias Oli.Publishing + alias Oli.Resources.ResourceType + + defp create_product(ctx) do + {:ok, product} = Blueprint.create_blueprint(ctx.project.slug, "Some Product", nil) + + {:ok, %{product: product}} + end + + defp publish_project(ctx) do + Publishing.publish_project(ctx.project, "Datashop test", ctx.author.id) + {:ok, %{}} + end + + defp create_project(_conn) do + author = insert(:author) + project = insert(:project, authors: [author]) + # root container + container_resource = insert(:resource) + + container_revision = + insert(:revision, %{ + resource: container_resource, + resource_type_id: ResourceType.id_for_container(), + content: %{}, + slug: "root_container", + title: "Root Container" + }) + + # Associate root container to the project + insert(:project_resource, %{project_id: project.id, resource_id: container_resource.id}) + # Publication of project with root container + publication = + insert(:publication, %{ + project: project, + published: nil, + root_resource_id: container_resource.id + }) + + # Publish root container resource + insert(:published_resource, %{ + publication: publication, + resource: container_resource, + revision: container_revision, + author: author + }) + + [project: project, publication: publication, author: author] + end +end diff --git a/test/oli_web/live/workspaces/course_author/products_live_test.exs b/test/oli_web/live/workspaces/course_author/products_live_test.exs new file mode 100644 index 00000000000..a3f357457bd --- /dev/null +++ b/test/oli_web/live/workspaces/course_author/products_live_test.exs @@ -0,0 +1,212 @@ +defmodule OliWeb.Workspace.CourseAuthor.ProductsLiveTest do + use OliWeb.ConnCase + + import Oli.Factory + import Phoenix.LiveViewTest + + alias Oli.Delivery.Sections.Blueprint + + defp live_view_route(project_slug, params \\ %{}), + do: ~p"/workspaces/course_author/#{project_slug}/products?#{params}" + + describe "user cannot access when is not logged in" do + setup [:create_project] + + test "redirects to new session when accessing the bibliography view", %{ + conn: conn, + project: project + } do + {:error, {:redirect, %{to: redirect_path, flash: %{"error" => error_msg}}}} = + live(conn, live_view_route(project.slug)) + + assert redirect_path == "/workspaces/course_author" + assert error_msg == "You must be logged in to access that project" + + {:ok, _view, html} = live(conn, redirect_path) + + assert html =~ + "\n Welcome to\n \n OLI Torus\n " + end + end + + describe "user cannot access when is logged in as an author but is not an author of the project" do + setup [:author_conn, :create_project] + + test "redirects to projects view when accessing the bibliography view", %{ + conn: conn, + project: project + } do + {:error, {:redirect, %{to: redirect_path, flash: %{"error" => error_msg}}}} = + live(conn, live_view_route(project.slug)) + + assert redirect_path == "/workspaces/course_author" + assert error_msg == "You don't have access to that project" + + {:ok, view, _html} = live(conn, redirect_path) + + assert view + |> element("#button-new-project") + |> render() =~ "New Project" + end + end + + describe "products not published" do + setup [:admin_conn, :create_project] + + test "cannot be created message", %{conn: conn, project: project} do + {:ok, view, _html} = live(conn, live_view_route(project.slug)) + + assert view + |> element("#content") + |> render() =~ "Products cannot be created until project is published." + end + end + + describe "products" do + setup [:admin_conn, :create_project, :publish_project] + + test "render message when no products exists", %{conn: conn, project: project} do + {:ok, view, _html} = live(conn, live_view_route(project.slug)) + + assert view + |> element("#content") + |> render() =~ "None exist" + end + + test "create a product", %{conn: conn, project: project} do + {:ok, view, _html} = live(conn, live_view_route(project.slug)) + + # Submit form to create a product + view + |> form("form", create_product_form: %{"product_title" => "Some product"}) + |> render_submit() + + # Flash message + assert view |> element("div[role='alert']") |> render() =~ "Product successfully created." + + # Total count message + assert render(view) =~ "Showing all results (1 total)" + + # Table content + product_title_col = render(element(view, "table tbody tr td:nth-of-type(1) div")) + + # Table content - Product title - text + assert product_title_col =~ "Some product" + + # Table content - Product title - anchor + assert product_title_col =~ + "/workspaces/course_author/#{project.slug}/products/some_product" + + # Table content - Status + assert render(element(view, "table tbody tr td:nth-of-type(2) div")) =~ "active" + assert render(element(view, "table tbody tr td:nth-of-type(3) div")) =~ "None" + + # Table content - Base project + base_project_col = render(element(view, "table tbody tr td:nth-of-type(4) div")) + + # Table content - Base project - text + assert base_project_col =~ "#{project.title}" + + # Table content - Base project - anchor + assert base_project_col =~ + "/workspaces/course_author/#{project.slug}/overview" + end + + test "trigger archived products checkbox", %{conn: conn, project: project} do + product_title_1 = "Some product 1" + product_title_2 = "Some product 2" + {:ok, product_1} = Blueprint.create_blueprint(project.slug, product_title_1, nil) + {:ok, _product_2} = Blueprint.create_blueprint(project.slug, product_title_2, nil) + + # Archive product 1 + Oli.Delivery.Sections.update_section!(product_1, %{status: :archived}) + + {:ok, view, _html} = live(conn, live_view_route(project.slug)) + + # Check archived products checkbox is not checked + refute view |> element("input[type=\"checkbox\"]") |> render() =~ "checked=\"checked\"" + + # Total count message + assert render(view) =~ "Showing all results (1 total)" + + # Check archived products are not displayed + rows = + view + |> element("table tbody") + |> render() + |> Floki.parse_fragment!() + |> Floki.find("tr") + + refute Floki.text(rows) =~ "Some product 1" + assert Floki.text(rows) =~ "Some product 2" + + assert Enum.count(rows) == 1 + + # Toggle archived products checkbox - It should be 2 products now + view |> element("input[type='checkbox']") |> render_click() + + # Check archived products checkbox is checked + assert view |> element("input[type=\"checkbox\"]") |> render() =~ "checked=\"checked\"" + + # Total count message + assert render(view) =~ "Showing all results (2 total)" + + # Check archived products are displayed + rows = + view + |> element("table tbody") + |> render() + |> Floki.parse_fragment!() + |> Floki.find("tr") + + assert Enum.count(rows) == 2 + + assert Floki.text(rows) =~ "Some product 1" + assert Floki.text(rows) =~ "Some product 2" + end + end + + ##### HELPER FUNCTIONS ##### + alias Oli.Resources.ResourceType + + defp publish_project(context) do + Oli.Publishing.publish_project(context.project, "Datashop test", context.author.id) + {:ok, %{}} + end + + defp create_project(_conn) do + author = insert(:author) + project = insert(:project, authors: [author]) + # root container + container_resource = insert(:resource) + + container_revision = + insert(:revision, %{ + resource: container_resource, + resource_type_id: ResourceType.id_for_container(), + content: %{}, + slug: "root_container", + title: "Root Container" + }) + + # Associate root container to the project + insert(:project_resource, %{project_id: project.id, resource_id: container_resource.id}) + # Publication of project with root container + publication = + insert(:publication, %{ + project: project, + published: nil, + root_resource_id: container_resource.id + }) + + # Publish root container resource + insert(:published_resource, %{ + publication: publication, + resource: container_resource, + revision: container_revision, + author: author + }) + + [project: project, publication: publication, author: author] + end +end diff --git a/test/oli_web/live/workspace/course_author/publish_live_test.exs b/test/oli_web/live/workspaces/course_author/publish_live_test.exs similarity index 100% rename from test/oli_web/live/workspace/course_author/publish_live_test.exs rename to test/oli_web/live/workspaces/course_author/publish_live_test.exs diff --git a/test/oli_web/live/workspace/course_author/qa_logic_test.exs b/test/oli_web/live/workspaces/course_author/qa_logic_test.exs similarity index 100% rename from test/oli_web/live/workspace/course_author/qa_logic_test.exs rename to test/oli_web/live/workspaces/course_author/qa_logic_test.exs diff --git a/test/oli_web/live/workspace/course_author_test.exs b/test/oli_web/live/workspaces/course_author_test.exs similarity index 100% rename from test/oli_web/live/workspace/course_author_test.exs rename to test/oli_web/live/workspaces/course_author_test.exs diff --git a/test/oli_web/live/workspace/instructor/dashboard_live_test.exs b/test/oli_web/live/workspaces/instructor/dashboard_live_test.exs similarity index 100% rename from test/oli_web/live/workspace/instructor/dashboard_live_test.exs rename to test/oli_web/live/workspaces/instructor/dashboard_live_test.exs diff --git a/test/oli_web/live/workspace/instructor_test.exs b/test/oli_web/live/workspaces/instructor_test.exs similarity index 100% rename from test/oli_web/live/workspace/instructor_test.exs rename to test/oli_web/live/workspaces/instructor_test.exs diff --git a/test/oli_web/live/workspace/student_test.exs b/test/oli_web/live/workspaces/student_test.exs similarity index 100% rename from test/oli_web/live/workspace/student_test.exs rename to test/oli_web/live/workspaces/student_test.exs From 631c06567dda94abecfd4d8a1ee6d5c3d2756cd1 Mon Sep 17 00:00:00 2001 From: Anders Weinstein Date: Fri, 13 Sep 2024 09:13:03 -0400 Subject: [PATCH 8/9] [ENHANCEMENT] [MER-3789] render any note in bibliography entries (#5097) * append any note to bibliography entries * remove experimental code --------- Co-authored-by: Anders Weinstein --- assets/src/apps/bibliography/BibEntryView.tsx | 2 ++ assets/src/components/editing/elements/cite/CitationEditor.tsx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/assets/src/apps/bibliography/BibEntryView.tsx b/assets/src/apps/bibliography/BibEntryView.tsx index 0186b778ed3..144a83be43c 100644 --- a/assets/src/apps/bibliography/BibEntryView.tsx +++ b/assets/src/apps/bibliography/BibEntryView.tsx @@ -14,6 +14,8 @@ export const BibEntryView: React.FC = (props: BibEntryViewPro format: 'html', template: 'apa', lang: 'en-US', + // include any note, used for URL in legacy bib entries + append: (entry: any) => ` ${entry.note}`, }); }; diff --git a/assets/src/components/editing/elements/cite/CitationEditor.tsx b/assets/src/components/editing/elements/cite/CitationEditor.tsx index 298869c4bcd..af67844a8da 100644 --- a/assets/src/components/editing/elements/cite/CitationEditor.tsx +++ b/assets/src/components/editing/elements/cite/CitationEditor.tsx @@ -63,6 +63,8 @@ export const CitationEditor = (props: ExistingCiteEditorProps) => { format: 'html', template: 'apa', lang: 'en-US', + // include any note, used for URL in legacy bib entries + append: (entry: any) => ` ${entry.note}`, }); }; From 9fa6f74ec606ac8cad046cd47237f77ee4519d2f Mon Sep 17 00:00:00 2001 From: Devesh Tiwari <70621864+dtiwarATS@users.noreply.github.com> Date: Fri, 13 Sep 2024 23:31:35 +0530 Subject: [PATCH 9/9] [BUG FIX] [MER-3763] Dropdown/Num/Text Input Components contain dark background (#5095) * MER-3763 * MER-3762 * MER-3763 * trying to restart the checks * Update text-input.scss --- assets/styles/adaptive/activities/dropdown.scss | 7 +++++++ assets/styles/adaptive/activities/popup.scss | 7 +++++++ assets/styles/adaptive/activities/text-input.scss | 9 +++++++++ assets/styles/preview/preview-tools.scss | 6 ++++++ 4 files changed, 29 insertions(+) diff --git a/assets/styles/adaptive/activities/dropdown.scss b/assets/styles/adaptive/activities/dropdown.scss index 34182f9538e..fd600eafcb8 100644 --- a/assets/styles/adaptive/activities/dropdown.scss +++ b/assets/styles/adaptive/activities/dropdown.scss @@ -68,3 +68,10 @@ janus-input-number .unitsLabel { margin-left: 10px; margin-top: 5px; } + +//As per the requirment, dropdown / select needs to be in light mode even if the current theme is Dark. +// This can be customized from any external CSS file. +.dark select { + background-color: #fff; + color: #333333; +} diff --git a/assets/styles/adaptive/activities/popup.scss b/assets/styles/adaptive/activities/popup.scss index 3bb95056c8a..d6afb2a51f4 100644 --- a/assets/styles/adaptive/activities/popup.scss +++ b/assets/styles/adaptive/activities/popup.scss @@ -150,3 +150,10 @@ janus-popup:has(input) { } } } + +.dark janus-popup:has(input) { + input[src*='data']:focus, + input[src*='data']:active { + background-color: rgba(0, 0, 0, 0); + } +} diff --git a/assets/styles/adaptive/activities/text-input.scss b/assets/styles/adaptive/activities/text-input.scss index ac59c683058..12674f6b51a 100644 --- a/assets/styles/adaptive/activities/text-input.scss +++ b/assets/styles/adaptive/activities/text-input.scss @@ -73,3 +73,12 @@ janus-input-number .unitsLabel { margin-left: 10px; margin-top: 10px; } + +//As per the requirment, text & number input needs to be in light mode even if the current theme is Dark. +// This can be customized from any external CSS file. +.dark input[type='datetime-local'], +.dark input[type='number'], +.dark input[type='text'] { + background-color: #fff; + color: #333333; +} diff --git a/assets/styles/preview/preview-tools.scss b/assets/styles/preview/preview-tools.scss index ca59294feee..240f150c48c 100644 --- a/assets/styles/preview/preview-tools.scss +++ b/assets/styles/preview/preview-tools.scss @@ -180,6 +180,9 @@ .list-group { margin-left: 1rem !important; } + .list-group { + margin: 0px !important; + } button { font-size: 0.9rem; } @@ -214,3 +217,6 @@ width: 1px; } } +.dark .inspector .stateKey { + color: white !important; +}