diff --git a/assets/src/components/misc/VegaLiteRenderer.tsx b/assets/src/components/misc/VegaLiteRenderer.tsx index eb17e5ec8d8..5527ba35a44 100644 --- a/assets/src/components/misc/VegaLiteRenderer.tsx +++ b/assets/src/components/misc/VegaLiteRenderer.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { VegaLite, VisualizationSpec } from 'react-vega'; export interface VegaLiteSpec { @@ -6,9 +6,70 @@ export interface VegaLiteSpec { } export const VegaLiteRenderer = (props: VegaLiteSpec) => { + const [darkMode, setDarkMode] = useState(document.documentElement.classList.contains('dark')); + + const viewRef = useRef(null); + + // Update the 'isDarkMode' parameter and background color when 'darkMode' changes + useEffect(() => { + if (viewRef.current) { + const view = viewRef.current; + + view.signal('isDarkMode', darkMode); + view.background(darkMode ? '#262626' : 'white'); + view.run(); + } + }, [darkMode]); + + const darkTooltipTheme = { + theme: 'dark', + style: { + 'vega-tooltip': { + backgroundColor: 'black', + color: 'white', + }, + }, + }; + const lightTooltipTheme = { + theme: 'light', + style: { + 'vega-tooltip': { + backgroundColor: 'white', + color: 'black', + }, + }, + }; + + // Set up a MutationObserver to listen for changes to the 'class' attribute + useEffect(() => { + const observer = new MutationObserver(() => { + const isDark = document.documentElement.classList.contains('dark'); + setDarkMode(isDark); + }); + + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ['class'], + }); + + return () => { + observer.disconnect(); + }; + }, []); + return ( <> - + { + viewRef.current = view; + view.signal('isDarkMode', darkMode); + view.background(darkMode ? '#262626' : 'white'); + view.run(); + }} + /> ); }; diff --git a/lib/oli/analytics/summary.ex b/lib/oli/analytics/summary.ex index 32f2d4dea97..fa2637ff71d 100644 --- a/lib/oli/analytics/summary.ex +++ b/lib/oli/analytics/summary.ex @@ -1,4 +1,6 @@ defmodule Oli.Analytics.Summary do + import Ecto.Query + alias Oli.Analytics.Summary.{ AttemptGroup, ResponseLabel, @@ -10,6 +12,9 @@ defmodule Oli.Analytics.Summary do alias Oli.Analytics.Common.Pipeline alias Oli + alias Oli.Repo + alias Oli.Resources.ResourceType + require Logger @resource_fields "project_id, publication_id, section_id, user_id, resource_id, part_id, resource_type_id, num_correct, num_attempts, num_hints, num_first_attempts, num_first_attempts_correct" @@ -409,4 +414,58 @@ defmodule Oli.Analytics.Summary do |> StudentResponse.changeset(attrs) |> Oli.Repo.insert() end + + @doc """ + Counts the number of attempts made by a list of students for a given activity in a given section. + """ + @spec count_student_attempts( + activity_resource_id :: integer(), + section_id :: integer(), + student_ids :: [integer()] + ) :: integer() | nil + def count_student_attempts(activity_resource_id, section_id, student_ids) do + page_type_id = ResourceType.get_id_by_type("activity") + + from(rs in ResourceSummary, + where: + rs.section_id == ^section_id and rs.resource_id == ^activity_resource_id and + rs.user_id in ^student_ids and rs.project_id == -1 and rs.publication_id == -1 and + rs.resource_type_id == ^page_type_id, + select: sum(rs.num_attempts) + ) + |> Repo.one() + end + + @doc """ + Returns a list of response summaries for a given page resource id, section id, and activity resource ids. + """ + @spec get_response_summary_for( + page_resource_id :: integer(), + section_id :: integer(), + activity_resource_ids :: [integer()] + ) :: [map()] + def get_response_summary_for(page_resource_id, section_id, activity_resource_ids) do + from(rs in ResponseSummary, + join: rpp in ResourcePartResponse, + on: rs.resource_part_response_id == rpp.id, + left_join: sr in StudentResponse, + on: + rs.section_id == sr.section_id and rs.page_id == sr.page_id and + rs.resource_part_response_id == sr.resource_part_response_id, + left_join: u in Oli.Accounts.User, + on: sr.user_id == u.id, + where: + rs.section_id == ^section_id and rs.page_id == ^page_resource_id and + rs.publication_id == -1 and rs.project_id == -1 and + rs.activity_id in ^activity_resource_ids, + select: %{ + part_id: rpp.part_id, + response: rpp.response, + count: rs.count, + user: u, + activity_id: rs.activity_id + } + ) + |> Repo.all() + end end diff --git a/lib/oli/delivery/attempts/core.ex b/lib/oli/delivery/attempts/core.ex index 4c69bb1f27d..6dbc86bc91e 100644 --- a/lib/oli/delivery/attempts/core.ex +++ b/lib/oli/delivery/attempts/core.ex @@ -660,6 +660,93 @@ defmodule Oli.Delivery.Attempts.Core do {:ok, results} end + @doc """ + Returns a list of activity attempts for a given section and activity resource ids. + """ + @spec get_activity_attempts_by(integer(), [integer()]) :: [map()] + def get_activity_attempts_by(section_id, activity_resource_ids) do + from(activity_attempt in ActivityAttempt, + left_join: resource_attempt in assoc(activity_attempt, :resource_attempt), + left_join: resource_access in assoc(resource_attempt, :resource_access), + left_join: user in assoc(resource_access, :user), + left_join: activity_revision in assoc(activity_attempt, :revision), + left_join: resource_revision in assoc(resource_attempt, :revision), + where: + resource_access.section_id == ^section_id and + activity_revision.resource_id in ^activity_resource_ids, + select: activity_attempt, + select_merge: %{ + activity_type_id: activity_revision.activity_type_id, + activity_title: activity_revision.title, + page_title: resource_revision.title, + page_id: resource_revision.resource_id, + resource_attempt_number: resource_attempt.attempt_number, + graded: resource_revision.graded, + user: user, + revision: activity_revision, + resource_attempt_guid: resource_attempt.attempt_guid, + resource_access_id: resource_access.id + } + ) + |> Repo.all() + end + + @doc """ + Fetches the activities for a given assessment and section attempted by a list of students + + ## Parameters + * `assessment_resource_id` - the resource id of the assessment + * `section_id` - the section id + * `student_ids` - the list of student ids + * `filter_by_survey` - an optional boolean flag to filter by survey activities + + iex> get_activities(1, 2, [3, 4], false) + [ + %{ + id: 1, + resource_id: 1, + title: "Activity 1" + }, + %{ + id: 2, + resource_id: 2, + title: "Activity 2" + } + ] + """ + @spec get_evaluated_activities_for(integer(), integer(), [integer()], boolean()) :: [map()] + def get_evaluated_activities_for( + assessment_resource_id, + section_id, + student_ids, + filter_by_survey \\ false + ) do + filter_by_survey? = + if filter_by_survey do + dynamic([aa, _], not is_nil(aa.survey_id)) + else + dynamic([aa, _], is_nil(aa.survey_id)) + end + + from(aa in ActivityAttempt, + join: res_attempt in ResourceAttempt, + on: aa.resource_attempt_id == res_attempt.id, + where: aa.lifecycle_state == :evaluated, + join: res_access in ResourceAccess, + on: res_attempt.resource_access_id == res_access.id, + where: + res_access.section_id == ^section_id and + res_access.resource_id == ^assessment_resource_id and + res_access.user_id in ^student_ids, + where: ^filter_by_survey?, + join: rev in Revision, + on: aa.revision_id == rev.id, + group_by: [rev.resource_id, rev.id], + select: map(rev, [:id, :resource_id, :title]) + ) + |> Repo.all() + end + def get_all_activity_attempts(resource_attempt_id) do Repo.all( from(activity_attempt in ActivityAttempt, @@ -919,4 +1006,20 @@ defmodule Oli.Delivery.Attempts.Core do ) ) end + + @doc """ + Counts the number of attempts made by a list of students for a given activity in a given section. + """ + @spec count_student_attempts(integer(), %Section{}, [integer()]) :: integer() | nil + def count_student_attempts(activity_resource_id, section_id, student_ids) do + from(ra in ResourceAttempt, + join: access in ResourceAccess, + on: access.id == ra.resource_access_id, + where: + ra.lifecycle_state == :evaluated and access.section_id == ^section_id and + access.resource_id == ^activity_resource_id and access.user_id in ^student_ids, + select: count(ra.id) + ) + |> Repo.one() + end end diff --git a/lib/oli_web/components/delivery/activity_helpers.ex b/lib/oli_web/components/delivery/activity_helpers.ex new file mode 100644 index 00000000000..ccd723ed4d7 --- /dev/null +++ b/lib/oli_web/components/delivery/activity_helpers.ex @@ -0,0 +1,624 @@ +defmodule OliWeb.Delivery.ActivityHelpers do + @moduledoc """ + Common helper functions for rendering activities with metrics + in the instructor dashboard's Insights View (Scored Activities, Practice Activities and Surveys) + """ + + use OliWeb, :html + + alias Oli.Analytics.Summary + alias Oli.Delivery.Attempts.Core + alias Oli.Delivery.Sections.Section + alias Oli.Publishing.DeliveryResolver + alias OliWeb.ManualGrading.RenderedActivity + + def get_activities(assessment_resource_id, section_id, student_ids, filter_by_survey \\ false), + do: + Core.get_evaluated_activities_for( + assessment_resource_id, + section_id, + student_ids, + filter_by_survey + ) + + @spec get_activities_details( + any(), + atom() | %{:analytics_version => any(), :id => any(), optional(any()) => any()}, + any(), + any() + ) :: any() + def get_activities_details(activity_resource_ids, section, activity_types_map, page_resource_id) do + multiple_choice_type_id = + Enum.find_value(activity_types_map, fn {k, v} -> if v.title == "Multiple Choice", do: k end) + + single_response_type_id = + Enum.find_value(activity_types_map, fn {k, v} -> if v.title == "Single Response", do: k end) + + multi_input_type_id = + Enum.find_value(activity_types_map, fn {k, v} -> + if v.title == "Multi Input", + do: k + end) + + likert_type_id = + Enum.find_value(activity_types_map, fn {k, v} -> if v.title == "Likert", do: k end) + + activity_attempts = Core.get_activity_attempts_by(section.id, activity_resource_ids) + + if section.analytics_version == :v2 do + response_summaries = + Summary.get_response_summary_for(page_resource_id, section.id, activity_resource_ids) + + Enum.map(activity_attempts, fn activity_attempt -> + case activity_attempt.activity_type_id do + ^multiple_choice_type_id -> + add_choices_frequencies(activity_attempt, response_summaries) + + ^single_response_type_id -> + add_single_response_details(activity_attempt, response_summaries) + + ^multi_input_type_id -> + add_multi_input_details(activity_attempt, response_summaries) + + ^likert_type_id -> + add_likert_details(activity_attempt, response_summaries) + + _ -> + activity_attempt + end + end) + else + activity_attempts + end + end + + attr :activity, :map, required: true + + def rendered_activity( + %{ + activity: %{ + preview_rendered: [" _rest], + analytics_version: :v2 + } + } = assigns + ) do + spec = + VegaLite.from_json(""" + { + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "padding": {"left": 20, "top": 30, "right": 20, "bottom": 30}, + "description": "Likert Scale Ratings Distributions and Medians.", + "datasets": { + "medians": #{Jason.encode!(assigns.activity.datasets.medians)}, + "values": #{Jason.encode!(assigns.activity.datasets.values)} + }, + "data": {"name": "medians"}, + "title": {"text": #{Jason.encode!(assigns.activity.datasets.title)}, "offset": 20, "fontSize": 20}, + "width": 600, + "height": #{likert_dynamic_height(assigns.activity.datasets.questions_count)}, + "config": { + "axis": { + "labelColor": {"expr": "isDarkMode ? 'white' : 'black'"}, + "titleColor": {"expr": "isDarkMode ? 'white' : 'black'"}, + "gridColor": {"expr": "isDarkMode ? '#666' : '#e0e0e0'"} + }, + "title": {"color": {"expr": "isDarkMode ? 'white' : 'black'"}}, + "legend": { + "labelColor": {"expr": "isDarkMode ? 'red' : 'blue'"}, + "titleColor": {"expr": "isDarkMode ? 'white' : 'black'"} + }, + "text": {"color": {"expr": "isDarkMode ? 'white' : 'black'"}} + }, + "encoding": { + "y": { + "field": "question", + "type": "nominal", + "sort": null, + "axis": { + "domain": false, + "labels": false, + "offset": #{likert_dynamic_y_offset(assigns.activity.datasets.first_choice_text)}, + "ticks": false, + "grid": true, + "title": null + } + }, + "x": { + "type": "quantitative", + "scale": {"domain": #{likert_dynamic_x_scale(assigns.activity.datasets.axis_values)}}, + "axis": {"grid": false, "values": #{Jason.encode!(assigns.activity.datasets.axis_values)}, "title": null} + } + }, + "view": {"stroke": null}, + "layer": [ + { + "mark": {"type": "circle"}, + "data": {"name": "values"}, + "encoding": { + "x": {"field": "value"}, + "size": { + "aggregate": "count", + "type": "quantitative", + "title": "Number of Ratings", + "legend": { + "offset": #{likert_dynamic_legend_offset(assigns.activity.datasets.last_choice_text)}, + "labelColor": {"expr": "isDarkMode ? 'white' : 'black'"}, + "type": null, + "symbolFillColor": {"expr": "isDarkMode ? '#4CA6FF' : '#0165DA'"}, + "symbolStrokeColor": {"expr": "isDarkMode ? '#4CA6FF' : '#0165DA'"} + } + }, + "tooltip": [ + {"field": "choice", "type": "nominal", "title": "Rating"}, + { + "field": "value", + "type": "quantitative", + "aggregate": "count", + "title": "# Answers" + }, + {"field": "out_of", "type": "nominal", "title": "Out of"} + ], + "color": { + "condition": {"test": "isDarkMode", "value": "#4CA6FF"}, + "value": "#0165DA" + } + } + }, + { + "mark": "tick", + "encoding": { + "x": {"field": "median"}, + "color": { + "condition": {"test": "isDarkMode", "value": "white"}, + "value": "black" + }, + "tooltip": [ + {"field": "median", "type": "quantitative", "title": "Median"} + ] + } + }, + { + "mark": {"type": "text", "x": -10, "align": "right"}, + "encoding": { + "text": {"field": "lo"}, + "color": { + "condition": {"test": "isDarkMode", "value": "white"}, + "value": "black" + } + } + }, + { + "mark": {"type": "text", "x": 610, "align": "left"}, + "encoding": { + "text": {"field": "hi"}, + "color": { + "condition": {"test": "isDarkMode", "value": "white"}, + "value": "black" + } + } + }, + { + "transform": [ + { + "calculate": "length(datum.question) > 30 ? substring(datum.question, 0, 30) + '…' : datum.question", + "as": "maybe_truncated_question" + } + ], + "mark": { + "type": "text", + "align": "right", + "baseline": "middle", + "dx": #{-likert_dynamic_y_offset(assigns.activity.datasets.first_choice_text)}, + "fontSize": 13, + "fontWeight": "bold" + }, + "encoding": { + "y": {"field": "question", "type": "nominal", "sort": null}, + "x": {"value": 0}, + "text": {"field": "maybe_truncated_question"}, + "tooltip": { + "condition": { + "test": "length(datum.question) > 30", + "field": "question" + }, + "value": null + } + } + } + ] + } + """) + |> VegaLite.to_spec() + + spec = + %{spec: spec} + |> VegaLite.config(signals: [%{"name" => "isDarkMode", "value" => true}]) + + assigns = Map.merge(assigns, spec) + + ~H""" +
+ <%= OliWeb.Common.React.component( + %{is_liveview: true}, + "Components.VegaLiteRenderer", + %{spec: @spec}, + id: "activity_#{@activity.id}", + container: [class: "overflow-x-scroll"], + container_tag: :div + ) %> +
+ """ + end + + def rendered_activity(assigns) do + ~H""" + + """ + end + + def add_activity_attempts_info(activity, students, student_ids, section) do + students_with_attempts = + DeliveryResolver.students_with_attempts_for_page( + activity, + section, + student_ids + ) + + student_emails_without_attempts = + Enum.reduce(students, [], fn s, acc -> + if s.id in students_with_attempts do + acc + else + [s.email | acc] + end + end) + + activity + |> Map.put(:students_with_attempts_count, Enum.count(students_with_attempts)) + |> Map.put(:student_emails_without_attempts, student_emails_without_attempts) + |> Map.put( + :total_attempts_count, + count_student_attempts(activity.resource_id, section, student_ids) || 0 + ) + end + + def get_preview_rendered(nil, _activity_types_map, _section), do: nil + + def get_preview_rendered(activity_attempt, activity_types_map, section) do + OliWeb.ManualGrading.Rendering.create_rendering_context( + activity_attempt, + Core.get_latest_part_attempts(activity_attempt.attempt_guid), + activity_types_map, + section + ) + |> Map.merge(%{is_liveview: true}) + |> OliWeb.ManualGrading.Rendering.render(:instructor_preview) + end + + defp add_single_response_details(activity_attempt, response_summaries) do + responses = + Enum.reduce(response_summaries, [], fn response_summary, acc -> + if response_summary.activity_id == activity_attempt.resource_id do + [ + %{ + text: response_summary.response, + user_name: OliWeb.Common.Utils.name(response_summary.user) + } + | acc + ] + else + acc + end + end) + |> Enum.reverse() + + update_in( + activity_attempt, + [Access.key!(:revision), Access.key!(:content)], + &Map.put(&1, "responses", responses) + ) + end + + defp add_choices_frequencies(activity_attempt, response_summaries) do + responses = + Enum.filter(response_summaries, fn response_summary -> + response_summary.activity_id == activity_attempt.resource_id + end) + + # we must consider the case where a transformed model is present and if so, then use it + # otherwise, use the revision model. This block also returns a corresponding updater function + {model, updater} = + case activity_attempt.transformed_model do + nil -> + {activity_attempt.revision.content, + fn activity_attempt, choices -> + update_in( + activity_attempt, + [Access.key!(:revision), Access.key!(:content)], + &Map.put(&1, "choices", choices) + ) + end} + + transformed_model -> + {transformed_model, + fn activity_attempt, choices -> + update_in( + activity_attempt, + [Access.key!(:transformed_model)], + &Map.put(&1, "choices", choices) + ) + end} + end + + choices = + model["choices"] + |> Enum.map( + &Map.merge(&1, %{ + "frequency" => + Enum.find(responses, %{count: 0}, fn r -> r.response == &1["id"] end).count + }) + ) + |> then(fn choices -> + blank_reponses = Enum.find(responses, fn r -> r.response == "" end) + + if blank_reponses[:response] do + [ + %{ + "content" => [ + %{ + "children" => [ + %{ + "text" => + "Blank attempt (user submitted assessment without selecting any choice for this activity)" + } + ], + "type" => "p" + } + ], + "frequency" => blank_reponses.count + } + | choices + ] + else + choices + end + end) + + updater.(activity_attempt, choices) + end + + defp add_likert_details(activity_attempt, response_summaries) do + %{questions: questions, question_mapper: question_mapper} = + Enum.reduce( + activity_attempt.revision.content["items"], + %{questions: [], question_mapper: %{}, question_number: 1}, + fn q, acc -> + question = %{ + id: q["id"], + text: q["content"] |> hd() |> Map.get("children") |> hd() |> Map.get("text"), + number: acc.question_number + } + + %{ + questions: [question | acc.questions], + question_mapper: + Map.put(acc.question_mapper, q["id"], %{ + text: question.text, + number: question.number + }), + question_number: acc.question_number + 1 + } + end + ) + + {ordered_choices, choice_mapper} = + Enum.reduce( + activity_attempt.revision.content["choices"], + %{ordered_choices: [], choice_mapper: %{}, aux_points: 1}, + fn ch, acc -> + choice = %{ + id: ch["id"], + text: ch["content"] |> hd() |> Map.get("children") |> hd() |> Map.get("text"), + points: acc.aux_points + } + + %{ + ordered_choices: [choice | acc.ordered_choices], + choice_mapper: + Map.put(acc.choice_mapper, ch["id"], %{text: choice.text, points: choice.points}), + aux_points: acc.aux_points + 1 + } + end + ) + |> then(fn acc -> + {Enum.reverse(acc.ordered_choices), acc.choice_mapper} + end) + + responses = + Enum.reduce(response_summaries, [], fn response_summary, acc -> + if response_summary.activity_id == activity_attempt.resource_id do + [ + %{ + count: response_summary.count, + choice_id: response_summary.response, + selected_choice_text: + Map.get(choice_mapper, to_string(response_summary.response))[:text] || + "Student left this question blank", + selected_choice_points: + Map.get(choice_mapper, to_string(response_summary.response))[:points] || 0, + question_id: response_summary.part_id, + question: Map.get(question_mapper, to_string(response_summary.part_id))[:text], + question_number: + Map.get(question_mapper, to_string(response_summary.part_id))[:number] + } + | acc + ] + else + acc + end + end) + |> Enum.sort_by(& &1.question_number) + + {average_points_per_question_id, responses_per_question_id} = + Enum.reduce(responses, {%{}, %{}}, fn response, {avg_points_acc, responses_acc} -> + {Map.put(avg_points_acc, response.question_id, [ + response.selected_choice_points | Map.get(avg_points_acc, response.question_id, []) + ]), + Map.put( + responses_acc, + response.question_id, + Map.get(responses_acc, response.question_id, 0) + 1 + )} + end) + |> then(fn {points_per_question_id, responses_per_question_id} -> + average_points_per_question_id = + Enum.into(points_per_question_id, %{}, fn {question_id, points} -> + count = Enum.count(points) + + { + question_id, + if count == 0 do + 0 + else + Enum.sum(points) / count + end + } + end) + + {average_points_per_question_id, responses_per_question_id} + end) + + first_choice_text = Enum.at(ordered_choices, 0)[:text] + last_choice_text = Enum.at(ordered_choices, -1)[:text] + + medians = + Enum.map(questions, fn q -> + %{ + question: q.text, + median: Map.get(average_points_per_question_id, q.id, 0.0), + lo: first_choice_text, + hi: last_choice_text + } + end) + + values = + Enum.map(responses, fn r -> + %{ + value: r.selected_choice_points, + choice: r.selected_choice_text, + question: r.question, + out_of: Map.get(responses_per_question_id, r.question_id, 0) + } + end) + + Map.merge(activity_attempt, %{ + datasets: %{ + medians: medians, + values: values, + questions_count: length(questions), + axis_values: Enum.map(ordered_choices, fn c -> c.points end), + first_choice_text: first_choice_text, + last_choice_text: last_choice_text, + title: activity_attempt.revision.title + } + }) + end + + defp add_multi_input_details(activity_attempt, response_summaries) do + mapper = build_input_mapper(activity_attempt.transformed_model["inputs"]) + + Enum.reduce( + activity_attempt.transformed_model["inputs"], + activity_attempt, + fn input, acc2 -> + case input["inputType"] do + response when response in ["numeric", "text"] -> + add_text_or_numeric_responses( + acc2, + response_summaries, + mapper + ) + + "dropdown" -> + add_dropdown_choices(acc2, response_summaries) + end + end + ) + end + + defp add_dropdown_choices(acc, response_summaries) do + add_choices_frequencies(acc, response_summaries) + |> update_in( + [ + Access.key!(:transformed_model), + Access.key!("inputs"), + Access.filter(&(&1["inputType"] == "dropdown")), + Access.key!("choiceIds") + ], + &List.insert_at(&1, -1, "0") + ) + end + + defp add_text_or_numeric_responses(acumulator, response_summaries, mapper) do + responses = + relevant_responses(acumulator.resource_id, response_summaries, mapper) + + update_in( + acumulator, + [Access.key!(:transformed_model), Access.key!("authoring")], + &Map.put(&1, "responses", responses) + ) + end + + defp relevant_responses(resource_id, response_summaries, mapper) do + Enum.reduce(response_summaries, [], fn response_summary, acc_responses -> + if response_summary.activity_id == resource_id do + [ + %{ + text: response_summary.response, + user_name: OliWeb.Common.Utils.name(response_summary.user), + type: mapper[response_summary.part_id], + part_id: response_summary.part_id + } + | acc_responses + ] + else + acc_responses + end + end) + end + + defp build_input_mapper(inputs) do + Enum.into(inputs, %{}, fn input -> + {input["partId"], input["inputType"]} + end) + end + + defp count_student_attempts( + activity_resource_id, + %Section{analytics_version: :v2, id: section_id}, + student_ids + ), + do: Summary.count_student_attempts(activity_resource_id, section_id, student_ids) + + defp count_student_attempts( + activity_resource_id, + section, + student_ids + ), + do: Core.count_student_attempts(activity_resource_id, section.id, student_ids) + + defp likert_dynamic_height(questions_count), do: 60 + 30 * questions_count + + defp likert_dynamic_y_offset(first_choice_text), + do: 60 + (String.length(first_choice_text) - 7) * 5 + + defp likert_dynamic_legend_offset(last_choice_text), + do: 80 + (String.length(last_choice_text) - 7) * 5 + + defp likert_dynamic_x_scale(axis_values), + do: "[0, #{to_string(length(axis_values) + 1)}]" +end diff --git a/lib/oli_web/components/delivery/practice_activities/practice_activities.ex b/lib/oli_web/components/delivery/practice_activities/practice_activities.ex index 7ff42931dd7..4692c50cde6 100644 --- a/lib/oli_web/components/delivery/practice_activities/practice_activities.ex +++ b/lib/oli_web/components/delivery/practice_activities/practice_activities.ex @@ -1,36 +1,13 @@ defmodule OliWeb.Components.Delivery.PracticeActivities do use OliWeb, :live_component - import Ecto.Query - - alias Oli.Accounts.User - - alias Oli.Analytics.Summary.{ - ResourcePartResponse, - ResourceSummary, - ResponseSummary, - StudentResponse - } - alias OliWeb.Common.InstructorDashboardPagedTable - alias Oli.Delivery.Attempts.Core - - alias Oli.Delivery.Attempts.Core.{ - ActivityAttempt, - ResourceAccess, - ResourceAttempt - } alias Oli.Delivery.Sections - alias Oli.Delivery.Sections.Section - alias Oli.Publishing.DeliveryResolver - alias Oli.Repo - alias Oli.Resources.Revision - alias Oli.Resources.ResourceType alias OliWeb.Common.{Params, SearchInput} alias OliWeb.Common.Table.SortableTableModel + alias OliWeb.Delivery.ActivityHelpers alias OliWeb.Delivery.PracticeActivities.PracticeAssessmentsTableModel - alias OliWeb.ManualGrading.RenderedActivity alias OliWeb.Router.Helpers, as: Routes alias Phoenix.LiveView.JS @@ -212,11 +189,8 @@ defmodule OliWeb.Components.Delivery.PracticeActivities do id="activity_detail" phx-hook="LoadSurveyScripts" > - <%= if activity.preview_rendered != nil do %> - + <%= if Map.get(activity, :preview_rendered) != nil do %> + <% else %>

No attempt registered for this question

<% end %> @@ -247,9 +221,9 @@ defmodule OliWeb.Components.Delivery.PracticeActivities do end) current_activities = - get_activities( - current_assessment, - section, + ActivityHelpers.get_activities( + current_assessment.resource_id, + section.id, student_ids ) @@ -257,7 +231,7 @@ defmodule OliWeb.Components.Delivery.PracticeActivities do Enum.map(current_activities, fn activity -> activity.resource_id end) activities_details = - get_activities_details( + ActivityHelpers.get_activities_details( activity_resource_ids, socket.assigns.section, socket.assigns.activity_types_map, @@ -271,8 +245,18 @@ defmodule OliWeb.Components.Delivery.PracticeActivities do activity.resource_id == activity_details.revision.resource_id end) - Map.put(activity, :preview_rendered, get_preview_rendered(activity_details, socket)) - |> add_activity_attempts_info(students, student_ids, section) + activity + |> Map.put( + :preview_rendered, + ActivityHelpers.get_preview_rendered( + activity_details, + socket.assigns.activity_types_map, + socket.assigns.section + ) + ) + |> Map.put(:datasets, Map.get(activity_details, :datasets)) + |> Map.put(:analytics_version, section.analytics_version) + |> ActivityHelpers.add_activity_attempts_info(students, student_ids, section) end) {:noreply, @@ -516,42 +500,6 @@ defmodule OliWeb.Components.Delivery.PracticeActivities do ) end - defp add_activity_attempts_info(activity, students, student_ids, section) do - students_with_attempts = - DeliveryResolver.students_with_attempts_for_page( - activity, - section, - student_ids - ) - - student_emails_without_attempts = - Enum.reduce(students, [], fn s, acc -> - if s.id in students_with_attempts do - acc - else - [s.email | acc] - end - end) - - activity - |> Map.put(:students_with_attempts_count, Enum.count(students_with_attempts)) - |> Map.put(:student_emails_without_attempts, student_emails_without_attempts) - |> Map.put(:total_attempts_count, count_attempts(activity, section, student_ids) || 0) - end - - defp get_preview_rendered(nil, socket), do: socket - - defp get_preview_rendered(activity_attempt, socket) do - OliWeb.ManualGrading.Rendering.create_rendering_context( - activity_attempt, - Core.get_latest_part_attempts(activity_attempt.attempt_guid), - socket.assigns.activity_types_map, - socket.assigns.section - ) - |> Map.merge(%{is_liveview: true}) - |> OliWeb.ManualGrading.Rendering.render(:instructor_preview) - end - defp assign_selected_assessment(socket, selected_assessment_id) when selected_assessment_id in ["", nil] do case socket.assigns.table_model.rows do @@ -572,339 +520,6 @@ defmodule OliWeb.Components.Delivery.PracticeActivities do assign(socket, table_model: table_model) end - defp count_attempts( - current_activity, - %Section{analytics_version: :v2, id: section_id}, - student_ids - ) do - page_type_id = ResourceType.get_id_by_type("activity") - - from(rs in ResourceSummary, - where: - rs.section_id == ^section_id and rs.resource_id == ^current_activity.resource_id and - rs.user_id in ^student_ids and rs.project_id == -1 and rs.publication_id == -1 and - rs.resource_type_id == ^page_type_id, - select: sum(rs.num_attempts) - ) - |> Repo.one() - end - - defp count_attempts(current_activity, section, student_ids) do - from(ra in ResourceAttempt, - join: access in ResourceAccess, - on: access.id == ra.resource_access_id, - where: - ra.lifecycle_state == :evaluated and access.section_id == ^section.id and - access.resource_id == ^current_activity.resource_id and access.user_id in ^student_ids, - select: count(ra.id) - ) - |> Repo.one() - end - - defp get_activities(current_assessment, section, student_ids) do - from(aa in ActivityAttempt, - join: res_attempt in ResourceAttempt, - on: aa.resource_attempt_id == res_attempt.id, - where: aa.lifecycle_state == :evaluated, - join: res_access in ResourceAccess, - on: res_attempt.resource_access_id == res_access.id, - where: - res_access.section_id == ^section.id and - res_access.resource_id == ^current_assessment.resource_id and - res_access.user_id in ^student_ids and is_nil(aa.survey_id), - join: rev in Revision, - on: aa.revision_id == rev.id, - group_by: [rev.resource_id, rev.id], - select: map(rev, [:id, :resource_id, :title]) - ) - |> Repo.all() - end - - def get_activities_details(activity_resource_ids, section, activity_types_map, page_resource_id) do - multiple_choice_type_id = - Enum.find_value(activity_types_map, fn {k, v} -> if v.title == "Multiple Choice", do: k end) - - single_response_type_id = - Enum.find_value(activity_types_map, fn {k, v} -> if v.title == "Single Response", do: k end) - - multi_input_type_id = - Enum.find_value(activity_types_map, fn {k, v} -> - if v.title == "Multi Input", - do: k - end) - - likert_type_id = - Enum.find_value(activity_types_map, fn {k, v} -> if v.title == "Likert", do: k end) - - activity_attempts = - from(activity_attempt in ActivityAttempt, - left_join: resource_attempt in assoc(activity_attempt, :resource_attempt), - left_join: resource_access in assoc(resource_attempt, :resource_access), - left_join: user in assoc(resource_access, :user), - left_join: activity_revision in assoc(activity_attempt, :revision), - left_join: resource_revision in assoc(resource_attempt, :revision), - where: - resource_access.section_id == ^section.id and - activity_revision.resource_id in ^activity_resource_ids, - select: activity_attempt, - select_merge: %{ - activity_type_id: activity_revision.activity_type_id, - activity_title: activity_revision.title, - page_title: resource_revision.title, - page_id: resource_revision.resource_id, - resource_attempt_number: resource_attempt.attempt_number, - graded: resource_revision.graded, - user: user, - revision: activity_revision, - resource_attempt_guid: resource_attempt.attempt_guid, - resource_access_id: resource_access.id - } - ) - |> Repo.all() - - if section.analytics_version == :v2 do - response_summaries = - from(rs in ResponseSummary, - join: rpp in ResourcePartResponse, - on: rs.resource_part_response_id == rpp.id, - left_join: sr in StudentResponse, - on: - rs.section_id == sr.section_id and rs.page_id == sr.page_id and - rs.resource_part_response_id == sr.resource_part_response_id, - left_join: u in User, - on: sr.user_id == u.id, - where: - rs.section_id == ^section.id and rs.page_id == ^page_resource_id and - rs.publication_id == -1 and rs.project_id == -1 and - rs.activity_id in ^activity_resource_ids, - select: %{ - part_id: rpp.part_id, - response: rpp.response, - count: rs.count, - user: u, - activity_id: rs.activity_id - } - ) - |> Repo.all() - - Enum.map(activity_attempts, fn activity_attempt -> - case activity_attempt.activity_type_id do - ^multiple_choice_type_id -> - add_choices_frequencies(activity_attempt, response_summaries) - - ^single_response_type_id -> - add_single_response_details(activity_attempt, response_summaries) - - ^multi_input_type_id -> - add_multi_input_details(activity_attempt, response_summaries) - - ^likert_type_id -> - add_likert_details(activity_attempt, response_summaries) - - _ -> - activity_attempt - end - end) - else - activity_attempts - end - end - - defp add_single_response_details(activity_attempt, response_summaries) do - responses = - Enum.reduce(response_summaries, [], fn response_summary, acc -> - if response_summary.activity_id == activity_attempt.resource_id do - [ - %{ - text: response_summary.response, - user_name: OliWeb.Common.Utils.name(response_summary.user) - } - | acc - ] - else - acc - end - end) - |> Enum.reverse() - - update_in( - activity_attempt, - [Access.key!(:revision), Access.key!(:content)], - &Map.put(&1, "responses", responses) - ) - end - - defp add_choices_frequencies(activity_attempt, response_summaries) do - responses = - Enum.filter(response_summaries, fn response_summary -> - response_summary.activity_id == activity_attempt.resource_id - end) - - choices = - activity_attempt.transformed_model["choices"] - |> Enum.map( - &Map.merge(&1, %{ - "frequency" => - Enum.find(responses, %{count: 0}, fn r -> r.response == &1["id"] end).count - }) - ) - |> then(fn choices -> - blank_reponses = Enum.find(responses, fn r -> r.response == "" end) - - if blank_reponses[:response] do - [ - %{ - "content" => [ - %{ - "children" => [ - %{ - "text" => - "Blank attempt (user submitted assessment without selecting any choice for this activity)" - } - ], - "type" => "p" - } - ], - "frequency" => blank_reponses.count - } - | choices - ] - else - choices - end - end) - - update_in( - activity_attempt, - [Access.key!(:transformed_model)], - &Map.put(&1, "choices", choices) - ) - end - - defp add_multi_input_details(activity_attempt, response_summaries) do - mapper = build_input_mapper(activity_attempt.transformed_model["inputs"]) - - Enum.reduce( - activity_attempt.transformed_model["inputs"], - activity_attempt, - fn input, acc2 -> - case input["inputType"] do - response when response in ["numeric", "text"] -> - add_text_or_numeric_responses( - acc2, - response_summaries, - mapper - ) - - "dropdown" -> - add_dropdown_choices(acc2, response_summaries) - end - end - ) - end - - defp add_dropdown_choices(acc, response_summaries) do - add_choices_frequencies(acc, response_summaries) - |> update_in( - [ - Access.key!(:transformed_model), - Access.key!("inputs"), - Access.filter(&(&1["inputType"] == "dropdown")), - Access.key!("choiceIds") - ], - &List.insert_at(&1, -1, "0") - ) - end - - defp add_text_or_numeric_responses(acumulator, response_summaries, mapper) do - responses = - relevant_responses(acumulator.resource_id, response_summaries, mapper) - - update_in( - acumulator, - [Access.key!(:transformed_model), Access.key!("authoring")], - &Map.put(&1, "responses", responses) - ) - end - - defp relevant_responses(resource_id, response_summaries, mapper) do - Enum.reduce(response_summaries, [], fn response_summary, acc_responses -> - if response_summary.activity_id == resource_id do - [ - %{ - text: response_summary.response, - user_name: OliWeb.Common.Utils.name(response_summary.user), - type: mapper[response_summary.part_id], - part_id: response_summary.part_id - } - | acc_responses - ] - else - acc_responses - end - end) - end - - defp build_input_mapper(inputs) do - Enum.into(inputs, %{}, fn input -> - {input["partId"], input["inputType"]} - end) - end - - defp add_likert_details(activity_attempt, response_summaries) do - responses = - Enum.filter(response_summaries, fn response_summary -> - response_summary.activity_id == activity_attempt.resource_id - end) - - choices = - activity_attempt.revision.content["choices"] - |> Enum.map( - &Map.merge(&1, %{ - "frequency" => - Enum.find(responses, %{count: 0}, fn r -> r.response == &1["id"] end).count - }) - ) - |> then(fn choices -> - blank_reponses = Enum.find(responses, fn r -> r.response == "" end) - - if blank_reponses[:response] do - [ - %{ - "content" => [ - %{ - "children" => [ - %{ - "text" => - "Blank attempt (user submitted assessment without selecting any choice for this activity)" - } - ], - "type" => "p" - } - ], - "frequency" => blank_reponses.count - } - | choices - ] - else - choices - end - end) - - update_in( - activity_attempt, - [Access.key!(:revision), Access.key!(:content)], - &Map.put(&1, "choices", choices) - ) - |> update_in( - [ - Access.key!(:revision), - Access.key!(:content) - ], - &Map.put(&1, "activityTitle", activity_attempt.revision.title) - ) - end - defp build_units_and_modules(container_count, modules_and_units) do if container_count == 0 do [] diff --git a/lib/oli_web/components/delivery/scored_activities/scored_activities.ex b/lib/oli_web/components/delivery/scored_activities/scored_activities.ex index e13954d0ac4..4cf74e5e7b2 100644 --- a/lib/oli_web/components/delivery/scored_activities/scored_activities.ex +++ b/lib/oli_web/components/delivery/scored_activities/scored_activities.ex @@ -3,11 +3,8 @@ defmodule OliWeb.Components.Delivery.ScoredActivities do import Ecto.Query - alias Oli.Accounts.User - alias Oli.Analytics.Summary.ResourcePartResponse alias Oli.Analytics.Summary.ResourceSummary alias Oli.Analytics.Summary.ResponseSummary - alias Oli.Analytics.Summary.StudentResponse alias Oli.Delivery.Attempts.Core alias Oli.Delivery.Attempts.Core.ActivityAttempt alias Oli.Delivery.Attempts.Core.ResourceAccess @@ -25,10 +22,10 @@ defmodule OliWeb.Components.Delivery.ScoredActivities do alias OliWeb.Common.Params alias OliWeb.Common.SearchInput alias OliWeb.Common.Table.SortableTableModel + alias OliWeb.Delivery.ActivityHelpers alias OliWeb.Delivery.ScoredActivities.ActivitiesTableModel alias OliWeb.Delivery.ScoredActivities.AssessmentsTableModel alias OliWeb.ManualGrading.Rendering - alias OliWeb.ManualGrading.RenderedActivity alias OliWeb.Router.Helpers, as: Routes alias OliWeb.Icons @@ -59,7 +56,7 @@ defmodule OliWeb.Components.Delivery.ScoredActivities do students: assigns.students, scripts: assigns.scripts, activity_types_map: assigns.activity_types_map, - preview_rendered: nil + selected_activity: nil ) case params.assessment_id do @@ -251,8 +248,8 @@ defmodule OliWeb.Components.Delivery.ScoredActivities do id="activity_detail" phx-hook="LoadSurveyScripts" > - <%= if @preview_rendered != nil do %> - + <%= if Map.get(@selected_activity, :preview_rendered) != nil do %> + <% else %>

No attempt registered for this question

<% end %> @@ -378,15 +375,20 @@ defmodule OliWeb.Components.Delivery.ScoredActivities do table_model = Map.merge(socket.assigns.table_model, %{selected: "#{selected_activity_id}"}) - section = socket.assigns.section - activity_types_map = socket.assigns.activity_types_map - page_id = socket.assigns.current_assessment.resource_id - - case get_activity_details(selected_activity, section, activity_types_map, page_id) do - nil -> - assign(socket, table_model: table_model) - - activity_attempt -> + %{ + section: section, + activity_types_map: activity_types_map, + current_assessment: %{resource_id: page_id} + } = socket.assigns + + case ActivityHelpers.get_activities_details( + [selected_activity.resource_id], + section, + activity_types_map, + page_id + ) do + details when details not in [nil, []] -> + activity_attempt = hd(details) part_attempts = Core.get_latest_part_attempts(activity_attempt.attempt_guid) rendering_context = @@ -398,10 +400,15 @@ defmodule OliWeb.Components.Delivery.ScoredActivities do ) |> Map.merge(%{is_liveview: true}) - preview_rendered = Rendering.render(rendering_context, :instructor_preview) + selected_activity = + Map.merge(selected_activity, %{ + preview_rendered: Rendering.render(rendering_context, :instructor_preview), + datasets: Map.get(activity_attempt, :datasets), + analytics_version: section.analytics_version + }) socket - |> assign(table_model: table_model, preview_rendered: preview_rendered) + |> assign(table_model: table_model, selected_activity: selected_activity) |> case do %{assigns: %{scripts_loaded: true}} = socket -> socket @@ -411,6 +418,9 @@ defmodule OliWeb.Components.Delivery.ScoredActivities do script_sources: socket.assigns.scripts }) end + + _details -> + assign(socket, table_model: table_model, selected_activity: selected_activity) end end @@ -651,308 +661,4 @@ defmodule OliWeb.Components.Delivery.ScoredActivities do end end) end - - defp get_activity_details(selected_activity, section, activity_types_map, page_id) do - multiple_choice_type_id = - Enum.find_value(activity_types_map, fn {k, v} -> if v.title == "Multiple Choice", do: k end) - - single_response_type_id = - Enum.find_value(activity_types_map, fn {k, v} -> if v.title == "Single Response", do: k end) - - multi_input_type_id = - Enum.find_value(activity_types_map, fn {k, v} -> - if v.title == "Multi Input", - do: k - end) - - likert_type_id = - Enum.find_value(activity_types_map, fn {k, v} -> if v.title == "Likert", do: k end) - - activity_attempt = - ActivityAttempt - |> join(:left, [aa], resource_attempt in ResourceAttempt, - on: aa.resource_attempt_id == resource_attempt.id - ) - |> join(:left, [_, resource_attempt], ra in ResourceAccess, - on: resource_attempt.resource_access_id == ra.id - ) - |> join(:left, [_, _, ra], a in assoc(ra, :user)) - |> join(:left, [aa, _, _, _], activity_revision in Revision, - on: activity_revision.id == aa.revision_id - ) - |> join(:left, [_, resource_attempt, _, _, _], resource_revision in Revision, - on: resource_revision.id == resource_attempt.revision_id - ) - |> where( - [aa, _resource_attempt, resource_access, _u, activity_revision, _resource_revision], - resource_access.section_id == ^section.id and - activity_revision.resource_id == ^selected_activity.resource_id - ) - |> order_by([aa, _, _, _, _, _], desc: aa.inserted_at) - |> limit(1) - |> Ecto.Query.select([aa, _, _, _, _, _], aa) - |> select_merge( - [aa, resource_attempt, resource_access, user, activity_revision, resource_revision], - %{ - activity_type_id: activity_revision.activity_type_id, - activity_title: activity_revision.title, - page_title: resource_revision.title, - page_id: resource_revision.resource_id, - resource_attempt_number: resource_attempt.attempt_number, - graded: resource_revision.graded, - user: user, - revision: activity_revision, - resource_attempt_guid: resource_attempt.attempt_guid, - resource_access_id: resource_access.id - } - ) - |> Repo.one() - - if section.analytics_version == :v2 do - response_summaries = - from(rs in ResponseSummary, - join: rpp in ResourcePartResponse, - on: rs.resource_part_response_id == rpp.id, - join: sr in StudentResponse, - on: - rs.section_id == sr.section_id and rs.page_id == sr.page_id and - rs.resource_part_response_id == sr.resource_part_response_id, - join: u in User, - on: sr.user_id == u.id, - where: - rs.section_id == ^section.id and rs.activity_id == ^selected_activity.resource_id and - rs.publication_id == -1 and rs.project_id == -1 and - rs.page_id == ^page_id, - select: %{ - part_id: rpp.part_id, - response: rpp.response, - count: rs.count, - user: u, - activity_id: rs.activity_id - } - ) - |> Repo.all() - - case activity_attempt.activity_type_id do - ^multiple_choice_type_id -> - add_choices_frequencies(activity_attempt, response_summaries) - - ^single_response_type_id -> - add_single_response_details(activity_attempt, response_summaries) - - ^multi_input_type_id -> - add_multi_input_details(activity_attempt, response_summaries) - - ^likert_type_id -> - add_likert_details(activity_attempt, response_summaries) - - _ -> - activity_attempt - end - else - activity_attempt - end - end - - defp add_single_response_details(activity_attempt, response_summaries) do - responses = - Enum.reduce(response_summaries, [], fn response_summary, acc -> - if response_summary.activity_id == activity_attempt.resource_id do - [ - %{ - text: response_summary.response, - user_name: OliWeb.Common.Utils.name(response_summary.user) - } - | acc - ] - else - acc - end - end) - |> Enum.reverse() - - update_in( - activity_attempt, - [Access.key!(:revision), Access.key!(:content)], - &Map.put(&1, "responses", responses) - ) - end - - defp add_choices_frequencies(activity_attempt, response_summaries) do - responses = - Enum.filter(response_summaries, fn response_summary -> - response_summary.activity_id == activity_attempt.resource_id - end) - - choices = - activity_attempt.transformed_model["choices"] - |> Enum.map( - &Map.merge(&1, %{ - "frequency" => - Enum.find(responses, %{count: 0}, fn r -> r.response == &1["id"] end).count - }) - ) - |> then(fn choices -> - blank_reponses = Enum.find(responses, fn r -> r.response == "" end) - - if blank_reponses[:response] do - [ - %{ - "content" => [ - %{ - "children" => [ - %{ - "text" => - "Blank attempt (user submitted assessment without selecting any choice for this activity)" - } - ], - "type" => "p" - } - ], - "frequency" => blank_reponses.count - } - | choices - ] - else - choices - end - end) - - update_in( - activity_attempt, - [Access.key!(:transformed_model)], - &Map.put(&1, "choices", choices) - ) - end - - defp add_multi_input_details(activity_attempt, response_summaries) do - if activity_attempt.transformed_model do - mapper = build_input_mapper(activity_attempt.transformed_model["inputs"]) - - Enum.reduce( - activity_attempt.transformed_model["inputs"], - activity_attempt, - fn input, acc2 -> - case input["inputType"] do - response when response in ["numeric", "text"] -> - add_text_or_numeric_responses( - acc2, - response_summaries, - mapper - ) - - "dropdown" -> - add_dropdown_choices(acc2, response_summaries) - end - end - ) - end - end - - defp add_dropdown_choices(acc, response_summaries) do - add_choices_frequencies(acc, response_summaries) - |> update_in( - [ - Access.key!(:transformed_model), - Access.key!("inputs"), - Access.filter(&(&1["inputType"] == "dropdown")), - Access.key!("choiceIds") - ], - &List.insert_at(&1, -1, "0") - ) - end - - defp add_text_or_numeric_responses(acumulator, response_summaries, mapper) do - responses = - relevant_responses(acumulator.resource_id, response_summaries, mapper) - - update_in( - acumulator, - [Access.key!(:transformed_model), Access.key!("authoring")], - &Map.put(&1, "responses", responses) - ) - end - - defp relevant_responses(resource_id, response_summaries, mapper) do - Enum.reduce(response_summaries, [], fn response_summary, acc_responses -> - if response_summary.activity_id == resource_id do - [ - %{ - text: response_summary.response, - user_name: OliWeb.Common.Utils.name(response_summary.user), - type: mapper[response_summary.part_id], - part_id: response_summary.part_id, - count: response_summary.count - } - | acc_responses - ] - else - acc_responses - end - end) - end - - defp build_input_mapper(nil) do - %{} - end - - defp build_input_mapper(inputs) do - Enum.into(inputs, %{}, fn input -> - {input["partId"], input["inputType"]} - end) - end - - defp add_likert_details(activity_attempt, response_summaries) do - responses = - Enum.filter(response_summaries, fn response_summary -> - response_summary.activity_id == activity_attempt.resource_id - end) - - choices = - activity_attempt.revision.content["choices"] - |> Enum.map( - &Map.merge(&1, %{ - "frequency" => - Enum.find(responses, %{count: 0}, fn r -> r.response == &1["id"] end).count - }) - ) - |> then(fn choices -> - blank_reponses = Enum.find(responses, fn r -> r.response == "" end) - - if blank_reponses[:response] do - [ - %{ - "content" => [ - %{ - "children" => [ - %{ - "text" => - "Blank attempt (user submitted assessment without selecting any choice for this activity)" - } - ], - "type" => "p" - } - ], - "frequency" => blank_reponses.count - } - | choices - ] - else - choices - end - end) - - update_in( - activity_attempt, - [Access.key!(:revision), Access.key!(:content)], - &Map.put(&1, "choices", choices) - ) - |> update_in( - [ - Access.key!(:revision), - Access.key!(:content) - ], - &Map.put(&1, "activityTitle", activity_attempt.revision.title) - ) - end end diff --git a/lib/oli_web/components/delivery/surveys/surveys.ex b/lib/oli_web/components/delivery/surveys/surveys.ex index 1833826086e..22d8190381d 100644 --- a/lib/oli_web/components/delivery/surveys/surveys.ex +++ b/lib/oli_web/components/delivery/surveys/surveys.ex @@ -1,29 +1,12 @@ defmodule OliWeb.Components.Delivery.Surveys do use OliWeb, :live_component - import Ecto.Query - alias Oli.Accounts.User - - alias Oli.Analytics.Summary.ResourcePartResponse - alias Oli.Analytics.Summary.ResourceSummary - alias Oli.Analytics.Summary.ResponseSummary - alias Oli.Analytics.Summary.StudentResponse - alias Oli.Delivery.Attempts.Core - alias Oli.Delivery.Attempts.Core.ActivityAttempt - alias Oli.Delivery.Attempts.Core.ResourceAccess - alias Oli.Delivery.Attempts.Core.ResourceAttempt - alias Oli.Delivery.Sections.Section - alias Oli.Publishing.DeliveryResolver - alias Oli.Repo - alias Oli.Resources.ResourceType - alias Oli.Resources.Revision - + alias OliWeb.Delivery.ActivityHelpers alias OliWeb.Common.InstructorDashboardPagedTable alias OliWeb.Common.Params alias OliWeb.Common.SearchInput alias OliWeb.Common.Table.SortableTableModel alias OliWeb.Delivery.Surveys.SurveysAssessmentsTableModel - alias OliWeb.ManualGrading.RenderedActivity alias OliWeb.Router.Helpers, as: Routes alias Phoenix.LiveView.JS @@ -175,11 +158,8 @@ defmodule OliWeb.Components.Delivery.Surveys do id="activity_detail" phx-hook="LoadSurveyScripts" > - <%= if activity.preview_rendered != nil do %> - + <%= if Map.get(activity, :preview_rendered) != nil do %> + <% else %>

No attempt registered for this question

<% end %> @@ -322,13 +302,19 @@ defmodule OliWeb.Components.Delivery.Surveys do end defp find_current_activities(current_assessment, section, student_ids, students, socket) do - activities = get_activities(current_assessment, section, student_ids) + activities = + ActivityHelpers.get_activities( + current_assessment.resource_id, + section.id, + student_ids, + true + ) activity_resource_ids = Enum.map(activities, fn activity -> activity.resource_id end) activities_details = - get_activities_details( + ActivityHelpers.get_activities_details( activity_resource_ids, socket.assigns.section, socket.assigns.activity_types_map, @@ -341,8 +327,18 @@ defmodule OliWeb.Components.Delivery.Surveys do activity.resource_id == activity_details.revision.resource_id end) - Map.put(activity, :preview_rendered, get_preview_rendered(activity_details, socket)) - |> add_activity_attempts_info(students, student_ids, section) + Map.put( + activity, + :preview_rendered, + ActivityHelpers.get_preview_rendered( + activity_details, + socket.assigns.activity_types_map, + socket.assigns.section + ) + ) + |> Map.put(:datasets, Map.get(activity_details, :datasets)) + |> Map.put(:analytics_version, section.analytics_version) + |> ActivityHelpers.add_activity_attempts_info(students, student_ids, section) end) end @@ -372,42 +368,6 @@ defmodule OliWeb.Components.Delivery.Surveys do assign(socket, table_model: table_model) end - defp add_activity_attempts_info(activity, students, student_ids, section) do - students_with_attempts = - DeliveryResolver.students_with_attempts_for_page( - activity, - section, - student_ids - ) - - student_emails_without_attempts = - Enum.reduce(students, [], fn s, acc -> - if s.id in students_with_attempts do - acc - else - [s.email | acc] - end - end) - - activity - |> Map.put(:students_with_attempts_count, Enum.count(students_with_attempts)) - |> Map.put(:student_emails_without_attempts, student_emails_without_attempts) - |> Map.put(:total_attempts_count, count_attempts(activity, section, student_ids) || 0) - end - - defp get_preview_rendered(nil, socket), do: socket - - defp get_preview_rendered(activity_attempt, socket) do - OliWeb.ManualGrading.Rendering.create_rendering_context( - activity_attempt, - Core.get_latest_part_attempts(activity_attempt.attempt_guid), - socket.assigns.activity_types_map, - socket.assigns.section - ) - |> Map.merge(%{is_liveview: true}) - |> OliWeb.ManualGrading.Rendering.render(:instructor_preview) - end - defp apply_filters(assessments, params) do assessments = assessments @@ -520,364 +480,4 @@ defmodule OliWeb.Components.Delivery.Surveys do params ) end - - defp count_attempts( - current_activity, - %Section{analytics_version: :v2, id: section_id}, - student_ids - ) do - page_type_id = ResourceType.get_id_by_type("activity") - - from(rs in ResourceSummary, - where: - rs.section_id == ^section_id and rs.resource_id == ^current_activity.resource_id and - rs.user_id in ^student_ids and rs.project_id == -1 and rs.publication_id == -1 and - rs.resource_type_id == ^page_type_id, - select: sum(rs.num_attempts) - ) - |> Repo.one() - end - - defp count_attempts(current_activity, section, student_ids) do - from(ra in ResourceAttempt, - join: access in ResourceAccess, - on: access.id == ra.resource_access_id, - where: - ra.lifecycle_state == :evaluated and access.section_id == ^section.id and - access.resource_id == ^current_activity.resource_id and access.user_id in ^student_ids, - select: count(ra.id) - ) - |> Repo.one() - end - - def get_activities(current_assessment, section, student_ids) do - from(activity_attempt in ActivityAttempt, - join: resource_attempt in ResourceAttempt, - on: activity_attempt.resource_attempt_id == resource_attempt.id, - where: activity_attempt.lifecycle_state == :evaluated, - join: resource_accesses in ResourceAccess, - on: resource_attempt.resource_access_id == resource_accesses.id, - where: - resource_accesses.section_id == ^section.id and - resource_accesses.resource_id == ^current_assessment.resource_id and - resource_accesses.user_id in ^student_ids and not is_nil(activity_attempt.survey_id), - join: revision in Revision, - on: activity_attempt.revision_id == revision.id, - group_by: [revision.resource_id, revision.id], - select: map(revision, [:id, :resource_id, :title]) - ) - |> Repo.all() - end - - @spec get_activities_details( - any(), - atom() | %{:analytics_version => any(), :id => any(), optional(any()) => any()}, - any(), - any() - ) :: any() - def get_activities_details(activity_resource_ids, section, activity_types_map, page_resource_id) do - multiple_choice_type_id = - Enum.find_value(activity_types_map, fn {k, v} -> if v.title == "Multiple Choice", do: k end) - - single_response_type_id = - Enum.find_value(activity_types_map, fn {k, v} -> if v.title == "Single Response", do: k end) - - multi_input_type_id = - Enum.find_value(activity_types_map, fn {k, v} -> - if v.title == "Multi Input", - do: k - end) - - likert_type_id = - Enum.find_value(activity_types_map, fn {k, v} -> if v.title == "Likert", do: k end) - - activity_attempts = - from(activity_attempt in ActivityAttempt, - left_join: resource_attempt in assoc(activity_attempt, :resource_attempt), - left_join: resource_access in assoc(resource_attempt, :resource_access), - left_join: user in assoc(resource_access, :user), - left_join: activity_revision in assoc(activity_attempt, :revision), - left_join: resource_revision in assoc(resource_attempt, :revision), - where: - resource_access.section_id == ^section.id and - activity_revision.resource_id in ^activity_resource_ids, - select: activity_attempt, - select_merge: %{ - activity_type_id: activity_revision.activity_type_id, - activity_title: activity_revision.title, - page_title: resource_revision.title, - page_id: resource_revision.resource_id, - resource_attempt_number: resource_attempt.attempt_number, - graded: resource_revision.graded, - user: user, - revision: activity_revision, - resource_attempt_guid: resource_attempt.attempt_guid, - resource_access_id: resource_access.id - } - ) - |> Repo.all() - - if section.analytics_version == :v2 do - response_summaries = - from(rs in ResponseSummary, - join: rpp in ResourcePartResponse, - on: rs.resource_part_response_id == rpp.id, - left_join: sr in StudentResponse, - on: - rs.section_id == sr.section_id and rs.page_id == sr.page_id and - rs.resource_part_response_id == sr.resource_part_response_id, - left_join: u in User, - on: sr.user_id == u.id, - where: - rs.section_id == ^section.id and rs.page_id == ^page_resource_id and - rs.publication_id == -1 and rs.project_id == -1 and - rs.activity_id in ^activity_resource_ids, - select: %{ - part_id: rpp.part_id, - response: rpp.response, - count: rs.count, - user: u, - activity_id: rs.activity_id - } - ) - |> Repo.all() - - Enum.map(activity_attempts, fn activity_attempt -> - case activity_attempt.activity_type_id do - ^multiple_choice_type_id -> - add_choices_frequencies(activity_attempt, response_summaries) - - ^single_response_type_id -> - add_single_response_details(activity_attempt, response_summaries) - - ^multi_input_type_id -> - add_multi_input_details(activity_attempt, response_summaries) - - ^likert_type_id -> - add_likert_details(activity_attempt, response_summaries) - - _ -> - activity_attempt - end - end) - else - activity_attempts - end - end - - defp add_single_response_details(activity_attempt, response_summaries) do - responses = - Enum.reduce(response_summaries, [], fn response_summary, acc -> - if response_summary.activity_id == activity_attempt.resource_id do - [ - %{ - text: response_summary.response, - user_name: OliWeb.Common.Utils.name(response_summary.user) - } - | acc - ] - else - acc - end - end) - |> Enum.reverse() - - update_in( - activity_attempt, - [Access.key!(:revision), Access.key!(:content)], - &Map.put(&1, "responses", responses) - ) - end - - defp add_choices_frequencies(activity_attempt, response_summaries) do - responses = - Enum.filter(response_summaries, fn response_summary -> - response_summary.activity_id == activity_attempt.resource_id - end) - - # we must consider the case where a transformed model is present and if so, then use it - # otherwise, use the revision model. This block also returns a corresponding updater function - {model, updater} = - case activity_attempt.transformed_model do - nil -> - {activity_attempt.revision.content, - fn activity_attempt, choices -> - update_in( - activity_attempt, - [Access.key!(:revision), Access.key!(:content)], - &Map.put(&1, "choices", choices) - ) - end} - - transformed_model -> - {transformed_model, - fn activity_attempt, choices -> - update_in( - activity_attempt, - [Access.key!(:transformed_model)], - &Map.put(&1, "choices", choices) - ) - end} - end - - choices = - model["choices"] - |> Enum.map( - &Map.merge(&1, %{ - "frequency" => - Enum.find(responses, %{count: 0}, fn r -> r.response == &1["id"] end).count - }) - ) - |> then(fn choices -> - blank_reponses = Enum.find(responses, fn r -> r.response == "" end) - - if blank_reponses[:response] do - [ - %{ - "content" => [ - %{ - "children" => [ - %{ - "text" => - "Blank attempt (user submitted assessment without selecting any choice for this activity)" - } - ], - "type" => "p" - } - ], - "frequency" => blank_reponses.count - } - | choices - ] - else - choices - end - end) - - updater.(activity_attempt, choices) - end - - defp add_likert_details(activity_attempt, response_summaries) do - responses = - Enum.filter(response_summaries, fn response_summary -> - response_summary.activity_id == activity_attempt.resource_id - end) - - choices = - activity_attempt.revision.content["choices"] - |> Enum.map( - &Map.merge(&1, %{ - "frequency" => - Enum.find(responses, %{count: 0}, fn r -> r.response == &1["id"] end).count - }) - ) - |> then(fn choices -> - blank_reponses = Enum.find(responses, fn r -> r.response == "" end) - - if blank_reponses[:response] do - [ - %{ - "content" => [ - %{ - "children" => [ - %{ - "text" => - "Blank attempt (user submitted assessment without selecting any choice for this activity)" - } - ], - "type" => "p" - } - ], - "frequency" => blank_reponses.count - } - | choices - ] - else - choices - end - end) - - update_in( - activity_attempt, - [Access.key!(:revision), Access.key!(:content)], - &Map.put(&1, "choices", choices) - ) - |> update_in( - [ - Access.key!(:revision), - Access.key!(:content) - ], - &Map.put(&1, "activityTitle", activity_attempt.revision.title) - ) - end - - defp add_multi_input_details(activity_attempt, response_summaries) do - mapper = build_input_mapper(activity_attempt.transformed_model["inputs"]) - - Enum.reduce( - activity_attempt.transformed_model["inputs"], - activity_attempt, - fn input, acc2 -> - case input["inputType"] do - response when response in ["numeric", "text"] -> - add_text_or_numeric_responses( - acc2, - response_summaries, - mapper - ) - - "dropdown" -> - add_dropdown_choices(acc2, response_summaries) - end - end - ) - end - - defp add_dropdown_choices(acc, response_summaries) do - add_choices_frequencies(acc, response_summaries) - |> update_in( - [ - Access.key!(:transformed_model), - Access.key!("inputs"), - Access.filter(&(&1["inputType"] == "dropdown")), - Access.key!("choiceIds") - ], - &List.insert_at(&1, -1, "0") - ) - end - - defp add_text_or_numeric_responses(acumulator, response_summaries, mapper) do - responses = - relevant_responses(acumulator.resource_id, response_summaries, mapper) - - update_in( - acumulator, - [Access.key!(:transformed_model), Access.key!("authoring")], - &Map.put(&1, "responses", responses) - ) - end - - defp relevant_responses(resource_id, response_summaries, mapper) do - Enum.reduce(response_summaries, [], fn response_summary, acc_responses -> - if response_summary.activity_id == resource_id do - [ - %{ - text: response_summary.response, - user_name: OliWeb.Common.Utils.name(response_summary.user), - type: mapper[response_summary.part_id], - part_id: response_summary.part_id - } - | acc_responses - ] - else - acc_responses - end - end) - end - - defp build_input_mapper(inputs) do - Enum.into(inputs, %{}, fn input -> - {input["partId"], input["inputType"]} - end) - end end diff --git a/test/oli_web/live/delivery/instructor_dashboard/overview/practice_activities_tab_test.exs b/test/oli_web/live/delivery/instructor_dashboard/overview/practice_activities_tab_test.exs index dcfa0b6f852..0fcd05efc53 100644 --- a/test/oli_web/live/delivery/instructor_dashboard/overview/practice_activities_tab_test.exs +++ b/test/oli_web/live/delivery/instructor_dashboard/overview/practice_activities_tab_test.exs @@ -64,6 +64,9 @@ defmodule OliWeb.Delivery.InstructorDashboard.PracticeActivitiesTabTest do "oli_multiple_choice" -> %{choices: generate_choices(activity_revision.id)} + "oli_likert" -> + Oli.TestHelpers.likert_activity_content() + _ -> nil end @@ -417,92 +420,6 @@ defmodule OliWeb.Delivery.InstructorDashboard.PracticeActivitiesTabTest do } end - defp generate_likert_content(title) do - %{ - "stem" => %{ - "id" => "2028833010", - "content" => [ - %{"id" => "280825708", "type" => "p", "children" => [%{"text" => title}]} - ] - }, - "choices" => generate_choices("2028833010"), - "authoring" => %{ - "parts" => [ - %{ - "id" => "1", - "hints" => [ - %{ - "id" => "540968727", - "content" => [ - %{"id" => "2256338253", "type" => "p", "children" => [%{"text" => ""}]} - ] - }, - %{ - "id" => "2627194758", - "content" => [ - %{"id" => "3013119256", "type" => "p", "children" => [%{"text" => ""}]} - ] - }, - %{ - "id" => "2413327578", - "content" => [ - %{"id" => "3742562774", "type" => "p", "children" => [%{"text" => ""}]} - ] - } - ], - "outOf" => nil, - "responses" => [ - %{ - "id" => "4122423546", - "rule" => "(!(input like {1968053412})) && (input like {1436663133})", - "score" => 1, - "feedback" => %{ - "id" => "685174561", - "content" => [ - %{ - "id" => "2621700133", - "type" => "p", - "children" => [%{"text" => "Correct"}] - } - ] - } - }, - %{ - "id" => "3738563441", - "rule" => "input like {.*}", - "score" => 0, - "feedback" => %{ - "id" => "3796426513", - "content" => [ - %{ - "id" => "1605260471", - "type" => "p", - "children" => [%{"text" => "Incorrect"}] - } - ] - } - } - ], - "gradingApproach" => "automatic", - "scoringStrategy" => "average" - } - ], - "correct" => [["1436663133"], "4122423546"], - "version" => 2, - "targeted" => [], - "previewText" => "", - "transformations" => [ - %{ - "id" => "1349799137", - "path" => "choices", - "operation" => "shuffle", - "firstAttemptOnly" => true - } - ] - } - } - end - defp generate_choices(id), do: [ %{ @@ -627,7 +544,7 @@ defmodule OliWeb.Delivery.InstructorDashboard.PracticeActivitiesTabTest do }, activity_type_id: likert_reg.id, title: "The Likert question", - content: generate_likert_content("This is a likert question") + content: Oli.TestHelpers.likert_activity_content("This is a likert question") ) ## graded pages (assessments)... @@ -1517,14 +1434,15 @@ defmodule OliWeb.Delivery.InstructorDashboard.PracticeActivitiesTabTest do |> element("table tbody tr td div[phx-value-id=\"#{page_1.id}\"]") |> render_click() - # check that the multi input details render correctly - selected_activity_model = + # check that the likert VegaLite visualization renders correctly + selected_activity_data = view + |> element("div[data-live-react-class=\"Components.VegaLiteRenderer\"]") |> render() |> Floki.parse_fragment!() - |> Floki.find(~s{oli-likert-authoring}) - |> Floki.attribute("model") - |> hd + |> Floki.attribute("data-live-react-props") + |> hd() + |> Jason.decode!() assert has_element?( view, @@ -1532,8 +1450,7 @@ defmodule OliWeb.Delivery.InstructorDashboard.PracticeActivitiesTabTest do "#{likert_activity.title} - Question details" ) - assert selected_activity_model =~ - "{\"activityTitle\":\"The Likert question\",\"authoring\":{\"correct\":[[\"1436663133\"],\"4122423546\"],\"parts\":[{\"gradingApproach\":\"automatic\",\"hints\":[{\"content\":[{\"children\":[{\"text\":\"\"}],\"id\":\"2256338253\",\"type\":\"p\"}],\"id\":\"540968727\"},{\"content\":[{\"children\":[{\"text\":\"\"}],\"id\":\"3013119256\",\"type\":\"p\"}],\"id\":\"2627194758\"},{\"content\":[{\"children\":[{\"text\":\"\"}],\"id\":\"3742562774\",\"type\":\"p\"}],\"id\":\"2413327578\"}],\"id\":\"1\",\"outOf\":null,\"responses\":[{\"feedback\":{\"content\":[{\"children\":[{\"text\":\"Correct\"}],\"id\":\"2621700133\",\"type\":\"p\"}],\"id\":\"685174561\"},\"id\":\"4122423546\",\"rule\":\"(!(input like {1968053412})) && (input like {1436663133})\",\"score\":1},{\"feedback\":{\"content\":[{\"children\":[{\"text\":\"Incorrect\"}],\"id\":\"1605260471\",\"type\":\"p\"}],\"id\":\"3796426513\"},\"id\":\"3738563441\",\"rule\":\"input like {.*}\",\"score\":0}],\"scoringStrategy\":\"average\"}],\"previewText\":\"\",\"targeted\":[],\"transformations\":[{\"firstAttemptOnly\":true,\"id\":\"1349799137\",\"operation\":\"shuffle\",\"path\":\"choices\"}],\"version\":2},\"choices\":[{\"content\":[{\"children\":[{\"text\":\"Choice 1 for 2028833010\"}],\"id\":\"1866911747\",\"type\":\"p\"}],\"frequency\":1,\"id\":\"id_for_option_a\"},{\"content\":[{\"children\":[{\"text\":\"Choice 2 for 2028833010\"}],\"id\":\"3926142114\",\"type\":\"p\"}],\"frequency\":0,\"id\":\"id_for_option_b\"}],\"stem\":{\"content\":[{\"children\":[{\"text\":\"This is a likert question\"}],\"id\":\"280825708\",\"type\":\"p\"}],\"id\":\"2028833010\"}}" + assert selected_activity_data["spec"]["title"]["text"] == likert_activity.title end test "single response details get rendered for a section with analytics_version :v2 but not for :v1", @@ -1740,16 +1657,11 @@ defmodule OliWeb.Delivery.InstructorDashboard.PracticeActivitiesTabTest do |> element("table tbody tr td div[phx-value-id=\"#{page_1.id}\"]") |> render_click() - selected_activity_model = - view - |> render() - |> Floki.parse_fragment!() - |> Floki.find(~s{oli-likert-authoring}) - |> Floki.attribute("model") - |> hd - - assert selected_activity_model =~ ~s{"id":"id_for_option_a"} - refute selected_activity_model =~ ~s{"frequency":} + # check that the likert VegaLite visualization is not rendered + refute has_element?( + view, + ~s(div[data-live-react-class="Components.VegaLiteRenderer"]) + ) end test "student attempts summary gets rendered correctly when no students have attempted", %{ diff --git a/test/oli_web/live/delivery/instructor_dashboard/overview/surveys_tab_test.exs b/test/oli_web/live/delivery/instructor_dashboard/overview/surveys_tab_test.exs index 7b1860700d0..180a2f20532 100644 --- a/test/oli_web/live/delivery/instructor_dashboard/overview/surveys_tab_test.exs +++ b/test/oli_web/live/delivery/instructor_dashboard/overview/surveys_tab_test.exs @@ -65,6 +65,9 @@ defmodule OliWeb.Delivery.InstructorDashboard.SurveysTabTest do "oli_multiple_choice" -> %{choices: generate_choices(activity_revision.id)} + "oli_likert" -> + Oli.TestHelpers.likert_activity_content() + _ -> nil end @@ -419,92 +422,6 @@ defmodule OliWeb.Delivery.InstructorDashboard.SurveysTabTest do } end - defp generate_likert_content(title) do - %{ - "stem" => %{ - "id" => "2028833010", - "content" => [ - %{"id" => "280825708", "type" => "p", "children" => [%{"text" => title}]} - ] - }, - "choices" => generate_choices("2028833010"), - "authoring" => %{ - "parts" => [ - %{ - "id" => "1", - "hints" => [ - %{ - "id" => "540968727", - "content" => [ - %{"id" => "2256338253", "type" => "p", "children" => [%{"text" => ""}]} - ] - }, - %{ - "id" => "2627194758", - "content" => [ - %{"id" => "3013119256", "type" => "p", "children" => [%{"text" => ""}]} - ] - }, - %{ - "id" => "2413327578", - "content" => [ - %{"id" => "3742562774", "type" => "p", "children" => [%{"text" => ""}]} - ] - } - ], - "outOf" => nil, - "responses" => [ - %{ - "id" => "4122423546", - "rule" => "(!(input like {1968053412})) && (input like {1436663133})", - "score" => 1, - "feedback" => %{ - "id" => "685174561", - "content" => [ - %{ - "id" => "2621700133", - "type" => "p", - "children" => [%{"text" => "Correct"}] - } - ] - } - }, - %{ - "id" => "3738563441", - "rule" => "input like {.*}", - "score" => 0, - "feedback" => %{ - "id" => "3796426513", - "content" => [ - %{ - "id" => "1605260471", - "type" => "p", - "children" => [%{"text" => "Incorrect"}] - } - ] - } - } - ], - "gradingApproach" => "automatic", - "scoringStrategy" => "average" - } - ], - "correct" => [["1436663133"], "4122423546"], - "version" => 2, - "targeted" => [], - "previewText" => "", - "transformations" => [ - %{ - "id" => "1349799137", - "path" => "choices", - "operation" => "shuffle", - "firstAttemptOnly" => true - } - ] - } - } - end - defp generate_choices(id), do: [ %{ @@ -632,7 +549,7 @@ defmodule OliWeb.Delivery.InstructorDashboard.SurveysTabTest do }, activity_type_id: likert_reg.id, title: "The Likert question", - content: generate_likert_content("This is a likert question") + content: Oli.TestHelpers.likert_activity_content("This is a likert question") ) ## graded pages (assessments)... @@ -1484,14 +1401,15 @@ defmodule OliWeb.Delivery.InstructorDashboard.SurveysTabTest do |> element("table tbody tr td div[phx-value-id=\"#{page_1.id}\"]") |> render_click() - # check that the multi input details render correctly - selected_activity_model = + # check that the likert VegaLite visualization renders correctly + selected_activity_data = view + |> element("div[data-live-react-class=\"Components.VegaLiteRenderer\"]") |> render() |> Floki.parse_fragment!() - |> Floki.find(~s{oli-likert-authoring}) - |> Floki.attribute("model") - |> hd + |> Floki.attribute("data-live-react-props") + |> hd() + |> Jason.decode!() assert has_element?( view, @@ -1499,8 +1417,7 @@ defmodule OliWeb.Delivery.InstructorDashboard.SurveysTabTest do "#{likert_activity.title} - Question details" ) - assert selected_activity_model =~ - "{\"activityTitle\":\"The Likert question\",\"authoring\":{\"correct\":[[\"1436663133\"],\"4122423546\"],\"parts\":[{\"gradingApproach\":\"automatic\",\"hints\":[{\"content\":[{\"children\":[{\"text\":\"\"}],\"id\":\"2256338253\",\"type\":\"p\"}],\"id\":\"540968727\"},{\"content\":[{\"children\":[{\"text\":\"\"}],\"id\":\"3013119256\",\"type\":\"p\"}],\"id\":\"2627194758\"},{\"content\":[{\"children\":[{\"text\":\"\"}],\"id\":\"3742562774\",\"type\":\"p\"}],\"id\":\"2413327578\"}],\"id\":\"1\",\"outOf\":null,\"responses\":[{\"feedback\":{\"content\":[{\"children\":[{\"text\":\"Correct\"}],\"id\":\"2621700133\",\"type\":\"p\"}],\"id\":\"685174561\"},\"id\":\"4122423546\",\"rule\":\"(!(input like {1968053412})) && (input like {1436663133})\",\"score\":1},{\"feedback\":{\"content\":[{\"children\":[{\"text\":\"Incorrect\"}],\"id\":\"1605260471\",\"type\":\"p\"}],\"id\":\"3796426513\"},\"id\":\"3738563441\",\"rule\":\"input like {.*}\",\"score\":0}],\"scoringStrategy\":\"average\"}],\"previewText\":\"\",\"targeted\":[],\"transformations\":[{\"firstAttemptOnly\":true,\"id\":\"1349799137\",\"operation\":\"shuffle\",\"path\":\"choices\"}],\"version\":2},\"choices\":[{\"content\":[{\"children\":[{\"text\":\"Choice 1 for 2028833010\"}],\"id\":\"1866911747\",\"type\":\"p\"}],\"frequency\":1,\"id\":\"id_for_option_a\"},{\"content\":[{\"children\":[{\"text\":\"Choice 2 for 2028833010\"}],\"id\":\"3926142114\",\"type\":\"p\"}],\"frequency\":0,\"id\":\"id_for_option_b\"}],\"stem\":{\"content\":[{\"children\":[{\"text\":\"This is a likert question\"}],\"id\":\"280825708\",\"type\":\"p\"}],\"id\":\"2028833010\"}}" + assert selected_activity_data["spec"]["title"]["text"] == likert_activity.title end test "single response details get rendered for a section with analytics_version :v2 but not for :v1", @@ -1707,16 +1624,11 @@ defmodule OliWeb.Delivery.InstructorDashboard.SurveysTabTest do |> element("table tbody tr td div[phx-value-id=\"#{page_1.id}\"]") |> render_click() - selected_activity_model = - view - |> render() - |> Floki.parse_fragment!() - |> Floki.find(~s{oli-likert-authoring}) - |> Floki.attribute("model") - |> hd - - assert selected_activity_model =~ ~s{"id":"id_for_option_a"} - refute selected_activity_model =~ ~s{"frequency":} + # check that the likert VegaLite visualization is not rendered + refute has_element?( + view, + ~s(div[data-live-react-class="Components.VegaLiteRenderer"]) + ) end test "question details responds to user click on an activity", %{ diff --git a/test/oli_web/live/delivery/instructor_dashboard/scored_activities/scored_activities_tab_test.exs b/test/oli_web/live/delivery/instructor_dashboard/scored_activities/scored_activities_tab_test.exs index 49d31cd5e50..4bcf69a6250 100644 --- a/test/oli_web/live/delivery/instructor_dashboard/scored_activities/scored_activities_tab_test.exs +++ b/test/oli_web/live/delivery/instructor_dashboard/scored_activities/scored_activities_tab_test.exs @@ -79,6 +79,9 @@ defmodule OliWeb.Delivery.InstructorDashboard.ScoredActivitiesTabTest do "oli_multiple_choice" -> %{choices: generate_choices(activity_revision.id)} + "oli_likert" -> + Oli.TestHelpers.likert_activity_content() + _ -> nil end @@ -432,92 +435,6 @@ defmodule OliWeb.Delivery.InstructorDashboard.ScoredActivitiesTabTest do } end - defp generate_likert_content(title) do - %{ - "stem" => %{ - "id" => "2028833010", - "content" => [ - %{"id" => "280825708", "type" => "p", "children" => [%{"text" => title}]} - ] - }, - "choices" => generate_choices("2028833010"), - "authoring" => %{ - "parts" => [ - %{ - "id" => "1", - "hints" => [ - %{ - "id" => "540968727", - "content" => [ - %{"id" => "2256338253", "type" => "p", "children" => [%{"text" => ""}]} - ] - }, - %{ - "id" => "2627194758", - "content" => [ - %{"id" => "3013119256", "type" => "p", "children" => [%{"text" => ""}]} - ] - }, - %{ - "id" => "2413327578", - "content" => [ - %{"id" => "3742562774", "type" => "p", "children" => [%{"text" => ""}]} - ] - } - ], - "outOf" => nil, - "responses" => [ - %{ - "id" => "4122423546", - "rule" => "(!(input like {1968053412})) && (input like {1436663133})", - "score" => 1, - "feedback" => %{ - "id" => "685174561", - "content" => [ - %{ - "id" => "2621700133", - "type" => "p", - "children" => [%{"text" => "Correct"}] - } - ] - } - }, - %{ - "id" => "3738563441", - "rule" => "input like {.*}", - "score" => 0, - "feedback" => %{ - "id" => "3796426513", - "content" => [ - %{ - "id" => "1605260471", - "type" => "p", - "children" => [%{"text" => "Incorrect"}] - } - ] - } - } - ], - "gradingApproach" => "automatic", - "scoringStrategy" => "average" - } - ], - "correct" => [["1436663133"], "4122423546"], - "version" => 2, - "targeted" => [], - "previewText" => "", - "transformations" => [ - %{ - "id" => "1349799137", - "path" => "choices", - "operation" => "shuffle", - "firstAttemptOnly" => true - } - ] - } - } - end - defp generate_choices(id), do: [ %{ @@ -645,7 +562,7 @@ defmodule OliWeb.Delivery.InstructorDashboard.ScoredActivitiesTabTest do }, activity_type_id: likert_reg.id, title: "The Likert question", - content: generate_likert_content("This is a likert question") + content: Oli.TestHelpers.likert_activity_content("This is a likert question") ) ## graded pages (assessments)... @@ -1804,23 +1721,17 @@ defmodule OliWeb.Delivery.InstructorDashboard.ScoredActivitiesTabTest do }) ) - # check that the likert details render correctly - selected_activity_model = + # check that the likert VegaLite visualization renders correctly + selected_activity_data = view + |> element("div[data-live-react-class=\"Components.VegaLiteRenderer\"]") |> render() |> Floki.parse_fragment!() - |> Floki.find(~s{oli-likert-authoring}) - |> Floki.attribute("model") - |> hd - - assert has_element?( - view, - ~s(div[role="activity_title"]), - "Question details" - ) + |> Floki.attribute("data-live-react-props") + |> hd() + |> Jason.decode!() - assert selected_activity_model =~ - "{\"activityTitle\":\"The Likert question\",\"authoring\":{\"correct\":[[\"1436663133\"],\"4122423546\"],\"parts\":[{\"gradingApproach\":\"automatic\",\"hints\":[{\"content\":[{\"children\":[{\"text\":\"\"}],\"id\":\"2256338253\",\"type\":\"p\"}],\"id\":\"540968727\"},{\"content\":[{\"children\":[{\"text\":\"\"}],\"id\":\"3013119256\",\"type\":\"p\"}],\"id\":\"2627194758\"},{\"content\":[{\"children\":[{\"text\":\"\"}],\"id\":\"3742562774\",\"type\":\"p\"}],\"id\":\"2413327578\"}],\"id\":\"1\",\"outOf\":null,\"responses\":[{\"feedback\":{\"content\":[{\"children\":[{\"text\":\"Correct\"}],\"id\":\"2621700133\",\"type\":\"p\"}],\"id\":\"685174561\"},\"id\":\"4122423546\",\"rule\":\"(!(input like {1968053412})) && (input like {1436663133})\",\"score\":1},{\"feedback\":{\"content\":[{\"children\":[{\"text\":\"Incorrect\"}],\"id\":\"1605260471\",\"type\":\"p\"}],\"id\":\"3796426513\"},\"id\":\"3738563441\",\"rule\":\"input like {.*}\",\"score\":0}],\"scoringStrategy\":\"average\"}],\"previewText\":\"\",\"targeted\":[],\"transformations\":[{\"firstAttemptOnly\":true,\"id\":\"1349799137\",\"operation\":\"shuffle\",\"path\":\"choices\"}],\"version\":2},\"choices\":[{\"content\":[{\"children\":[{\"text\":\"Choice 1 for 2028833010\"}],\"id\":\"1866911747\",\"type\":\"p\"}],\"frequency\":1,\"id\":\"id_for_option_a\"},{\"content\":[{\"children\":[{\"text\":\"Choice 2 for 2028833010\"}],\"id\":\"3926142114\",\"type\":\"p\"}],\"frequency\":0,\"id\":\"id_for_option_b\"}],\"stem\":{\"content\":[{\"children\":[{\"text\":\"This is a likert question\"}],\"id\":\"280825708\",\"type\":\"p\"}],\"id\":\"2028833010\"}}" + assert selected_activity_data["spec"]["title"]["text"] == likert_activity.title end test "single response details get rendered for a section with analytics_version :v2 but not for :v1", @@ -2039,16 +1950,11 @@ defmodule OliWeb.Delivery.InstructorDashboard.ScoredActivitiesTabTest do }) ) - selected_activity_model = - view - |> render() - |> Floki.parse_fragment!() - |> Floki.find(~s{oli-likert-authoring}) - |> Floki.attribute("model") - |> hd - - assert selected_activity_model =~ ~s{"id":"id_for_option_a"} - refute selected_activity_model =~ ~s{"frequency":} + # check that the likert VegaLite visualization is not rendered + refute has_element?( + view, + ~s(div[data-live-react-class="Components.VegaLiteRenderer"]) + ) end test "question details responds to user click on an activity", %{ diff --git a/test/support/test_helpers.ex b/test/support/test_helpers.ex index cb604f1fc71..c8f4addf474 100644 --- a/test/support/test_helpers.ex +++ b/test/support/test_helpers.ex @@ -3728,4 +3728,333 @@ defmodule Oli.TestHelpers do """ |> Jason.decode!() end + + def likert_activity_content(title \\ "Some Title") do + %{ + "stem" => %{ + "id" => "2045374543", + "editor" => "slate", + "content" => [ + %{ + "id" => "481387076", + "type" => "p", + "children" => [ + %{ + "text" => title + } + ] + } + ], + "textDirection" => "ltr" + }, + "items" => [ + %{ + "id" => "1801321981", + "editor" => "slate", + "content" => [ + %{ + "id" => "1756897531", + "type" => "p", + "children" => [ + %{ + "text" => "item 1" + } + ] + } + ], + "required" => false, + "textDirection" => "ltr" + }, + %{ + "id" => "1", + "editor" => "slate", + "content" => [ + %{ + "id" => "1513586314", + "type" => "p", + "children" => [ + %{ + "text" => "item 2" + } + ] + } + ], + "textDirection" => "ltr" + } + ], + "bibrefs" => [], + "choices" => [ + %{ + "id" => "id_for_option_a", + "value" => %{ + "type" => 0 + }, + "editor" => "slate", + "content" => [ + %{ + "id" => "2993895163", + "type" => "p", + "children" => [ + %{ + "text" => "Agree" + } + ] + } + ], + "textDirection" => "ltr" + }, + %{ + "id" => "id_for_option_b", + "value" => %{ + "type" => 0 + }, + "editor" => "slate", + "content" => [ + %{ + "id" => "2480215248", + "type" => "p", + "children" => [ + %{ + "text" => "Neither Agree Nor Disagree" + } + ] + } + ], + "textDirection" => "ltr" + }, + %{ + "id" => "2744313274", + "value" => %{ + "type" => 0 + }, + "editor" => "slate", + "content" => [ + %{ + "id" => "1677958907", + "type" => "p", + "children" => [ + %{ + "text" => "Disagree" + } + ] + } + ], + "textDirection" => "ltr" + } + ], + "authoring" => %{ + "parts" => [ + %{ + "id" => "1", + "hints" => [ + %{ + "id" => "3815680172", + "editor" => "slate", + "content" => [ + %{ + "id" => "1086576948", + "type" => "p", + "children" => [ + %{ + "text" => "" + } + ] + } + ], + "textDirection" => "ltr" + }, + %{ + "id" => "2087690726", + "editor" => "slate", + "content" => [ + %{ + "id" => "11486367", + "type" => "p", + "children" => [ + %{ + "text" => "" + } + ] + } + ], + "textDirection" => "ltr" + }, + %{ + "id" => "1328056631", + "editor" => "slate", + "content" => [ + %{ + "id" => "3113974429", + "type" => "p", + "children" => [ + %{ + "text" => "" + } + ] + } + ], + "textDirection" => "ltr" + } + ], + "outOf" => nil, + "targets" => [], + "responses" => [ + %{ + "id" => "1506873322", + "rule" => "input like %{2059901803}", + "score" => 1, + "correct" => true, + "feedback" => %{ + "id" => "3973552980", + "editor" => "slate", + "content" => [ + %{ + "id" => "560772231", + "type" => "p", + "children" => [ + %{ + "text" => "Correct" + } + ] + } + ], + "textDirection" => "ltr" + } + }, + %{ + "id" => "3417030249", + "rule" => "input like %{.*}", + "score" => 0, + "feedback" => %{ + "id" => "2851599196", + "editor" => "slate", + "content" => [ + %{ + "id" => "3187218957", + "type" => "p", + "children" => [ + %{ + "text" => "Incorrect" + } + ] + } + ], + "textDirection" => "ltr" + } + } + ], + "gradingApproach" => "automatic", + "scoringStrategy" => "average" + }, + %{ + "id" => "1", + "hints" => [ + %{ + "id" => "3763332041", + "editor" => "slate", + "content" => [ + %{ + "id" => "38052499", + "type" => "p", + "children" => [ + %{ + "text" => "" + } + ] + } + ], + "textDirection" => "ltr" + }, + %{ + "id" => "665246757", + "editor" => "slate", + "content" => [ + %{ + "id" => "3064108522", + "type" => "p", + "children" => [ + %{ + "text" => "" + } + ] + } + ], + "textDirection" => "ltr" + }, + %{ + "id" => "1510363789", + "editor" => "slate", + "content" => [ + %{ + "id" => "3760829234", + "type" => "p", + "children" => [ + %{ + "text" => "" + } + ] + } + ], + "textDirection" => "ltr" + } + ], + "outOf" => nil, + "targets" => [], + "responses" => [ + %{ + "id" => "1625009830", + "rule" => "input like %{2059901803}", + "score" => 1, + "correct" => true, + "feedback" => %{ + "id" => "3396414840", + "editor" => "slate", + "content" => [ + %{ + "id" => "3625710370", + "type" => "p", + "children" => [ + %{ + "text" => "Correct" + } + ] + } + ], + "textDirection" => "ltr" + } + }, + %{ + "id" => "2686336238", + "rule" => "input like %{.*}", + "score" => 0, + "feedback" => %{ + "id" => "1381016321", + "editor" => "slate", + "content" => [ + %{ + "id" => "798578659", + "type" => "p", + "children" => [ + %{ + "text" => "Incorrect" + } + ] + } + ], + "textDirection" => "ltr" + } + } + ], + "gradingApproach" => "automatic", + "scoringStrategy" => "average" + } + ], + "targeted" => [], + "previewText" => "", + "transformations" => [] + }, + "activityTitle" => "", + "orderDescending" => false + } + end end