diff --git a/CHANGELOG.md b/CHANGELOG.md index 653d0d9d..2d8db52b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +### v.2.3.1 + +### Fixes and improvements + +- Improve performance of presentation to load slides faster +- Fix manager layout on small screens +- Add clickable hyperlinks in messages +- Improve quiz export +- Add option to force login to submit quizzes +- Fix url with question mark being flagged as a question + ### v.2.3.0 ### Features diff --git a/assets/js/manager.js b/assets/js/manager.js index 06d5ba63..0060bcce 100644 --- a/assets/js/manager.js +++ b/assets/js/manager.js @@ -5,6 +5,8 @@ export class Manager { this.context = context; this.currentPage = parseInt(context.el.dataset.currentPage); this.maxPage = parseInt(context.el.dataset.maxPage); + + localStorage.setItem("slide-position", this.currentPage); } init() { @@ -31,19 +33,14 @@ export class Manager { window.addEventListener("keydown", (e) => { if ((e.target.tagName || "").toLowerCase() != "input") { - e.preventDefault(); switch (e.key) { - case "ArrowUp": - this.prevPage(); - break; case "ArrowLeft": + e.preventDefault(); this.prevPage(); break; case "ArrowRight": - this.nextPage(); - break; - case "ArrowDown": + e.preventDefault(); this.nextPage(); break; } @@ -168,6 +165,7 @@ export class Manager { if (this.currentPage == this.maxPage - 1) return; this.currentPage += 1; + localStorage.setItem("slide-position", this.currentPage); this.context.pushEventTo(this.context.el, "current-page", { page: this.currentPage.toString(), }); @@ -177,6 +175,7 @@ export class Manager { if (this.currentPage == 0) return; this.currentPage -= 1; + localStorage.setItem("slide-position", this.currentPage); this.context.pushEventTo(this.context.el, "current-page", { page: this.currentPage.toString(), }); diff --git a/assets/js/presenter.js b/assets/js/presenter.js index 0abd0ba0..53b77046 100644 --- a/assets/js/presenter.js +++ b/assets/js/presenter.js @@ -19,6 +19,7 @@ export class Presenter { controls: false, swipeAngle: false, startIndex: this.currentPage, + speed: 0, loop: false, nav: false, }); @@ -29,8 +30,13 @@ export class Presenter { this.context.handleEvent("page", (data) => { //set current page + if (this.currentPage == data.current_page) { + return; + } + this.currentPage = parseInt(data.current_page); this.slider.goTo(data.current_page); + }); this.context.handleEvent("chat-visible", (data) => { @@ -103,35 +109,37 @@ export class Presenter { window.addEventListener("keyup", (e) => { if (e.target.tagName.toLowerCase() != "input") { - e.preventDefault(); switch (e.key) { case "f": // F + e.preventDefault(); this.fullscreen(); break; - case "ArrowUp": - window.opener.dispatchEvent( - new KeyboardEvent("keydown", { key: "ArrowUp" }) - ); - break; case "ArrowLeft": + e.preventDefault(); window.opener.dispatchEvent( new KeyboardEvent("keydown", { key: "ArrowLeft" }) ); break; case "ArrowRight": + e.preventDefault(); window.opener.dispatchEvent( new KeyboardEvent("keydown", { key: "ArrowRight" }) ); break; - case "ArrowDown": - window.opener.dispatchEvent( - new KeyboardEvent("keydown", { key: "ArrowDown" }) - ); - break; } } }); + + window.addEventListener("storage", (e) => { + console.log(e) + if (e.key == "slide-position") { + console.log("settings new value " + Date.now()) + this.currentPage = parseInt(e.newValue); + this.slider.goTo(e.newValue); + + } + }) } update() { diff --git a/lib/claper/events.ex b/lib/claper/events.ex index cacc4827..131c8fab 100644 --- a/lib/claper/events.ex +++ b/lib/claper/events.ex @@ -123,7 +123,7 @@ defmodule Claper.Events do query = from(e in Event, where: e.user_id == ^user_id and not is_nil(e.expired_at), - order_by: [desc: e.inserted_at] + order_by: [desc: e.expired_at] ) Repo.paginate(query, page: page, page_size: page_size, preload: preload) diff --git a/lib/claper/quizzes.ex b/lib/claper/quizzes.ex index 726bcca5..d1595bfd 100644 --- a/lib/claper/quizzes.ex +++ b/lib/claper/quizzes.ex @@ -443,6 +443,26 @@ defmodule Claper.Quizzes do end end + @doc """ + Get number of submissions for a given quiz_id + + ## Examples + + iex> get_number_submissions(quiz_id) + 12 + + """ + def get_submission_count(quiz_id) do + from(r in QuizResponse, + where: r.quiz_id == ^quiz_id, + select: + count( + fragment("DISTINCT COALESCE(?, CAST(? AS varchar))", r.attendee_identifier, r.user_id) + ) + ) + |> Repo.one() + end + @doc """ Calculate percentage of all quiz questions for a given quiz. diff --git a/lib/claper/quizzes/quiz.ex b/lib/claper/quizzes/quiz.ex index 7146e294..2e46ccb7 100644 --- a/lib/claper/quizzes/quiz.ex +++ b/lib/claper/quizzes/quiz.ex @@ -6,7 +6,8 @@ defmodule Claper.Quizzes.Quiz do field :title, :string field :position, :integer, default: 0 field :enabled, :boolean, default: false - field :show_results, :boolean, default: false + field :show_results, :boolean, default: true + field :allow_anonymous, :boolean, default: false field :lti_line_item_url, :string belongs_to :presentation_file, Claper.Presentations.PresentationFile @@ -30,6 +31,7 @@ defmodule Claper.Quizzes.Quiz do :presentation_file_id, :enabled, :show_results, + :allow_anonymous, :lti_resource_id, :lti_line_item_url ]) diff --git a/lib/claper_web/controllers/stat_controller.ex b/lib/claper_web/controllers/stat_controller.ex index 9b19d258..95e0be17 100644 --- a/lib/claper_web/controllers/stat_controller.ex +++ b/lib/claper_web/controllers/stat_controller.ex @@ -86,43 +86,87 @@ defmodule ClaperWeb.StatController do with quiz <- Quizzes.get_quiz!(quiz_id, [ :quiz_questions, + :quiz_responses, quiz_questions: :quiz_question_opts, + quiz_responses: [:quiz_question_opt, :user], presentation_file: :event ]), event <- quiz.presentation_file.event, :ok <- authorize_event_access(current_user, event) do - # Create headers for the CSV - headers = ["Question", "Correct Answers", "Total Responses", "Response Distribution (%)"] - - # Format data rows - data = - quiz.quiz_questions - |> Enum.map(fn question -> - [ - question.content, - # Correct answers - question.quiz_question_opts - |> Enum.filter(& &1.is_correct) - |> Enum.map_join(", ", & &1.content), - # Total responses - question.quiz_question_opts - |> Enum.map(& &1.response_count) - |> Enum.sum() - |> to_string(), - # Response distribution - question.quiz_question_opts - |> Enum.map_join(", ", fn opt -> - "#{opt.content}: #{opt.percentage}%" - end) - ] - end) + questions = quiz.quiz_questions + headers = build_quiz_headers(questions) - export_as_csv(conn, headers, data, "quiz-#{sanitize(quiz.title)}") - else - :unauthorized -> send_resp(conn, 403, "Forbidden") + # Group responses by user/attendee and question + responses_by_user = + Enum.group_by( + quiz.quiz_responses, + fn response -> response.user_id || response.attendee_identifier end + ) + + # Format data rows - one row per user with their answers and score + data = Enum.map(responses_by_user, &process_user_responses(&1, questions)) + + csv_content = + CSV.encode([headers | data]) + |> Enum.to_list() + |> to_string() + + send_download(conn, {:binary, csv_content}, + filename: "quiz_#{quiz.id}_results.csv", + content_type: "text/csv" + ) end end + defp build_quiz_headers(questions) do + question_headers = + questions + |> Enum.with_index(1) + |> Enum.map(fn {question, _index} -> question.content end) + + ["Attendee identifier", "User email"] ++ question_headers ++ ["Total"] + end + + defp process_user_responses({_user_id, responses}, questions) do + user_identifier = format_attendee_identifier(List.first(responses).attendee_identifier) + user_email = Map.get(List.first(responses).user || %{}, :email, "N/A") + responses_by_question = Enum.group_by(responses, & &1.quiz_question_id) + + answers_with_correctness = process_question_responses(questions, responses_by_question) + answers = Enum.map(answers_with_correctness, fn {answer, _} -> answer || "" end) + correct_count = Enum.count(answers_with_correctness, fn {_, correct} -> correct end) + total = "#{correct_count}/#{length(questions)}" + + [user_identifier, user_email] ++ answers ++ [total] + end + + defp process_question_responses(questions, responses_by_question) do + Enum.map(questions, fn question -> + question_responses = Map.get(responses_by_question, question.id, []) + + correct_opt_ids = + question.quiz_question_opts + |> Enum.filter(& &1.is_correct) + |> Enum.map(& &1.id) + |> MapSet.new() + + format_question_response(question_responses, correct_opt_ids) + end) + end + + defp format_question_response([], _correct_opt_ids), do: {nil, false} + + defp format_question_response(question_responses, correct_opt_ids) do + answers = Enum.map(question_responses, & &1.quiz_question_opt.content) + + all_correct = + Enum.all?(question_responses, fn r -> + MapSet.member?(correct_opt_ids, r.quiz_question_opt_id) + end) + + {Enum.join(answers, ", "), all_correct} + end + @doc """ Exports quiz as QTI format. Requires user to be either an event leader or the event owner. diff --git a/lib/claper_web/helpers.ex b/lib/claper_web/helpers.ex new file mode 100644 index 00000000..99c55984 --- /dev/null +++ b/lib/claper_web/helpers.ex @@ -0,0 +1,22 @@ +defmodule ClaperWeb.Helpers do + def format_body(body) do + url_regex = ~r/(https?:\/\/[^\s]+)/ + + body + |> String.split(url_regex, include_captures: true) + |> Enum.map(fn + "http" <> _rest = url -> + Phoenix.HTML.raw( + ~s(#{url}) + ) + + text -> + text + end) + end + + def body_without_links(text) do + url_regex = ~r/(https?:\/\/[^\s]+)/ + String.replace(text, url_regex, "") + end +end diff --git a/lib/claper_web/live/event_live/event_card_component.ex b/lib/claper_web/live/event_live/event_card_component.ex index eeffb337..52acac6b 100644 --- a/lib/claper_web/live/event_live/event_card_component.ex +++ b/lib/claper_web/live/event_live/event_card_component.ex @@ -14,9 +14,14 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
-

+ <%= @event.name %> -

+

-