mirror of
https://github.com/ClaperCo/Claper.git
synced 2025-12-16 11:57:58 +01:00
Add quizz feature + improvements
commit 705ea00064e552f482bff52c3c5b11d23fbd5b4c Author: Alex Lion <dev@alexandrelion.com> Date: Sat Dec 21 10:08:42 2024 -0500 Change version commit 330173bd64bb18c5ea7e68a2122f66497981c3c3 Author: Alex Lion <dev@alexandrelion.com> Date: Sat Dec 21 10:01:52 2024 -0500 Fix layout commit 3cc075962e961f8a78b0c30eca9b79db6b9a5731 Author: Alex Lion <dev@alexandrelion.com> Date: Thu Dec 19 14:20:59 2024 +0100 Update changelog commit 63b1fa7ee591d40e44005b7939f51c99cf3e119e Author: Alex Lion <dev@alexandrelion.com> Date: Sun Dec 15 18:56:01 2024 +0100 Fix upper commit 8e7bb1990c58e343de5aa18036eb0916573fb4c6 Author: Alex Lion <dev@alexandrelion.com> Date: Sun Dec 15 18:49:20 2024 +0100 Add pagination for events commit 28beacd120f0a1081e670e4a06bbc185cc699beb Author: Alex Lion <dev@alexandrelion.com> Date: Mon Dec 9 21:03:13 2024 +0100 Add pagination commit c79d6cce947869b98795b9baf541a32952624969 Author: Alex Lion <dev@alexandrelion.com> Date: Sun Dec 8 21:24:23 2024 +0100 Fix tests commit caad25ad75b5937ca0906dca89dedaa4d58ae072 Author: Alex Lion <dev@alexandrelion.com> Date: Sun Dec 8 17:43:55 2024 +0100 Fix user registration bug commit 38c3eecc49d1397a8bb7a4a11203775396d00272 Author: Alex Lion <dev@alexandrelion.com> Date: Sat Dec 7 22:23:24 2024 +0100 Update changelog commit e648ef08a0f61cf4b554fcbf0a83e02a2249de0d Author: Alex Lion <dev@alexandrelion.com> Date: Sat Dec 7 22:06:27 2024 +0100 Add obin commit 6925117818e117dbd60efea5ae6c81a26a57f76f Author: Alex Lion <dev@alexandrelion.com> Date: Sat Dec 7 19:39:03 2024 +0100 WIP commit be9b2886d3b879452f5bae08b3cdd181cac254f8 Author: Alex Lion <dev@alexandrelion.com> Date: Sat Dec 7 16:19:09 2024 +0100 Add LTI AGS for quizzes commit 29a7c96de6d4e38b26dfaa61bfa5e689a16d4935 Author: Alex Lion <dev@alexandrelion.com> Date: Thu Dec 5 13:30:49 2024 +0100 Add translations commit 249fdc9188c7613a6adafb0b983303c1ae7601bd Author: Alex Lion <dev@alexandrelion.com> Date: Tue Dec 3 21:37:27 2024 +0100 Add qti export commit c2d56e30cdb6c629e957c64e4393dfd9d5af7159 Author: Alex Lion <dev@alexandrelion.com> Date: Tue Dec 3 20:44:58 2024 +0100 Fix report embed commit a34c239f9014e53b079106f1935bc2b079d01eed Author: Alex Lion <dev@alexandrelion.com> Date: Sat Nov 30 11:32:32 2024 +0100 Add export quiz commit 8d1f34b90635776ae40849bd75fd135693b116fb Author: Alex Lion <dev@alexandrelion.com> Date: Sat Nov 30 00:56:50 2024 +0100 Improve design commit d9a7370419ed9e288eccf263c2715330831e45e4 Author: Alex Lion <dev@alexandrelion.com> Date: Sat Nov 30 00:43:27 2024 +0100 Add exports commit b374b7bbccfa655dfad7695d7a24c5ddd4a07b66 Author: Alex Lion <dev@alexandrelion.com> Date: Thu Nov 28 15:22:41 2024 +0100 Remove presence on manager commit 404e759ae3d2f5e555ae20437204553bffdc5065 Author: Alex Lion <dev@alexandrelion.com> Date: Thu Nov 28 15:22:33 2024 +0100 Improve engagement report commit 39dbec6692c2d3f74a97647a703993d6152bfa06 Author: Alex Lion <dev@alexandrelion.com> Date: Sat Nov 23 15:39:59 2024 +0100 Add translation commit 354c2e30aece5bc7d800893ce8dee3868a1c1f71 Author: Alex Lion <dev@alexandrelion.com> Date: Sat Nov 23 15:18:53 2024 +0100 Change product tour behavior commit 5f253812282fb11011694b8828580d886f1f5899 Author: Alex <dev@alexandrelion.com> Date: Tue Nov 19 19:59:32 2024 +0100 WIP commit f411180433a05b89fc9d029e2b313968985e5c3f Author: Alex <dev@alexandrelion.com> Date: Tue Nov 19 19:32:30 2024 +0100 WIP commit 2b5989774eeb839f7b7b2a49377aca9fe4d68c09 Author: Alex <dev@alexandrelion.com> Date: Sun Nov 17 19:31:27 2024 +0100 WIP commit c8750a667f131b68818859796670c3022c6d53fe Author: Alex <dev@alexandrelion.com> Date: Sun Nov 17 18:23:01 2024 +0100 WIP commit fdb9efecb5688423ed2c82cf445868040653d380 Author: Alex <dev@alexandrelion.com> Date: Sun Nov 17 17:55:57 2024 +0100 WIP commit 5d12b12ce33eb5c1ba2a3307ef4ac679b279f511 Author: Alex <dev@alexandrelion.com> Date: Sat Nov 16 21:22:12 2024 +0100 WIP commit 548b714fda61464517247910af7e3e1c2bdae8cf Author: Alex <dev@alexandrelion.com> Date: Fri Nov 15 15:34:00 2024 +0100 WIP commit f0c87f34ea2ac837b4b3b3d6fd51c32bd625371e Author: Alex <dev@alexandrelion.com> Date: Wed Nov 13 22:09:24 2024 +0100 WIP commit c0c8bf99a538653208e28300566cced3d444a764 Author: Alex <dev@alexandrelion.com> Date: Mon Nov 11 13:02:36 2024 +0100 WIP commit 245ea9b836c2e69c7269fc7d8c7fd2edd0032eed Author: Alex <dev@alexandrelion.com> Date: Sun Nov 10 19:07:36 2024 +0100 Add presenter commit 0cf50918d62a9ab5ea127698219e05f781c659bb Author: Alex <dev@alexandrelion.com> Date: Sat Nov 9 23:20:35 2024 +0100 Refactor reactions commit ef8ffefe56d5b19dd895be181437c461134176ab Author: Alex <dev@alexandrelion.com> Date: Sat Nov 9 22:21:13 2024 +0100 Add tests commit c4055142ed63d8ea1be921f527bcaf595a2b9268 Author: Alex <dev@alexandrelion.com> Date: Sat Nov 9 11:28:00 2024 +0100 WIP commit 779e6970f7ee7ca89aab2bdfcff6197895b9ce5e Author: Alex <dev@alexandrelion.com> Date: Fri Nov 8 17:21:11 2024 +0100 WIP commit 9d25c440b830ded7e6fc2e0bcc9353520ec4a951 Author: Alex <dev@alexandrelion.com> Date: Fri Nov 8 11:54:31 2024 +0100 WIP commit c0157487a9e20b6773e517553681915c12367851 Author: Alex <dev@alexandrelion.com> Date: Fri Nov 1 17:13:04 2024 +0100 Fix condition commit a64439fbf2d852e127deb00a11906fb86b0c9ece Author: Alex <dev@alexandrelion.com> Date: Fri Nov 1 12:16:21 2024 +0100 WIP commit a994d959afe20ee380d42feb5ca6da2ab832d569 Author: Alex <dev@alexandrelion.com> Date: Wed Oct 30 23:06:24 2024 +0100 Fix changeset commit 5b2935fc33577af21ccc2558b49d9a813f4835f3 Merge: cec1a977476269Author: Alex <dev@alexandrelion.com> Date: Sun Oct 20 11:26:51 2024 +0200 Merge branch 'dev' into feature/quizz commit cec1a97650867da3a09d8e23d0756a3a573e1bc8 Author: Alex <dev@alexandrelion.com> Date: Sat Oct 19 22:52:00 2024 +0200 WIP commit f65854f638393ce80fd9d17642e8a90ee5c1a06e Author: Alex <dev@alexandrelion.com> Date: Sat Oct 19 16:55:42 2024 +0200 WIP commit 1e6429a386c56be6a8fdd2f083e273b50a6bc4c9 Merge:19779596f8a2fdAuthor: Alex <dev@alexandrelion.com> Date: Sat Oct 19 13:49:22 2024 +0200 Merge branch 'dev' into feature/quizz # Conflicts: # lib/claper_web/live/event_live/manage.html.heex commit1977959efbAuthor: Alex <dev@alexandrelion.com> Date: Sat Oct 5 12:57:09 2024 +0200 WIP
This commit is contained in:
18
CHANGELOG.md
18
CHANGELOG.md
@@ -1,3 +1,21 @@
|
||||
### v.2.3.0
|
||||
|
||||
### Features
|
||||
|
||||
- Add quizzes interaction with LTI AGS integration and QTI export
|
||||
- Add join link in manager view to join attendee room more easily
|
||||
- Export all interactions to CSV in the reports view
|
||||
- Add Oban for asynchronous jobs (mailer and LMS API calls)
|
||||
|
||||
### Fixes and improvements
|
||||
|
||||
- New report view with better metrics and tab-view for all interactions
|
||||
- Improve design improvements for interaction boxes in attendee room
|
||||
- Fix engagement rate stats
|
||||
- Add button to trigger product tour instead of automatically starting it
|
||||
- Improve design and UX for interactions and presentation settings in the manager view
|
||||
- Add pagination for events on the dashboard
|
||||
|
||||
## v2.2.0
|
||||
|
||||
### Features
|
||||
|
||||
@@ -68,6 +68,10 @@ Hooks.EmbeddedBanner = {
|
||||
|
||||
Hooks.TourGuide = {
|
||||
mounted() {
|
||||
this.triggerDiv = document.querySelector(this.el.dataset.btnTrigger);
|
||||
this.btnTrigger = this.triggerDiv.querySelector('.open');
|
||||
this.closeBtnTrigger = this.triggerDiv.querySelector('.close');
|
||||
|
||||
this.tour = new TourGuideClient({
|
||||
nextLabel: this.el.dataset.nextLabel,
|
||||
prevLabel: this.el.dataset.prevLabel,
|
||||
@@ -77,12 +81,35 @@ Hooks.TourGuide = {
|
||||
});
|
||||
|
||||
if (!this.tour.isFinished(this.el.dataset.group)) {
|
||||
this.tour.start(this.el.dataset.group);
|
||||
this.triggerDiv.classList.remove("hidden");
|
||||
}
|
||||
|
||||
this.tour.onBeforeExit(() => {
|
||||
this.tour.finishTour(true, this.el.dataset.group);
|
||||
});
|
||||
|
||||
this.btnTrigger.addEventListener("click", () => {
|
||||
this.startTour();
|
||||
});
|
||||
|
||||
this.closeBtnTrigger.addEventListener("click", (e) => {
|
||||
this.triggerDiv.classList.add("hidden");
|
||||
this.tour.finishTour(true, this.el.dataset.group);
|
||||
});
|
||||
},
|
||||
|
||||
startTour() {
|
||||
this.triggerDiv.classList.add("hidden");
|
||||
this.tour.start(this.el.dataset.group);
|
||||
},
|
||||
destroyed() {
|
||||
this.btnTrigger.removeEventListener("click", () => {
|
||||
this.startTour();
|
||||
});
|
||||
this.closeBtnTrigger.removeEventListener("click", () => {
|
||||
this.triggerDiv.classList.add("hidden");
|
||||
this.tour.finishTour(true, this.el.dataset.group);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -95,7 +122,7 @@ Hooks.Split = {
|
||||
const columnSlitValue =
|
||||
localStorage.getItem(`column-split-${id}`) || "1fr 10px 1fr";
|
||||
const rowSlitValue =
|
||||
localStorage.getItem(`row-split-${id}`) || "1fr 10px 1fr";
|
||||
localStorage.getItem(`row-split-${id}`) || "0.5fr 10px 1fr";
|
||||
|
||||
if (type === "column") {
|
||||
this.columnSplit = Split({
|
||||
|
||||
@@ -20,6 +20,15 @@ config :claper, ClaperWeb.Gettext,
|
||||
default_locale: "en",
|
||||
locales: ~w(fr en de es nl)
|
||||
|
||||
config :claper, Oban,
|
||||
engine: Oban.Engines.Basic,
|
||||
queues: [default: 10, mailers: 20],
|
||||
repo: Claper.Repo,
|
||||
plugins: [
|
||||
{Oban.Plugins.Pruner, max_age: 60 * 60 * 24 * 7},
|
||||
{Oban.Plugins.Lifeline, rescue_after: :timer.minutes(30)}
|
||||
]
|
||||
|
||||
config :dart_sass,
|
||||
version: "1.61.0",
|
||||
default: [
|
||||
|
||||
@@ -30,27 +30,37 @@ port = get_int_from_path_or_env(config_dir, "PORT", "4000")
|
||||
|
||||
secret_key_base = get_var_from_path_or_env(config_dir, "SECRET_KEY_BASE", nil)
|
||||
|
||||
case secret_key_base do
|
||||
nil ->
|
||||
raise "SECRET_KEY_BASE configuration option is required. See https://docs.claper.co/configuration.html#production-docker"
|
||||
if System.get_env("MIX_ENV") == "prod" or Application.get_env(:claper, :server, false) do
|
||||
case secret_key_base do
|
||||
nil ->
|
||||
raise "SECRET_KEY_BASE configuration option is required. See https://docs.claper.co/configuration.html#production-docker"
|
||||
|
||||
key when byte_size(key) < 32 ->
|
||||
raise "SECRET_KEY_BASE must be at least 32 bytes long. See https://docs.claper.co/configuration.html#production-docker"
|
||||
key when byte_size(key) < 32 ->
|
||||
raise "SECRET_KEY_BASE must be at least 32 bytes long. See https://docs.claper.co/configuration.html#production-docker"
|
||||
|
||||
_ ->
|
||||
nil
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
base_url = get_var_from_path_or_env(config_dir, "BASE_URL")
|
||||
base_url = get_var_from_path_or_env(config_dir, "BASE_URL", "http://localhost:4000")
|
||||
|
||||
if !base_url do
|
||||
raise "BASE_URL configuration option is required. See https://docs.claper.co/configuration.html#production-docker"
|
||||
if System.get_env("MIX_ENV") == "prod" or Application.get_env(:claper, :server, false) do
|
||||
case base_url do
|
||||
nil ->
|
||||
raise "BASE_URL configuration option is required. See https://docs.claper.co/configuration.html#production-docker"
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
base_url = URI.parse(base_url)
|
||||
|
||||
if base_url.scheme not in ["http", "https"] do
|
||||
raise "BASE_URL must start with `http` or `https`. Currently configured as `#{System.get_env("BASE_URL")}`"
|
||||
if System.get_env("MIX_ENV") == "prod" or Application.get_env(:claper, :server, false) do
|
||||
if base_url.scheme not in ["http", "https"] do
|
||||
raise "BASE_URL must start with `http` or `https`. Currently configured as `#{System.get_env("BASE_URL")}`"
|
||||
end
|
||||
end
|
||||
|
||||
max_file_size = get_int_from_path_or_env(config_dir, "MAX_FILE_SIZE_MB", 15)
|
||||
|
||||
@@ -23,6 +23,8 @@ config :claper, ClaperWeb.Endpoint,
|
||||
# In test we don't send emails.
|
||||
config :claper, Claper.Mailer, adapter: Swoosh.Adapters.Test
|
||||
|
||||
config :claper, Oban, testing: :inline
|
||||
|
||||
# Print only warnings and errors during test
|
||||
config :logger, level: :warning
|
||||
|
||||
|
||||
2
dev.sh
2
dev.sh
@@ -6,6 +6,8 @@ args=("$@")
|
||||
|
||||
if [ "${args[0]}" == "start" ]; then
|
||||
mix phx.server
|
||||
elif [ "${args[0]}" == "iex" ]; then
|
||||
iex -S mix
|
||||
else
|
||||
mix "$@"
|
||||
fi
|
||||
|
||||
@@ -276,6 +276,7 @@ defmodule Claper.Accounts do
|
||||
|
||||
Repo.insert!(user_token)
|
||||
UserNotifier.deliver_magic_link(email, magic_link_url_fun.(encoded_token))
|
||||
{:ok, encoded_token}
|
||||
end
|
||||
|
||||
@doc """
|
||||
@@ -293,6 +294,7 @@ defmodule Claper.Accounts do
|
||||
|
||||
Repo.insert!(user_token)
|
||||
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
|
||||
{:ok, encoded_token}
|
||||
end
|
||||
|
||||
@doc """
|
||||
@@ -393,6 +395,7 @@ defmodule Claper.Accounts do
|
||||
{encoded_token, user_token} = UserToken.build_email_token(user, "confirm")
|
||||
Repo.insert!(user_token)
|
||||
UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token))
|
||||
{:ok, encoded_token}
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ defmodule Claper.Accounts.User do
|
||||
field :locale, :string
|
||||
|
||||
has_many :events, Claper.Events.Event
|
||||
has_one :lti_user, Lti13.Users.User
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
defmodule Claper.Accounts.UserNotifier do
|
||||
# import Swoosh.Email
|
||||
|
||||
alias Claper.Mailer
|
||||
|
||||
# Delivers the email using the application mailer.
|
||||
# defp deliver(recipient, subject, body) do
|
||||
# from_name = Application.get_env(:claper, :mail)[:from_name]
|
||||
@@ -21,51 +19,41 @@ defmodule Claper.Accounts.UserNotifier do
|
||||
# end
|
||||
|
||||
def deliver_magic_link(email, url) do
|
||||
email = ClaperWeb.Notifiers.UserNotifier.magic(email, url)
|
||||
Claper.Workers.Mailers.new_magic_link(email, url) |> Oban.insert()
|
||||
|
||||
with {:ok, _metadata} <- Mailer.deliver(email) do
|
||||
{:ok, email}
|
||||
end
|
||||
{:ok, :enqueued}
|
||||
end
|
||||
|
||||
def deliver_welcome(email) do
|
||||
email = ClaperWeb.Notifiers.UserNotifier.welcome(email)
|
||||
Claper.Workers.Mailers.new_welcome(email) |> Oban.insert()
|
||||
|
||||
with {:ok, _metadata} <- Mailer.deliver(email) do
|
||||
{:ok, email}
|
||||
end
|
||||
{:ok, :enqueued}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to confirm account.
|
||||
"""
|
||||
def deliver_confirmation_instructions(user, url) do
|
||||
email = ClaperWeb.Notifiers.UserNotifier.confirm(user, url)
|
||||
Claper.Workers.Mailers.new_confirmation(user.id, url) |> Oban.insert()
|
||||
|
||||
with {:ok, _metadata} <- Mailer.deliver(email) do
|
||||
{:ok, email}
|
||||
end
|
||||
{:ok, :enqueued}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to reset a user password.
|
||||
"""
|
||||
def deliver_reset_password_instructions(user, url) do
|
||||
email = ClaperWeb.Notifiers.UserNotifier.reset(user, url)
|
||||
Claper.Workers.Mailers.new_reset_password(user.id, url) |> Oban.insert()
|
||||
|
||||
with {:ok, _metadata} <- Mailer.deliver(email) do
|
||||
{:ok, email}
|
||||
end
|
||||
{:ok, :enqueued}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deliver instructions to update a user email.
|
||||
"""
|
||||
def deliver_update_email_instructions(user, url) do
|
||||
email = ClaperWeb.Notifiers.UserNotifier.update_email(user, url)
|
||||
Claper.Workers.Mailers.new_update_email(user.id, url) |> Oban.insert()
|
||||
|
||||
with {:ok, _metadata} <- Mailer.deliver(email) do
|
||||
{:ok, email}
|
||||
end
|
||||
{:ok, :enqueued}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -9,6 +9,7 @@ defmodule Claper.Application do
|
||||
def start(_type, _args) do
|
||||
topologies = Application.get_env(:libcluster, :topologies) || []
|
||||
oidc_config = Application.get_env(:claper, :oidc) || []
|
||||
Oban.Telemetry.attach_default_logger()
|
||||
|
||||
children = [
|
||||
{Cluster.Supervisor, [topologies, [name: Claper.ClusterSupervisor]]},
|
||||
@@ -26,7 +27,8 @@ defmodule Claper.Application do
|
||||
{Finch, name: Swoosh.Finch},
|
||||
{Task.Supervisor, name: Claper.TaskSupervisor},
|
||||
{Oidcc.ProviderConfiguration.Worker,
|
||||
%{issuer: oidc_config[:issuer], name: Claper.OidcProviderConfig}}
|
||||
%{issuer: oidc_config[:issuer], name: Claper.OidcProviderConfig}},
|
||||
{Oban, Application.fetch_env!(:claper, Oban)}
|
||||
]
|
||||
|
||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||
|
||||
@@ -10,6 +10,8 @@ defmodule Claper.Events do
|
||||
|
||||
alias Claper.Events.{Event, ActivityLeader}
|
||||
|
||||
@default_page_size 5
|
||||
|
||||
@doc """
|
||||
Returns the list of events of a given user.
|
||||
|
||||
@@ -25,6 +27,108 @@ defmodule Claper.Events do
|
||||
|> Repo.preload(preload)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a paginated list of events for a given user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> paginate_events(123, %{page: 1, page_size: 10})
|
||||
{[%Event{}, ...], total_count, total_pages}
|
||||
|
||||
"""
|
||||
def paginate_events(user_id, params \\ %{}, preload \\ []) do
|
||||
page = Map.get(params, "page", 1)
|
||||
page_size = Map.get(params, "page_size", @default_page_size)
|
||||
|
||||
query =
|
||||
from(e in Event,
|
||||
where: e.user_id == ^user_id,
|
||||
order_by: [desc: e.inserted_at]
|
||||
)
|
||||
|
||||
Repo.paginate(query, page: page, page_size: page_size, preload: preload)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of not expired events for a given user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_not_expired_events(123)
|
||||
[%Event{}, ...]
|
||||
|
||||
"""
|
||||
def list_not_expired_events(user_id, preload \\ []) do
|
||||
from(e in Event,
|
||||
where: e.user_id == ^user_id and is_nil(e.expired_at),
|
||||
order_by: [desc: e.inserted_at]
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Repo.preload(preload)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a paginated list of not expired events for a given user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> paginate_not_expired_events(123, %{page: 1, page_size: 10})
|
||||
{[%Event{}, ...], total_count, total_pages}
|
||||
|
||||
"""
|
||||
def paginate_not_expired_events(user_id, params \\ %{}, preload \\ []) do
|
||||
page = Map.get(params, "page", 1)
|
||||
page_size = Map.get(params, "page_size", @default_page_size)
|
||||
|
||||
query =
|
||||
from(e in Event,
|
||||
where: e.user_id == ^user_id and is_nil(e.expired_at),
|
||||
order_by: [desc: e.inserted_at]
|
||||
)
|
||||
|
||||
Repo.paginate(query, page: page, page_size: page_size, preload: preload)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of expired events for a given user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_expired_events(123)
|
||||
[%Event{}, ...]
|
||||
|
||||
"""
|
||||
def list_expired_events(user_id, preload \\ []) do
|
||||
from(e in Event,
|
||||
where: e.user_id == ^user_id and not is_nil(e.expired_at),
|
||||
order_by: [desc: e.expired_at]
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Repo.preload(preload)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a paginated list of expired events for a given user.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> paginate_expired_events(123, %{page: 1, page_size: 10})
|
||||
{[%Event{}, ...], total_count, total_pages}
|
||||
|
||||
"""
|
||||
def paginate_expired_events(user_id, params \\ %{}, preload \\ []) do
|
||||
page = Map.get(params, "page", 1)
|
||||
page_size = Map.get(params, "page_size", @default_page_size)
|
||||
|
||||
query =
|
||||
from(e in Event,
|
||||
where: e.user_id == ^user_id and not is_nil(e.expired_at),
|
||||
order_by: [desc: e.inserted_at]
|
||||
)
|
||||
|
||||
Repo.paginate(query, page: page, page_size: page_size, preload: preload)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of events managed by a given user email.
|
||||
|
||||
@@ -48,6 +152,53 @@ defmodule Claper.Events do
|
||||
|> Repo.preload(preload)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a paginated list of events managed by a given user email.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> paginate_managed_events_by("email@example.com", %{page: 1, page_size: 10})
|
||||
{[%Event{}, ...], total_count, total_pages}
|
||||
|
||||
"""
|
||||
def paginate_managed_events_by(email, params \\ %{}, preload \\ []) do
|
||||
page = Map.get(params, "page", 1)
|
||||
page_size = Map.get(params, "page_size", @default_page_size)
|
||||
|
||||
query =
|
||||
from(a in ActivityLeader,
|
||||
join: u in Claper.Accounts.User,
|
||||
on: u.email == a.email,
|
||||
join: e in Event,
|
||||
on: e.id == a.event_id,
|
||||
where: a.email == ^email,
|
||||
order_by: [desc: e.expired_at],
|
||||
select: e
|
||||
)
|
||||
|
||||
Repo.paginate(query, page: page, page_size: page_size, preload: preload)
|
||||
end
|
||||
|
||||
def count_managed_events_by(email) do
|
||||
from(a in ActivityLeader,
|
||||
join: u in Claper.Accounts.User,
|
||||
on: u.email == a.email,
|
||||
join: e in Event,
|
||||
on: e.id == a.event_id,
|
||||
where: a.email == ^email,
|
||||
select: e
|
||||
)
|
||||
|> Repo.aggregate(:count, :id)
|
||||
end
|
||||
|
||||
def count_expired_events(user_id) do
|
||||
from(e in Event,
|
||||
where: e.user_id == ^user_id and not is_nil(e.expired_at),
|
||||
order_by: [desc: e.expired_at]
|
||||
)
|
||||
|> Repo.aggregate(:count, :id)
|
||||
end
|
||||
|
||||
def count_events_month(user_id) do
|
||||
# minus 30 days, calculated as seconds
|
||||
seconds = -30 * 24 * 3600
|
||||
@@ -386,6 +537,7 @@ defmodule Claper.Events do
|
||||
polls: [:poll_opts],
|
||||
forms: [],
|
||||
embeds: [],
|
||||
quizzes: [:quiz_questions, quiz_questions: :quiz_question_opts],
|
||||
presentation_state: []
|
||||
]
|
||||
)}
|
||||
@@ -491,6 +643,37 @@ defmodule Claper.Events do
|
||||
new_embed
|
||||
end)}
|
||||
end)
|
||||
|> Ecto.Multi.run(:quizzes, fn _repo,
|
||||
%{
|
||||
new_presentation_file: new_presentation_file,
|
||||
original_event: original_event
|
||||
} ->
|
||||
{:ok,
|
||||
Enum.map(original_event.presentation_file.quizzes, fn quiz ->
|
||||
quiz_attrs =
|
||||
Map.from_struct(quiz)
|
||||
|> Map.drop([:id, :inserted_at, :updated_at])
|
||||
|> Map.put(:presentation_file_id, new_presentation_file.id)
|
||||
|> Map.put(
|
||||
:quiz_questions,
|
||||
Enum.map(quiz.quiz_questions, fn question ->
|
||||
Map.from_struct(question)
|
||||
|> Map.drop([:id, :inserted_at, :updated_at])
|
||||
|> Map.put(
|
||||
:quiz_question_opts,
|
||||
Enum.map(question.quiz_question_opts, fn opt ->
|
||||
Map.from_struct(opt)
|
||||
|> Map.drop([:id, :inserted_at, :updated_at])
|
||||
|> Map.put(:response_count, 0)
|
||||
end)
|
||||
)
|
||||
end)
|
||||
)
|
||||
|
||||
{:ok, new_quiz} = Claper.Quizzes.create_quiz(quiz_attrs)
|
||||
new_quiz
|
||||
end)}
|
||||
end)
|
||||
|> Repo.transaction() do
|
||||
{:ok, %{new_event: new_event}} -> {:ok, new_event}
|
||||
{:error, _failed_operation, failed_value, _changes_so_far} -> {:error, failed_value}
|
||||
|
||||
@@ -22,15 +22,16 @@ defmodule Claper.Events.Event do
|
||||
field :uuid, :binary_id
|
||||
field :name, :string
|
||||
field :code, :string
|
||||
field :audience_peak, :integer, default: 1
|
||||
field :audience_peak, :integer, default: 0
|
||||
field :started_at, :naive_datetime
|
||||
field :expired_at, :naive_datetime
|
||||
|
||||
has_many :posts, Claper.Posts.Post
|
||||
|
||||
has_many :leaders, Claper.Events.ActivityLeader, on_replace: :delete
|
||||
|
||||
has_one :presentation_file, Claper.Presentations.PresentationFile
|
||||
has_one :lti_resource, Lti13.Resources.Resource
|
||||
|
||||
belongs_to :user, Claper.Accounts.User
|
||||
|
||||
timestamps()
|
||||
|
||||
@@ -4,7 +4,7 @@ defmodule Claper.Interactions do
|
||||
alias Claper.Embeds
|
||||
alias Claper.Events
|
||||
alias Claper.Presentations
|
||||
|
||||
alias Claper.Quizzes
|
||||
import Ecto.Query, warn: false
|
||||
|
||||
@type interaction :: Polls.Poll | Forms.Form | Embeds.Embed
|
||||
@@ -29,6 +29,13 @@ defmodule Claper.Interactions do
|
||||
)
|
||||
|> Claper.Repo.one()
|
||||
)
|
||||
|> Kernel.+(
|
||||
from(q in Quizzes.Quiz,
|
||||
where: q.presentation_file_id == ^presentation_file_id,
|
||||
select: count(q.id)
|
||||
)
|
||||
|> Claper.Repo.one()
|
||||
)
|
||||
end
|
||||
|
||||
def get_active_interaction(event, position) do
|
||||
@@ -46,9 +53,10 @@ defmodule Claper.Interactions do
|
||||
) do
|
||||
with polls <- Polls.list_polls_at_position(presentation_file_id, position),
|
||||
forms <- Forms.list_forms_at_position(presentation_file_id, position),
|
||||
embeds <- Embeds.list_embeds_at_position(presentation_file_id, position) do
|
||||
embeds <- Embeds.list_embeds_at_position(presentation_file_id, position),
|
||||
quizzes <- Quizzes.list_quizzes_at_position(presentation_file_id, position) do
|
||||
interactions =
|
||||
(polls ++ forms ++ embeds)
|
||||
(polls ++ forms ++ embeds ++ quizzes)
|
||||
|> Enum.sort_by(& &1.inserted_at, {:asc, NaiveDateTime})
|
||||
|
||||
if broadcast do
|
||||
@@ -79,6 +87,10 @@ defmodule Claper.Interactions do
|
||||
{count, _} = Embeds.disable_all(interaction.presentation_file_id, interaction.position)
|
||||
{:ok, count}
|
||||
end)
|
||||
|> Ecto.Multi.run(:disable_quizzes, fn _repo, _ ->
|
||||
{count, _} = Quizzes.disable_all(interaction.presentation_file_id, interaction.position)
|
||||
{:ok, count}
|
||||
end)
|
||||
|> Ecto.Multi.run(:enable_interaction, fn _repo, _ ->
|
||||
set_enabled(interaction)
|
||||
end)
|
||||
@@ -101,6 +113,10 @@ defmodule Claper.Interactions do
|
||||
Embeds.set_enabled(interaction.id)
|
||||
end
|
||||
|
||||
defp set_enabled(%Quizzes.Quiz{} = interaction) do
|
||||
Quizzes.set_enabled(interaction.id)
|
||||
end
|
||||
|
||||
def disable_interaction(%Polls.Poll{} = interaction) do
|
||||
Polls.set_disabled(interaction.id)
|
||||
end
|
||||
@@ -112,4 +128,8 @@ defmodule Claper.Interactions do
|
||||
def disable_interaction(%Embeds.Embed{} = interaction) do
|
||||
Embeds.set_disabled(interaction.id)
|
||||
end
|
||||
|
||||
def disable_interaction(%Quizzes.Quiz{} = interaction) do
|
||||
Quizzes.set_disabled(interaction.id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,6 +11,7 @@ defmodule Claper.Presentations.PresentationFile do
|
||||
polls: [Claper.Polls.Poll.t()] | nil,
|
||||
forms: [Claper.Forms.Form.t()] | nil,
|
||||
embeds: [Claper.Embeds.Embed.t()] | nil,
|
||||
quizzes: [Claper.Quizzes.Quiz.t()] | nil,
|
||||
presentation_state: Claper.Presentations.PresentationState.t(),
|
||||
inserted_at: NaiveDateTime.t(),
|
||||
updated_at: NaiveDateTime.t()
|
||||
@@ -25,6 +26,7 @@ defmodule Claper.Presentations.PresentationFile do
|
||||
has_many :polls, Claper.Polls.Poll
|
||||
has_many :forms, Claper.Forms.Form
|
||||
has_many :embeds, Claper.Embeds.Embed
|
||||
has_many :quizzes, Claper.Quizzes.Quiz
|
||||
has_one :presentation_state, Claper.Presentations.PresentationState, on_replace: :delete
|
||||
|
||||
timestamps()
|
||||
|
||||
563
lib/claper/quizzes.ex
Normal file
563
lib/claper/quizzes.ex
Normal file
@@ -0,0 +1,563 @@
|
||||
defmodule Claper.Quizzes do
|
||||
import Ecto.Query, warn: false
|
||||
alias Claper.Repo
|
||||
|
||||
alias Claper.Quizzes.Quiz
|
||||
alias Claper.Quizzes.QuizQuestion
|
||||
alias Claper.Quizzes.QuizQuestionOpt
|
||||
alias Claper.Quizzes.QuizResponse
|
||||
|
||||
@doc """
|
||||
Returns the list of quizzes for a given presentation file.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_quizzes(123)
|
||||
[%Quiz{}, ...]
|
||||
|
||||
"""
|
||||
def list_quizzes(presentation_file_id) do
|
||||
from(p in Quiz,
|
||||
where: p.presentation_file_id == ^presentation_file_id,
|
||||
order_by: [asc: p.id, asc: p.position]
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Repo.preload([:quiz_questions, quiz_questions: :quiz_question_opts])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the list of quizzes for a given presentation file and a given position.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> list_quizzes_at_position(123, 0)
|
||||
[%Quiz{}, ...]
|
||||
|
||||
"""
|
||||
def list_quizzes_at_position(presentation_file_id, position) do
|
||||
from(q in Quiz,
|
||||
where: q.presentation_file_id == ^presentation_file_id and q.position == ^position,
|
||||
order_by: [asc: q.id]
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Repo.preload([:quiz_questions, quiz_questions: :quiz_question_opts])
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single quiz by ID.
|
||||
|
||||
Raises `Ecto.NoResultsError` if the Quiz does not exist.
|
||||
|
||||
## Parameters
|
||||
|
||||
- id: The ID of the quiz.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_quiz!(123, [:quiz_questions, quiz_questions: :quiz_question_opts])
|
||||
%Quiz{}
|
||||
|
||||
iex> get_quiz!(456)
|
||||
** (Ecto.NoResultsError)
|
||||
|
||||
"""
|
||||
def get_quiz!(id, preload \\ []) do
|
||||
Quiz
|
||||
|> Repo.get!(id)
|
||||
|> Repo.preload(preload)
|
||||
|> set_percentages()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a single quiz for a given position.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_quiz_current_position(123, 0)
|
||||
%Quiz{}
|
||||
|
||||
"""
|
||||
def get_quiz_current_position(presentation_file_id, position) do
|
||||
from(q in Quiz,
|
||||
where:
|
||||
q.position == ^position and q.presentation_file_id == ^presentation_file_id and
|
||||
q.enabled == true
|
||||
)
|
||||
|> Repo.one()
|
||||
|> Repo.preload([:quiz_questions, quiz_questions: :quiz_question_opts])
|
||||
|> set_percentages()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a quiz.
|
||||
|
||||
## Parameters
|
||||
|
||||
- attrs: A map of attributes for creating a quiz.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> create_quiz(%{field: value})
|
||||
{:ok, %Quiz{}}
|
||||
|
||||
iex> create_quiz(%{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def create_quiz(attrs \\ %{}) do
|
||||
%Quiz{}
|
||||
|> Quiz.changeset(attrs)
|
||||
|> Repo.insert()
|
||||
|> case do
|
||||
{:ok, quiz} ->
|
||||
Claper.Workers.QuizLti.create(quiz.id) |> Oban.insert()
|
||||
{:ok, quiz}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a quiz.
|
||||
|
||||
## Parameters
|
||||
|
||||
- quiz: The quiz struct to update.
|
||||
- attrs: A map of attributes to update.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> update_quiz(quiz, %{field: new_value})
|
||||
{:ok, %Quiz{}}
|
||||
|
||||
iex> update_quiz(quiz, %{field: bad_value})
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def update_quiz(event_uuid, %Quiz{} = quiz, attrs) do
|
||||
quiz
|
||||
|> Quiz.changeset(attrs)
|
||||
|> Repo.update()
|
||||
|> case do
|
||||
{:ok, updated_quiz} ->
|
||||
Claper.Workers.QuizLti.edit(updated_quiz.id) |> Oban.insert()
|
||||
broadcast({:ok, updated_quiz, event_uuid}, :quiz_updated)
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Deletes a quiz.
|
||||
|
||||
## Parameters
|
||||
|
||||
- event_uuid: The UUID of the event.
|
||||
- quiz: The quiz struct to delete.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> delete_quiz(event_uuid, quiz)
|
||||
{:ok, %Quiz{}}
|
||||
|
||||
iex> delete_quiz(event_uuid, quiz)
|
||||
{:error, %Ecto.Changeset{}}
|
||||
|
||||
"""
|
||||
def delete_quiz(event_uuid, %Quiz{} = quiz) do
|
||||
case Repo.delete(quiz) do
|
||||
{:ok, quiz} ->
|
||||
broadcast({:ok, quiz, event_uuid}, :quiz_deleted)
|
||||
{:ok, quiz}
|
||||
|
||||
error ->
|
||||
error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns an `%Ecto.Changeset{}` for tracking quiz changes.
|
||||
|
||||
## Parameters
|
||||
|
||||
- quiz: The quiz struct to create a changeset for.
|
||||
- attrs: A map of attributes (optional).
|
||||
|
||||
## Examples
|
||||
|
||||
iex> change_quiz(quiz)
|
||||
%Ecto.Changeset{data: %Quiz{}}
|
||||
|
||||
"""
|
||||
def change_quiz(%Quiz{} = quiz, attrs \\ %{}) do
|
||||
Quiz.changeset(quiz, attrs)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Adds a new quiz question to a quiz changeset.
|
||||
|
||||
Creates a new question with two default empty options and appends it to the existing questions.
|
||||
|
||||
## Parameters
|
||||
|
||||
- changeset: The quiz changeset to add the question to.
|
||||
|
||||
## Returns
|
||||
|
||||
The updated changeset with the new question added.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> add_quiz_question(quiz_changeset)
|
||||
%Ecto.Changeset{}
|
||||
|
||||
"""
|
||||
def add_quiz_question(changeset) do
|
||||
existing_questions = Ecto.Changeset.get_field(changeset, :quiz_questions, [])
|
||||
|
||||
new_question = %QuizQuestion{
|
||||
quiz_question_opts: [
|
||||
%QuizQuestionOpt{},
|
||||
%QuizQuestionOpt{}
|
||||
]
|
||||
}
|
||||
|
||||
new_question_changeset = Ecto.Changeset.change(new_question)
|
||||
|
||||
updated_questions = existing_questions ++ [new_question_changeset]
|
||||
|
||||
Ecto.Changeset.put_assoc(changeset, :quiz_questions, updated_questions)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Submits quiz responses for a user.
|
||||
|
||||
Records the user's selected options and increments response counts.
|
||||
|
||||
## Parameters
|
||||
|
||||
- user_id: The ID of the user submitting responses
|
||||
- quiz_opts: List of selected quiz options
|
||||
- quiz_id: The ID of the quiz being submitted
|
||||
|
||||
## Returns
|
||||
|
||||
Broadcasts the updated quiz on successful submission.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> submit_quiz(123, quiz_opts, 456)
|
||||
{:ok, quiz}
|
||||
|
||||
"""
|
||||
def submit_quiz(user_id, quiz_opts, quiz_id)
|
||||
when is_number(user_id) and is_list(quiz_opts) do
|
||||
case Enum.reduce(quiz_opts, Ecto.Multi.new(), fn opt, multi ->
|
||||
Ecto.Multi.update(
|
||||
multi,
|
||||
{:update_quiz_opt, opt.id},
|
||||
QuizQuestionOpt.changeset(opt, %{"response_count" => opt.response_count + 1})
|
||||
)
|
||||
|> Ecto.Multi.insert(
|
||||
{:insert_quiz_response, opt.id},
|
||||
QuizResponse.changeset(%QuizResponse{}, %{
|
||||
user_id: user_id,
|
||||
quiz_question_opt_id: opt.id,
|
||||
quiz_question_id: opt.quiz_question_id,
|
||||
quiz_id: quiz_id
|
||||
})
|
||||
)
|
||||
end)
|
||||
|> Repo.transaction() do
|
||||
{:ok, _} ->
|
||||
quiz = get_quiz!(quiz_id, [:quiz_questions, quiz_questions: :quiz_question_opts])
|
||||
Lti13.QuizScoreReporter.report_quiz_score(quiz, user_id)
|
||||
{:ok, quiz}
|
||||
end
|
||||
end
|
||||
|
||||
def submit_quiz(attendee_identifier, quiz_opts, quiz_id)
|
||||
when is_binary(attendee_identifier) and is_list(quiz_opts) do
|
||||
case Enum.reduce(quiz_opts, Ecto.Multi.new(), fn opt, multi ->
|
||||
Ecto.Multi.update(
|
||||
multi,
|
||||
{:update_quiz_opt, opt.id},
|
||||
QuizQuestionOpt.changeset(opt, %{"response_count" => opt.response_count + 1})
|
||||
)
|
||||
|> Ecto.Multi.insert(
|
||||
{:insert_quiz_response, opt.id},
|
||||
QuizResponse.changeset(%QuizResponse{}, %{
|
||||
attendee_identifier: attendee_identifier,
|
||||
quiz_question_opt_id: opt.id,
|
||||
quiz_question_id: opt.quiz_question_id,
|
||||
quiz_id: quiz_id
|
||||
})
|
||||
)
|
||||
end)
|
||||
|> Repo.transaction() do
|
||||
{:ok, _} ->
|
||||
quiz = get_quiz!(quiz_id, [:quiz_questions, quiz_questions: :quiz_question_opts])
|
||||
{:ok, quiz}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates the quiz score for a given user, handling multiple correct answers per question.
|
||||
|
||||
Takes a user_id or attendee_identifier and returns their score for the specified quiz.
|
||||
|
||||
## Parameters
|
||||
|
||||
- user_id: Integer user ID or string attendee_identifier
|
||||
- quiz_id: The ID of the quiz to calculate score for
|
||||
|
||||
## Returns
|
||||
|
||||
A tuple containing {correct_answers, total_questions}.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> calculate_user_score(123, quiz_id)
|
||||
{3, 4}
|
||||
|
||||
iex> calculate_user_score("abc123", quiz_id)
|
||||
{3, 4}
|
||||
|
||||
"""
|
||||
def calculate_user_score(user_id, quiz_id) when is_number(user_id) or is_binary(user_id) do
|
||||
# Get the user's responses
|
||||
responses = get_quiz_responses(user_id, quiz_id)
|
||||
|
||||
# Get quiz with questions and correct answers
|
||||
quiz = get_quiz!(quiz_id, [:quiz_questions, quiz_questions: :quiz_question_opts])
|
||||
|
||||
# Count correct responses per question
|
||||
correct_count =
|
||||
quiz.quiz_questions
|
||||
|> Enum.count(fn question ->
|
||||
# Get all user responses for this question
|
||||
question_responses = Enum.filter(responses, &(&1.quiz_question_id == question.id))
|
||||
# Get all correct options for this question
|
||||
correct_opts = Enum.filter(question.quiz_question_opts, & &1.is_correct)
|
||||
|
||||
# User must select all correct options and no incorrect ones
|
||||
user_opt_ids = Enum.map(question_responses, & &1.quiz_question_opt_id) |> MapSet.new()
|
||||
correct_opt_ids = Enum.map(correct_opts, & &1.id) |> MapSet.new()
|
||||
|
||||
MapSet.equal?(user_opt_ids, correct_opt_ids)
|
||||
end)
|
||||
|
||||
{correct_count, length(quiz.quiz_questions)}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculates the average quiz score across all participants.
|
||||
|
||||
Takes a quiz_id and returns the average score as a percentage.
|
||||
|
||||
## Parameters
|
||||
|
||||
- quiz_id: The ID of the quiz to calculate average score for
|
||||
|
||||
## Returns
|
||||
|
||||
A float representing the average score percentage.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> calculate_average_score(123)
|
||||
75.5
|
||||
|
||||
"""
|
||||
def calculate_average_score(quiz_id) do
|
||||
# Get quiz with questions and correct answers
|
||||
quiz = get_quiz!(quiz_id, [:quiz_questions, quiz_questions: :quiz_question_opts])
|
||||
|
||||
# Get all responses grouped by user/attendee
|
||||
responses =
|
||||
from(r in QuizResponse,
|
||||
where: r.quiz_id == ^quiz_id,
|
||||
select: %{
|
||||
user_id: r.user_id,
|
||||
attendee_identifier: r.attendee_identifier,
|
||||
quiz_question_id: r.quiz_question_id,
|
||||
quiz_question_opt_id: r.quiz_question_opt_id
|
||||
}
|
||||
)
|
||||
|> Repo.all()
|
||||
|
||||
# Group responses by user_id or attendee_identifier
|
||||
responses_by_user =
|
||||
responses
|
||||
|> Enum.group_by(fn response ->
|
||||
case response.user_id do
|
||||
nil -> response.attendee_identifier
|
||||
id -> id
|
||||
end
|
||||
end)
|
||||
|
||||
scores =
|
||||
Enum.map(responses_by_user, fn {_user_id, user_responses} ->
|
||||
correct_count =
|
||||
quiz.quiz_questions
|
||||
|> Enum.count(fn question ->
|
||||
# Get user responses for this question
|
||||
question_responses =
|
||||
Enum.filter(user_responses, &(&1.quiz_question_id == question.id))
|
||||
|
||||
# Get correct options for this question
|
||||
correct_opts = Enum.filter(question.quiz_question_opts, & &1.is_correct)
|
||||
|
||||
# Check if user selected all correct options and no incorrect ones
|
||||
user_opt_ids = Enum.map(question_responses, & &1.quiz_question_opt_id) |> MapSet.new()
|
||||
correct_opt_ids = Enum.map(correct_opts, & &1.id) |> MapSet.new()
|
||||
|
||||
MapSet.equal?(user_opt_ids, correct_opt_ids)
|
||||
end)
|
||||
|
||||
correct_count
|
||||
end)
|
||||
|
||||
case length(scores) do
|
||||
0 ->
|
||||
0
|
||||
|
||||
n ->
|
||||
avg = Enum.sum(scores) / n
|
||||
if avg == trunc(avg), do: trunc(avg), else: avg
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Calculate percentage of all quiz questions for a given quiz.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> set_percentages(quiz)
|
||||
%Quiz{}
|
||||
|
||||
"""
|
||||
def set_percentages(%Quiz{quiz_questions: quiz_questions} = quiz)
|
||||
when is_list(quiz_questions) do
|
||||
%{
|
||||
quiz
|
||||
| quiz_questions: Enum.map(quiz_questions, &set_question_percentages/1)
|
||||
}
|
||||
end
|
||||
|
||||
def set_percentages(quiz), do: quiz
|
||||
|
||||
@doc """
|
||||
Calculate percentage of all quiz question options for a given quiz.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> set_question_percentages(quiz)
|
||||
%Quiz{}
|
||||
|
||||
"""
|
||||
def set_question_percentages(
|
||||
%QuizQuestion{quiz_question_opts: quiz_question_opts} =
|
||||
quiz_question
|
||||
)
|
||||
when is_list(quiz_question_opts) do
|
||||
total =
|
||||
Enum.map(quiz_question.quiz_question_opts, fn o -> o.response_count end) |> Enum.sum()
|
||||
|
||||
%{
|
||||
quiz_question
|
||||
| quiz_question_opts:
|
||||
quiz_question.quiz_question_opts
|
||||
|> Enum.map(fn o -> %{o | percentage: calculate_percentage(o, total)} end)
|
||||
}
|
||||
end
|
||||
|
||||
def set_question_percentages(quiz_question), do: quiz_question
|
||||
|
||||
defp calculate_percentage(opt, total) do
|
||||
if total > 0,
|
||||
do: Float.round(opt.response_count / total * 100) |> :erlang.float_to_binary(decimals: 0),
|
||||
else: 0
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a all quiz_response.
|
||||
|
||||
|
||||
## Examples
|
||||
|
||||
iex> get_quiz_responses!(321, 123)
|
||||
[%QuizResponse{}]
|
||||
|
||||
"""
|
||||
def get_quiz_responses(user_id, quiz_id) when is_number(user_id) do
|
||||
from(p in QuizResponse,
|
||||
where: p.quiz_id == ^quiz_id and p.user_id == ^user_id,
|
||||
order_by: [asc: p.id]
|
||||
)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
def get_quiz_responses(attendee_identifier, quiz_id) do
|
||||
from(p in QuizResponse,
|
||||
where: p.quiz_id == ^quiz_id and p.attendee_identifier == ^attendee_identifier,
|
||||
order_by: [asc: p.id]
|
||||
)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Add an empty quiz opt to a quiz changeset.
|
||||
"""
|
||||
def add_quiz_question_opt(changeset, question_index) do
|
||||
existing_questions = Ecto.Changeset.get_field(changeset, :quiz_questions, [])
|
||||
|
||||
new_opt = %QuizQuestionOpt{}
|
||||
new_opt_changeset = Ecto.Changeset.change(new_opt)
|
||||
|
||||
updated_questions =
|
||||
List.update_at(existing_questions, question_index, fn question ->
|
||||
question_changeset = Ecto.Changeset.change(question)
|
||||
|
||||
existing_opts = Ecto.Changeset.get_field(question_changeset, :quiz_question_opts, [])
|
||||
updated_opts = existing_opts ++ [new_opt_changeset]
|
||||
|
||||
Ecto.Changeset.put_change(question_changeset, :quiz_question_opts, updated_opts)
|
||||
end)
|
||||
|
||||
Ecto.Changeset.put_assoc(changeset, :quiz_questions, updated_questions)
|
||||
end
|
||||
|
||||
def disable_all(presentation_file_id, position) do
|
||||
from(q in Quiz,
|
||||
where: q.presentation_file_id == ^presentation_file_id and q.position == ^position
|
||||
)
|
||||
|> Repo.update_all(set: [enabled: false])
|
||||
end
|
||||
|
||||
def set_enabled(id) do
|
||||
get_quiz!(id)
|
||||
|> Ecto.Changeset.change(enabled: true)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
def set_disabled(id) do
|
||||
get_quiz!(id)
|
||||
|> Ecto.Changeset.change(enabled: false)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
defp broadcast({:error, _reason} = error, _quiz), do: error
|
||||
|
||||
defp broadcast({:ok, quiz, event_uuid}, event) do
|
||||
Phoenix.PubSub.broadcast(
|
||||
Claper.PubSub,
|
||||
"event:#{event_uuid}",
|
||||
{event, quiz}
|
||||
)
|
||||
|
||||
{:ok, quiz}
|
||||
end
|
||||
end
|
||||
50
lib/claper/quizzes/quiz.ex
Normal file
50
lib/claper/quizzes/quiz.ex
Normal file
@@ -0,0 +1,50 @@
|
||||
defmodule Claper.Quizzes.Quiz do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
schema "quizzes" do
|
||||
field :title, :string
|
||||
field :position, :integer, default: 0
|
||||
field :enabled, :boolean, default: false
|
||||
field :show_results, :boolean, default: false
|
||||
field :lti_line_item_url, :string
|
||||
|
||||
belongs_to :presentation_file, Claper.Presentations.PresentationFile
|
||||
belongs_to :lti_resource, Lti13.Resources.Resource
|
||||
|
||||
has_many :quiz_questions, Claper.Quizzes.QuizQuestion,
|
||||
preload_order: [asc: :id],
|
||||
on_replace: :delete
|
||||
|
||||
has_many :quiz_responses, Claper.Quizzes.QuizResponse
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(quiz, attrs) do
|
||||
quiz
|
||||
|> cast(attrs, [
|
||||
:title,
|
||||
:position,
|
||||
:presentation_file_id,
|
||||
:enabled,
|
||||
:show_results,
|
||||
:lti_resource_id,
|
||||
:lti_line_item_url
|
||||
])
|
||||
|> validate_required([:title, :position, :presentation_file_id])
|
||||
|> cast_assoc(:quiz_questions,
|
||||
required: true,
|
||||
with: &Claper.Quizzes.QuizQuestion.changeset/2,
|
||||
sort_param: :quiz_questions_order,
|
||||
drop_param: :quiz_questions_delete
|
||||
)
|
||||
end
|
||||
|
||||
def update_line_item_changeset(quiz, attrs) do
|
||||
quiz
|
||||
|> cast(attrs, [:lti_line_item_url])
|
||||
|> validate_required([:lti_line_item_url])
|
||||
end
|
||||
end
|
||||
42
lib/claper/quizzes/quiz_question.ex
Normal file
42
lib/claper/quizzes/quiz_question.ex
Normal file
@@ -0,0 +1,42 @@
|
||||
defmodule Claper.Quizzes.QuizQuestion do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
schema "quiz_questions" do
|
||||
field :content, :string
|
||||
field :type, :string, default: "qcm"
|
||||
|
||||
belongs_to :quiz, Claper.Quizzes.Quiz
|
||||
|
||||
has_many :quiz_question_opts, Claper.Quizzes.QuizQuestionOpt,
|
||||
preload_order: [asc: :id],
|
||||
on_replace: :delete
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(quiz_question, attrs) do
|
||||
quiz_question
|
||||
|> cast(attrs, [:content, :type])
|
||||
|> validate_required([:content, :type])
|
||||
|> cast_assoc(:quiz_question_opts,
|
||||
required: true,
|
||||
with: &Claper.Quizzes.QuizQuestionOpt.changeset/2,
|
||||
sort_param: :quiz_question_opts_order,
|
||||
drop_param: :quiz_question_opts_delete
|
||||
)
|
||||
|> validate_at_least_one_correct_opt()
|
||||
end
|
||||
|
||||
defp validate_at_least_one_correct_opt(changeset) do
|
||||
quiz_question_opts = get_field(changeset, :quiz_question_opts) || []
|
||||
has_correct_opt = Enum.any?(quiz_question_opts, & &1.is_correct)
|
||||
|
||||
if has_correct_opt do
|
||||
changeset
|
||||
else
|
||||
add_error(changeset, :quiz_question_opts, "must have at least one correct answer")
|
||||
end
|
||||
end
|
||||
end
|
||||
22
lib/claper/quizzes/quiz_question_opt.ex
Normal file
22
lib/claper/quizzes/quiz_question_opt.ex
Normal file
@@ -0,0 +1,22 @@
|
||||
defmodule Claper.Quizzes.QuizQuestionOpt do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
schema "quiz_question_opts" do
|
||||
field :content, :string
|
||||
field :is_correct, :boolean, default: false
|
||||
field :response_count, :integer, default: 0
|
||||
field :percentage, :float, virtual: true
|
||||
|
||||
belongs_to :quiz_question, Claper.Quizzes.QuizQuestion
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(quiz_question_opt, attrs) do
|
||||
quiz_question_opt
|
||||
|> cast(attrs, [:content, :is_correct, :response_count])
|
||||
|> validate_required([:content, :is_correct])
|
||||
end
|
||||
end
|
||||
28
lib/claper/quizzes/quiz_response.ex
Normal file
28
lib/claper/quizzes/quiz_response.ex
Normal file
@@ -0,0 +1,28 @@
|
||||
defmodule Claper.Quizzes.QuizResponse do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
schema "quiz_responses" do
|
||||
field :attendee_identifier, :string
|
||||
|
||||
belongs_to :quiz, Claper.Quizzes.Quiz
|
||||
belongs_to :quiz_question, Claper.Quizzes.QuizQuestion
|
||||
belongs_to :quiz_question_opt, Claper.Quizzes.QuizQuestionOpt
|
||||
belongs_to :user, Claper.Accounts.User
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(quiz_response, attrs) do
|
||||
quiz_response
|
||||
|> cast(attrs, [
|
||||
:attendee_identifier,
|
||||
:quiz_id,
|
||||
:quiz_question_id,
|
||||
:quiz_question_opt_id,
|
||||
:user_id
|
||||
])
|
||||
|> validate_required([:quiz_id, :quiz_question_id, :quiz_question_opt_id])
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,10 @@ defmodule Claper.Repo do
|
||||
otp_app: :claper,
|
||||
adapter: Ecto.Adapters.Postgres
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
@default_page_size 12
|
||||
|
||||
def init(_type, config) do
|
||||
if url = System.get_env("DATABASE_URL") do
|
||||
{:ok, Keyword.put(config, :url, url)}
|
||||
@@ -10,4 +14,29 @@ defmodule Claper.Repo do
|
||||
{:ok, config}
|
||||
end
|
||||
end
|
||||
|
||||
def paginate(query, opts \\ []) do
|
||||
page = Keyword.get(opts, :page, 1)
|
||||
page_size = Keyword.get(opts, :page_size, @default_page_size)
|
||||
preload = Keyword.get(opts, :preload, [])
|
||||
|
||||
total_entries =
|
||||
query
|
||||
|> exclude(:order_by)
|
||||
|> exclude(:preload)
|
||||
|> exclude(:select)
|
||||
|> select(count("*"))
|
||||
|> Claper.Repo.one()
|
||||
|
||||
total_pages = ceil(total_entries / page_size)
|
||||
|
||||
results =
|
||||
query
|
||||
|> limit(^page_size)
|
||||
|> offset(^((page - 1) * page_size))
|
||||
|> Claper.Repo.all()
|
||||
|> Claper.Repo.preload(preload)
|
||||
|
||||
{results, total_entries, total_pages}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,10 +1,92 @@
|
||||
defmodule Claper.Stats do
|
||||
@moduledoc """
|
||||
The Stats context.
|
||||
All calculation for stats related to an event
|
||||
"""
|
||||
|
||||
import Ecto.Query, warn: false
|
||||
alias Claper.Repo
|
||||
alias Claper.Stats.Stat
|
||||
|
||||
def create_stat(event, attrs) do
|
||||
%Stat{}
|
||||
|> Map.put(:event, event)
|
||||
|> Stat.changeset(attrs)
|
||||
|> Repo.insert(on_conflict: :nothing)
|
||||
end
|
||||
|
||||
def get_unique_attendees_for_event(event_id) do
|
||||
from(s in Stat,
|
||||
where: s.event_id == ^event_id,
|
||||
select:
|
||||
count(
|
||||
fragment("DISTINCT COALESCE(?, CAST(? AS varchar))", s.attendee_identifier, s.user_id)
|
||||
)
|
||||
)
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
def get_distinct_poll_votes(poll_ids) do
|
||||
from(pv in Claper.Polls.PollVote,
|
||||
where: pv.poll_id in ^poll_ids,
|
||||
group_by: pv.poll_id,
|
||||
select: %{
|
||||
poll_id: pv.poll_id,
|
||||
count:
|
||||
count(
|
||||
fragment(
|
||||
"DISTINCT COALESCE(?, CAST(? AS varchar))",
|
||||
pv.attendee_identifier,
|
||||
pv.user_id
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Enum.map(fn %{count: count} -> count end)
|
||||
|> Enum.sum()
|
||||
end
|
||||
|
||||
def get_distinct_quiz_responses(quiz_ids) do
|
||||
from(pv in Claper.Quizzes.QuizResponse,
|
||||
where: pv.quiz_id in ^quiz_ids,
|
||||
group_by: pv.quiz_id,
|
||||
select: %{
|
||||
quiz_id: pv.quiz_id,
|
||||
count:
|
||||
count(
|
||||
fragment(
|
||||
"DISTINCT COALESCE(?, CAST(? AS varchar))",
|
||||
pv.attendee_identifier,
|
||||
pv.user_id
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Enum.map(fn %{count: count} -> count end)
|
||||
|> Enum.sum()
|
||||
end
|
||||
|
||||
def get_distinct_form_submits(form_ids) do
|
||||
from(pv in Claper.Forms.FormSubmit,
|
||||
where: pv.form_id in ^form_ids,
|
||||
group_by: pv.form_id,
|
||||
select: %{
|
||||
form_id: pv.form_id,
|
||||
count:
|
||||
count(
|
||||
fragment(
|
||||
"DISTINCT COALESCE(?, CAST(? AS varchar))",
|
||||
pv.attendee_identifier,
|
||||
pv.user_id
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|> Repo.all()
|
||||
|> Enum.map(fn %{count: count} -> count end)
|
||||
|> Enum.sum()
|
||||
end
|
||||
|
||||
def distinct_poster_count(event_id) do
|
||||
from(posts in Claper.Posts.Post,
|
||||
|
||||
35
lib/claper/stats/stat.ex
Normal file
35
lib/claper/stats/stat.ex
Normal file
@@ -0,0 +1,35 @@
|
||||
defmodule Claper.Stats.Stat do
|
||||
use Ecto.Schema
|
||||
import Ecto.Changeset
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: integer(),
|
||||
attendee_identifier: String.t() | nil,
|
||||
event_id: integer() | nil,
|
||||
user_id: integer() | nil,
|
||||
inserted_at: NaiveDateTime.t(),
|
||||
updated_at: NaiveDateTime.t()
|
||||
}
|
||||
|
||||
schema "stats" do
|
||||
field :attendee_identifier, :string
|
||||
|
||||
belongs_to :event, Claper.Events.Event
|
||||
belongs_to :user, Claper.Accounts.User
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@doc false
|
||||
def changeset(stat, attrs) do
|
||||
stat
|
||||
|> cast(attrs, [
|
||||
:attendee_identifier,
|
||||
:event_id,
|
||||
:user_id
|
||||
])
|
||||
|> cast_assoc(:event)
|
||||
|> unique_constraint([:event_id, :user_id])
|
||||
|> unique_constraint([:event_id, :attendee_identifier])
|
||||
end
|
||||
end
|
||||
52
lib/claper/workers/mailers.ex
Normal file
52
lib/claper/workers/mailers.ex
Normal file
@@ -0,0 +1,52 @@
|
||||
defmodule Claper.Workers.Mailers do
|
||||
use Oban.Worker, queue: :mailers
|
||||
|
||||
alias Claper.Mailer
|
||||
alias ClaperWeb.Notifiers.UserNotifier
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"type" => type, "user_id" => user_id, "url" => url}})
|
||||
when type in ["confirm", "reset", "update_email"] do
|
||||
user = Claper.Accounts.get_user!(user_id)
|
||||
|
||||
email =
|
||||
case type do
|
||||
"confirm" -> UserNotifier.confirm(user, url)
|
||||
"reset" -> UserNotifier.reset(user, url)
|
||||
"update_email" -> UserNotifier.update_email(user, url)
|
||||
end
|
||||
|
||||
Mailer.deliver(email)
|
||||
end
|
||||
|
||||
def perform(%Oban.Job{args: %{"type" => "magic", "email" => email, "url" => url}}) do
|
||||
email = UserNotifier.magic(email, url)
|
||||
Mailer.deliver(email)
|
||||
end
|
||||
|
||||
def perform(%Oban.Job{args: %{"type" => "welcome", "email" => email}}) do
|
||||
email = UserNotifier.welcome(email)
|
||||
Mailer.deliver(email)
|
||||
end
|
||||
|
||||
# Helper functions to create jobs
|
||||
def new_confirmation(user_id, url) do
|
||||
new(%{type: "confirm", user_id: user_id, url: url})
|
||||
end
|
||||
|
||||
def new_reset_password(user_id, url) do
|
||||
new(%{type: "reset", user_id: user_id, url: url})
|
||||
end
|
||||
|
||||
def new_update_email(user_id, url) do
|
||||
new(%{type: "update_email", user_id: user_id, url: url})
|
||||
end
|
||||
|
||||
def new_magic_link(email, url) do
|
||||
new(%{type: "magic", email: email, url: url})
|
||||
end
|
||||
|
||||
def new_welcome(email) do
|
||||
new(%{type: "welcome", email: email})
|
||||
end
|
||||
end
|
||||
123
lib/claper/workers/quiz_lti.ex
Normal file
123
lib/claper/workers/quiz_lti.ex
Normal file
@@ -0,0 +1,123 @@
|
||||
defmodule Claper.Workers.QuizLti do
|
||||
alias Claper.Quizzes.Quiz
|
||||
use Oban.Worker, queue: :default
|
||||
|
||||
alias Lti13.Tool.Services.{AGS, AccessToken}
|
||||
alias Lti13.Tool.Services.AGS.{Score, LineItem}
|
||||
|
||||
@impl Oban.Worker
|
||||
def perform(%Oban.Job{args: %{"action" => "create", "quiz_id" => quiz_id}}) do
|
||||
with quiz <- Claper.Quizzes.get_quiz!(quiz_id),
|
||||
presentation_file <-
|
||||
Claper.Presentations.get_presentation_file!(quiz.presentation_file_id,
|
||||
event: [lti_resource: [:registration]]
|
||||
),
|
||||
%Lti13.Resources.Resource{} = lti_resource <- presentation_file.event.lti_resource,
|
||||
{:ok, token} <- Lti13.Tool.Services.AccessToken.fetch_access_token(lti_resource) do
|
||||
case AGS.create_line_item(
|
||||
lti_resource.line_items_url,
|
||||
lti_resource.resource_id,
|
||||
100,
|
||||
quiz.title,
|
||||
token
|
||||
) do
|
||||
{:ok, line_item} ->
|
||||
quiz
|
||||
|> Quiz.update_line_item_changeset(%{lti_line_item_url: line_item.id})
|
||||
|> Claper.Repo.update()
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def perform(%Oban.Job{args: %{"action" => "update", "quiz_id" => quiz_id}}) do
|
||||
with quiz <- Claper.Quizzes.get_quiz!(quiz_id),
|
||||
presentation_file <-
|
||||
Claper.Presentations.get_presentation_file!(quiz.presentation_file_id,
|
||||
event: [lti_resource: [:registration]]
|
||||
),
|
||||
%Lti13.Resources.Resource{} = lti_resource <- presentation_file.event.lti_resource,
|
||||
{:ok, token} <- Lti13.Tool.Services.AccessToken.fetch_access_token(lti_resource) do
|
||||
AGS.update_line_item(
|
||||
%AGS.LineItem{
|
||||
id: quiz.lti_line_item_url,
|
||||
label: quiz.title,
|
||||
scoreMaximum: 100,
|
||||
resourceId: lti_resource.resource_id
|
||||
},
|
||||
%{label: quiz.title},
|
||||
token
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def perform(%Oban.Job{
|
||||
args: %{
|
||||
"action" => "post_score",
|
||||
"quiz_id" => quiz_id,
|
||||
"user_id" => user_id,
|
||||
"score" => score,
|
||||
"timestamp" => timestamp
|
||||
}
|
||||
}) do
|
||||
with quiz <- Claper.Quizzes.get_quiz!(quiz_id, [:lti_resource]),
|
||||
user <- Claper.Accounts.get_user!(user_id) |> Claper.Repo.preload(:lti_user) do
|
||||
case AccessToken.fetch_access_token(quiz.lti_resource) do
|
||||
{:ok, access_token} ->
|
||||
line_item = %LineItem{
|
||||
id: quiz.lti_line_item_url,
|
||||
scoreMaximum: 100.0,
|
||||
label: quiz.title,
|
||||
resourceId: quiz.lti_resource.resource_id
|
||||
}
|
||||
|
||||
post_score(line_item, user.lti_user, score, timestamp, access_token)
|
||||
|
||||
{:error, msg} ->
|
||||
{:error, msg}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp post_score(line_item, %Lti13.Users.User{sub: user_id}, score, timestamp, access_token) do
|
||||
AGS.post_score(
|
||||
%Score{
|
||||
scoreGiven: score,
|
||||
scoreMaximum: 100.0,
|
||||
activityProgress: "Completed",
|
||||
gradingProgress: "FullyGraded",
|
||||
userId: user_id,
|
||||
comment: "",
|
||||
timestamp: timestamp
|
||||
},
|
||||
line_item,
|
||||
access_token
|
||||
)
|
||||
end
|
||||
|
||||
def edit(quiz_id) do
|
||||
new(%{
|
||||
"action" => "update",
|
||||
"quiz_id" => quiz_id
|
||||
})
|
||||
end
|
||||
|
||||
def create(quiz_id) do
|
||||
new(%{
|
||||
"action" => "create",
|
||||
"quiz_id" => quiz_id
|
||||
})
|
||||
end
|
||||
|
||||
def post_score(quiz_id, user_id, score, timestamp) do
|
||||
new(%{
|
||||
"action" => "post_score",
|
||||
"quiz_id" => quiz_id,
|
||||
"user_id" => user_id,
|
||||
"score" => score,
|
||||
"timestamp" => timestamp
|
||||
})
|
||||
end
|
||||
end
|
||||
@@ -1,70 +0,0 @@
|
||||
defmodule ClaperWeb.Lti.GradeController do
|
||||
use ClaperWeb, :controller
|
||||
|
||||
alias Lti13.Tool.Services.AGS
|
||||
alias Lti13.Tool.Services.AGS.Score
|
||||
|
||||
def create(conn, _params) do
|
||||
resource_id = conn |> get_session(:resource_id) |> String.to_integer()
|
||||
user_id = conn |> get_session(:user_id)
|
||||
timestamp = get_timestamp()
|
||||
|
||||
case fetch_access_token() do
|
||||
{:ok, access_token} ->
|
||||
handle_line_item(conn, resource_id, user_id, timestamp, access_token)
|
||||
|
||||
{:error, msg} ->
|
||||
conn |> send_resp(500, msg)
|
||||
end
|
||||
end
|
||||
|
||||
defp get_timestamp do
|
||||
{:ok, dt} = DateTime.now("Etc/UTC")
|
||||
DateTime.to_iso8601(dt)
|
||||
end
|
||||
|
||||
defp fetch_access_token do
|
||||
Lti13.Tool.Services.AccessToken.fetch_access_token(
|
||||
%{
|
||||
auth_token_url: "http://localhost.charlesproxy.com/mod/lti/token.php",
|
||||
client_id: "NQQ8egz8Kj1s1qw",
|
||||
auth_server: "http://localhost.charlesproxy.com"
|
||||
},
|
||||
[
|
||||
"https://purl.imsglobal.org/spec/lti-ags/scope/lineitem",
|
||||
"https://purl.imsglobal.org/spec/lti-ags/scope/score"
|
||||
],
|
||||
"http://localhost:4000"
|
||||
)
|
||||
end
|
||||
|
||||
defp handle_line_item(conn, resource_id, user_id, timestamp, access_token) do
|
||||
case AGS.fetch_or_create_line_item(
|
||||
"http://localhost.charlesproxy.com/mod/lti/services.php/2/lineitems?type_id=2",
|
||||
resource_id,
|
||||
fn -> 100.0 end,
|
||||
"test",
|
||||
access_token
|
||||
) do
|
||||
{:ok, line_item} ->
|
||||
post_score(line_item, user_id, timestamp, access_token)
|
||||
conn |> send_resp(200, "")
|
||||
end
|
||||
end
|
||||
|
||||
defp post_score(line_item, user_id, timestamp, access_token) do
|
||||
AGS.post_score(
|
||||
%Score{
|
||||
scoreGiven: 90.0,
|
||||
scoreMaximum: 100.0,
|
||||
activityProgress: "Completed",
|
||||
gradingProgress: "FullyGraded",
|
||||
userId: user_id,
|
||||
comment: "",
|
||||
timestamp: timestamp
|
||||
},
|
||||
line_item,
|
||||
access_token
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -2,7 +2,7 @@ defmodule ClaperWeb.Lti.RegistrationController do
|
||||
use ClaperWeb, :controller
|
||||
|
||||
def new(conn, %{"openid_configuration" => conf, "registration_token" => token}) do
|
||||
render(conn, "new.html", conf: conf, token: token)
|
||||
render(conn, "new.html", conf: conf, token: token, current_user: conn.assigns.current_user)
|
||||
end
|
||||
|
||||
def new(conn, _params) do
|
||||
@@ -45,6 +45,7 @@ defmodule ClaperWeb.Lti.RegistrationController do
|
||||
Lti13.Registrations.create_registration(%{
|
||||
issuer: issuer,
|
||||
client_id: client_id,
|
||||
user_id: conn.assigns.current_user.id,
|
||||
key_set_url: jwks_uri,
|
||||
auth_token_url: token_endpoint,
|
||||
auth_login_url: auth_endpoint,
|
||||
|
||||
@@ -1,28 +1,261 @@
|
||||
defmodule ClaperWeb.StatController do
|
||||
@moduledoc """
|
||||
Controller responsible for exporting various statistics and data in CSV format.
|
||||
Handles form submissions, messages, and poll results exports.
|
||||
"""
|
||||
use ClaperWeb, :controller
|
||||
|
||||
alias Claper.Forms
|
||||
alias Claper.{Forms, Events, Polls, Presentations, Quizzes}
|
||||
|
||||
def export(conn, %{"form_id" => form_id}) do
|
||||
@doc """
|
||||
Exports form submissions as a CSV file.
|
||||
"""
|
||||
def export_form(conn, %{"form_id" => form_id}) do
|
||||
form = Forms.get_form!(form_id, [:form_submits])
|
||||
headers = form.fields |> Enum.map(& &1.name)
|
||||
csv_data = headers |> csv_content(form.form_submits |> Enum.map(& &1.response))
|
||||
|
||||
data =
|
||||
form.form_submits
|
||||
|> Enum.map(fn submit ->
|
||||
form.fields
|
||||
|> Enum.map(fn field ->
|
||||
Map.get(submit.response, field.name, "")
|
||||
end)
|
||||
end)
|
||||
|
||||
export_as_csv(conn, headers, data, "form-#{sanitize(form.title)}")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Exports all messages from an event as a CSV file.
|
||||
Requires user to be either an event leader or the event owner.
|
||||
"""
|
||||
def export_all_messages(%{assigns: %{current_user: current_user}} = conn, %{
|
||||
"event_id" => event_id
|
||||
}) do
|
||||
event = Events.get_event!(event_id, posts: [:user])
|
||||
|
||||
case authorize_event_access(current_user, event) do
|
||||
:ok ->
|
||||
headers = [
|
||||
"Attendee identifier",
|
||||
"User email",
|
||||
"Name",
|
||||
"Message",
|
||||
"Pinned",
|
||||
"Slide #",
|
||||
"Sent at (UTC)"
|
||||
]
|
||||
|
||||
content = format_messages_for_export(event.posts)
|
||||
|
||||
export_as_csv(conn, headers, content, "messages-#{sanitize(event.name)}")
|
||||
|
||||
:unauthorized ->
|
||||
send_resp(conn, 403, "Forbidden")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Exports poll results as a CSV file.
|
||||
Requires user to be either an event leader or the event owner.
|
||||
"""
|
||||
def export_poll(%{assigns: %{current_user: current_user}} = conn, %{"poll_id" => poll_id}) do
|
||||
with poll <- Polls.get_poll!(poll_id),
|
||||
presentation_file <-
|
||||
Presentations.get_presentation_file!(poll.presentation_file_id, [:event]),
|
||||
event <- presentation_file.event,
|
||||
:ok <- authorize_event_access(current_user, event) do
|
||||
headers = ["Name", "Multiple choice", "Slide #"] ++ Enum.map(poll.poll_opts, & &1.content)
|
||||
|
||||
content =
|
||||
[poll.title, poll.multiple, poll.position + 1] ++
|
||||
Enum.map(poll.poll_opts, & &1.vote_count)
|
||||
|
||||
export_as_csv(conn, headers, [content], "poll-#{sanitize(poll.title)}")
|
||||
else
|
||||
:unauthorized -> send_resp(conn, 403, "Forbidden")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Exports quiz results as a CSV file.
|
||||
Requires user to be either an event leader or the event owner.
|
||||
"""
|
||||
def export_quiz(%{assigns: %{current_user: current_user}} = conn, %{"quiz_id" => quiz_id}) do
|
||||
with quiz <-
|
||||
Quizzes.get_quiz!(quiz_id, [
|
||||
:quiz_questions,
|
||||
quiz_questions: :quiz_question_opts,
|
||||
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)
|
||||
|
||||
export_as_csv(conn, headers, data, "quiz-#{sanitize(quiz.title)}")
|
||||
else
|
||||
:unauthorized -> send_resp(conn, 403, "Forbidden")
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Exports quiz as QTI format.
|
||||
Requires user to be either an event leader or the event owner.
|
||||
"""
|
||||
def export_quiz_qti(%{assigns: %{current_user: current_user}} = conn, %{"quiz_id" => quiz_id}) do
|
||||
with quiz <-
|
||||
Quizzes.get_quiz!(quiz_id, [
|
||||
:quiz_questions,
|
||||
quiz_questions: :quiz_question_opts,
|
||||
presentation_file: :event
|
||||
]),
|
||||
event <- quiz.presentation_file.event,
|
||||
:ok <- authorize_event_access(current_user, event) do
|
||||
qti_content = generate_qti_content(quiz)
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("application/xml")
|
||||
|> put_resp_header(
|
||||
"content-disposition",
|
||||
~s(attachment; filename="#{sanitize(quiz.title)}_qti.xml")
|
||||
)
|
||||
|> send_resp(200, qti_content)
|
||||
else
|
||||
:unauthorized -> send_resp(conn, 403, "Forbidden")
|
||||
end
|
||||
end
|
||||
|
||||
# Private functions
|
||||
|
||||
defp authorize_event_access(user, event) do
|
||||
if Events.leaded_by?(user.email, event) || event.user_id == user.id do
|
||||
:ok
|
||||
else
|
||||
:unauthorized
|
||||
end
|
||||
end
|
||||
|
||||
defp format_messages_for_export(posts) do
|
||||
posts
|
||||
|> Enum.map(fn post ->
|
||||
[
|
||||
format_attendee_identifier(post.attendee_identifier),
|
||||
format_user_email(post.user),
|
||||
post.name || "N/A",
|
||||
post.body,
|
||||
post.pinned,
|
||||
post.position + 1,
|
||||
post.inserted_at
|
||||
]
|
||||
end)
|
||||
end
|
||||
|
||||
defp format_attendee_identifier(nil), do: "N/A"
|
||||
defp format_attendee_identifier(identifier), do: Base.encode16(identifier)
|
||||
|
||||
defp format_user_email(nil), do: "N/A"
|
||||
defp format_user_email(user), do: user.email
|
||||
|
||||
defp export_as_csv(conn, headers, data, filename) do
|
||||
csv_data =
|
||||
([headers] ++ data)
|
||||
|> CSV.encode()
|
||||
|> Enum.to_list()
|
||||
|> to_string()
|
||||
|
||||
conn
|
||||
|> put_resp_content_type("text/csv")
|
||||
|> put_resp_header("content-disposition", "attachment; filename=\"#{form.title}.csv\"")
|
||||
|> put_resp_header("content-disposition", "attachment; filename=\"#{filename}.csv\"")
|
||||
|> put_root_layout(false)
|
||||
|> send_resp(200, csv_data)
|
||||
end
|
||||
|
||||
defp csv_content(headers, records) do
|
||||
data =
|
||||
records
|
||||
|> Enum.map(&(&1 |> Map.values()))
|
||||
|
||||
([headers] ++ data)
|
||||
|> CSV.encode()
|
||||
|> Enum.to_list()
|
||||
|> to_string()
|
||||
defp generate_qti_content(quiz) do
|
||||
"""
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<questestinterop xmlns="http://www.imsglobal.org/xsd/ims_qtiasiv1p2">
|
||||
<assessment title="#{quiz.title}">
|
||||
<section>
|
||||
#{Enum.map_join(quiz.quiz_questions, "\n", &generate_qti_item/1)}
|
||||
</section>
|
||||
</assessment>
|
||||
</questestinterop>
|
||||
"""
|
||||
end
|
||||
|
||||
defp generate_qti_item(question) do
|
||||
"""
|
||||
<item>
|
||||
<presentation>
|
||||
<material>
|
||||
<mattext>#{question.content}</mattext>
|
||||
</material>
|
||||
<response_lid ident="RESPONSE" rcardinality="Multiple">
|
||||
<render_choice>
|
||||
#{Enum.map_join(question.quiz_question_opts, "\n", &generate_qti_option/1)}
|
||||
</render_choice>
|
||||
</response_lid>
|
||||
</presentation>
|
||||
<resprocessing>
|
||||
<outcomes>
|
||||
<decvar vartype="Integer" defaultval="0"/>
|
||||
</outcomes>
|
||||
#{Enum.map_join(question.quiz_question_opts, "\n", &generate_qti_condition/1)}
|
||||
</resprocessing>
|
||||
</item>
|
||||
"""
|
||||
end
|
||||
|
||||
defp generate_qti_option(option) do
|
||||
"""
|
||||
<response_label ident="#{option.id}">
|
||||
<material>
|
||||
<mattext>#{option.content}</mattext>
|
||||
</material>
|
||||
</response_label>
|
||||
"""
|
||||
end
|
||||
|
||||
defp generate_qti_condition(option) do
|
||||
if option.is_correct do
|
||||
"""
|
||||
<respcondition>
|
||||
<conditionvar>
|
||||
<varequal respident="RESPONSE">#{option.id}</varequal>
|
||||
</conditionvar>
|
||||
<setvar action="Set">1</setvar>
|
||||
</respcondition>
|
||||
"""
|
||||
else
|
||||
""
|
||||
end
|
||||
end
|
||||
|
||||
defp sanitize(string),
|
||||
do: string |> String.replace(~r/[^\w\s-]/, "") |> String.replace(~r/\s+/, "-")
|
||||
end
|
||||
|
||||
@@ -21,7 +21,7 @@ defmodule ClaperWeb.UserRegistrationController do
|
||||
end
|
||||
|
||||
def create(conn, %{"user" => user_params}) do
|
||||
case Accounts.register_user(user_params) do
|
||||
case Accounts.register_user(user_params(user_params)) do
|
||||
{:ok, user} ->
|
||||
if Application.get_env(:claper, :email_confirmation) do
|
||||
{:ok, _} =
|
||||
@@ -42,4 +42,13 @@ defmodule ClaperWeb.UserRegistrationController do
|
||||
render(conn, "new.html", changeset: changeset)
|
||||
end
|
||||
end
|
||||
|
||||
defp user_params(params) do
|
||||
if Application.get_env(:claper, :email_confirmation) do
|
||||
params
|
||||
else
|
||||
params
|
||||
|> Map.put("confirmed_at", NaiveDateTime.utc_now())
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,9 +13,30 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
|
||||
<div class="block bg-white rounded-2xl shadow-base">
|
||||
<div class="px-4 py-4 sm:px-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-lg font-medium text-primary-600 truncate">
|
||||
<%= @event.name %>
|
||||
</p>
|
||||
<div class="flex items-center">
|
||||
<p class="text-lg font-medium text-primary-600 truncate">
|
||||
<%= @event.name %>
|
||||
</p>
|
||||
<p
|
||||
:if={@event.lti_resource}
|
||||
class="text-xs text-white rounded-md px-2 py-0.5 bg-gray-500 mx-2 flex items-center space-x-1"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-3 w-3"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9.664 1.319a.75.75 0 0 1 .672 0 41.059 41.059 0 0 1 8.198 5.424.75.75 0 0 1-.254 1.285 31.372 31.372 0 0 0-7.86 3.83.75.75 0 0 1-.84 0 31.508 31.508 0 0 0-2.08-1.287V9.394c0-.244.116-.463.302-.592a35.504 35.504 0 0 1 3.305-2.033.75.75 0 0 0-.714-1.319 37 37 0 0 0-3.446 2.12A2.216 2.216 0 0 0 6 9.393v.38a31.293 31.293 0 0 0-4.28-1.746.75.75 0 0 1-.254-1.285 41.059 41.059 0 0 1 8.198-5.424ZM6 11.459a29.848 29.848 0 0 0-2.455-1.158 41.029 41.029 0 0 0-.39 3.114.75.75 0 0 0 .419.74c.528.256 1.046.53 1.554.82-.21.324-.455.63-.739.914a.75.75 0 1 0 1.06 1.06c.37-.369.69-.77.96-1.193a26.61 26.61 0 0 1 3.095 2.348.75.75 0 0 0 .992 0 26.547 26.547 0 0 1 5.93-3.95.75.75 0 0 0 .42-.739 41.053 41.053 0 0 0-.39-3.114 29.925 29.925 0 0 0-5.199 2.801 2.25 2.25 0 0 1-2.514 0c-.41-.275-.826-.541-1.25-.797a6.985 6.985 0 0 1-1.084 3.45 26.503 26.503 0 0 0-1.281-.78A5.487 5.487 0 0 0 6 12v-.54Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span>LTI</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-2 flex-shrink-0 flex">
|
||||
<%= if Event.started?(@event) && !Event.finished?(@event) do %>
|
||||
<div class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-500 text-white items-center gap-x-1">
|
||||
|
||||
@@ -1,11 +1,41 @@
|
||||
<div
|
||||
id="wrapper"
|
||||
phx-hook="TourGuide"
|
||||
data-btn-trigger="#product-tour-btn"
|
||||
data-next-label={gettext("Next")}
|
||||
data-prev-label={gettext("Back")}
|
||||
data-finish-label={gettext("Finish")}
|
||||
data-group="create-event"
|
||||
>
|
||||
<div id="product-tour-btn" class="hidden absolute bottom-5 right-5 z-30">
|
||||
<button class="close absolute -top-1.5 -right-1.5 bg-red-500 text-white rounded-full">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="open text-sm text-primary-500 bg-white shadow-md border border-primary-500 rounded-md pl-2 pr-4 py-2 flex items-center justify-center space-x-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-5 w-5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0ZM8.94 6.94a.75.75 0 1 1-1.061-1.061 3 3 0 1 1 2.871 5.026v.345a.75.75 0 0 1-1.5 0v-.5c0-.72.57-1.172 1.081-1.287A1.5 1.5 0 1 0 8.94 6.94ZM10 15a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span><%= gettext("How it works ?") %></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="border-b border-gray-200 py-4 flex flex-col sm:flex-row sm:items-center justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-2xl font-medium leading-6 text-gray-900 sm:truncate">
|
||||
|
||||
@@ -25,11 +25,21 @@ defmodule ClaperWeb.EventLive.Index do
|
||||
Phoenix.PubSub.subscribe(Claper.PubSub, "events:#{socket.assigns.current_user.id}")
|
||||
end
|
||||
|
||||
expired_events_count = Events.count_expired_events(socket.assigns.current_user.id)
|
||||
invited_events_count = Events.count_managed_events_by(socket.assigns.current_user.email)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> stream(:events, list_events(socket))
|
||||
|> assign(:managed_events, list_managed_events(socket))
|
||||
|> assign(:active_tab, "not_expired")
|
||||
|> assign(:quick_event_changeset, changeset)
|
||||
|> assign(:has_expired_events, expired_events_count > 0)
|
||||
|> assign(:has_invited_events, invited_events_count > 0)
|
||||
|> assign(:page, 1)
|
||||
|> assign(:total_pages, 1)
|
||||
|> assign(:total_entries, 0)
|
||||
|> assign(:events, [])
|
||||
|> assign(:temporary_assigns, [events: []])
|
||||
|> load_events()
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
@@ -43,7 +53,7 @@ defmodule ClaperWeb.EventLive.Index do
|
||||
def handle_info({:presentation_file_process_done, presentation}, socket) do
|
||||
event = Claper.Events.get_event!(presentation.event.uuid, [:presentation_file])
|
||||
|
||||
{:noreply, socket |> stream_insert(:events, event) |> put_flash(:info, nil)}
|
||||
{:noreply, socket |> assign(:events, [event | socket.assigns.events]) |> put_flash(:info, nil)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
@@ -142,6 +152,27 @@ defmodule ClaperWeb.EventLive.Index do
|
||||
{:noreply, assign(socket, :live_action, :quick_create)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("change-tab", %{"tab" => tab}, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:active_tab, tab)
|
||||
|> assign(:page, 1)
|
||||
|> assign(:events, [])
|
||||
|> load_events()
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("load-more", _, socket) do
|
||||
if socket.assigns.page < socket.assigns.total_pages do
|
||||
{:noreply, socket |> assign(:page, socket.assigns.page + 1) |> load_events()}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_action(socket, :edit, %{"id" => id}) do
|
||||
event =
|
||||
Events.get_user_event!(socket.assigns.current_user.id, id, [:presentation_file, :leaders])
|
||||
@@ -181,11 +212,33 @@ defmodule ClaperWeb.EventLive.Index do
|
||||
|> assign(:event, nil)
|
||||
end
|
||||
|
||||
defp list_events(socket) do
|
||||
Events.list_events(socket.assigns.current_user.id, [:presentation_file])
|
||||
end
|
||||
defp load_events(socket) do
|
||||
params = %{"page" => socket.assigns.page, "page_size" => 5}
|
||||
|
||||
defp list_managed_events(socket) do
|
||||
Events.list_managed_events_by(socket.assigns.current_user.email, [:presentation_file])
|
||||
{events, total_entries, total_pages} =
|
||||
case socket.assigns.active_tab do
|
||||
"not_expired" ->
|
||||
Events.paginate_not_expired_events(socket.assigns.current_user.id, params, [
|
||||
:presentation_file,
|
||||
:lti_resource
|
||||
])
|
||||
|
||||
"expired" ->
|
||||
Events.paginate_expired_events(socket.assigns.current_user.id, params, [
|
||||
:presentation_file,
|
||||
:lti_resource
|
||||
])
|
||||
|
||||
"invited" ->
|
||||
Events.paginate_managed_events_by(socket.assigns.current_user.email, params, [
|
||||
:presentation_file,
|
||||
:lti_resource
|
||||
])
|
||||
end
|
||||
|
||||
socket
|
||||
|> assign(:total_entries, total_entries)
|
||||
|> assign(:total_pages, total_pages)
|
||||
|> assign(:events, if(socket.assigns.page == 1, do: events, else: socket.assigns.events ++ events))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,4 +1,33 @@
|
||||
<div class="mx-3 md:max-w-3xl lg:max-w-5xl md:mx-auto">
|
||||
<div id="product-tour-btn" class="hidden absolute bottom-5 right-5 z-30">
|
||||
<button class="close absolute -top-1.5 -right-1.5 bg-red-500 text-white rounded-full">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="open text-sm text-primary-500 bg-white shadow-md border border-primary-500 rounded-md pl-2 pr-4 py-2 flex items-center justify-center space-x-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-5 w-5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0ZM8.94 6.94a.75.75 0 1 1-1.061-1.061 3 3 0 1 1 2.871 5.026v.345a.75.75 0 0 1-1.5 0v-.5c0-.72.57-1.172 1.081-1.287A1.5 1.5 0 1 0 8.94 6.94ZM10 15a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span><%= gettext("How it works ?") %></span>
|
||||
</button>
|
||||
</div>
|
||||
<%= if @live_action in [:new, :edit] do %>
|
||||
<.live_component
|
||||
module={ClaperWeb.EventLive.EventFormComponent}
|
||||
@@ -71,6 +100,7 @@
|
||||
class="border-b border-gray-200 py-4 flex items-start flex-col space-y-5 sm:space-y-0 sm:flex-row sm:items-center justify-between relative"
|
||||
id="events-header"
|
||||
phx-hook="TourGuide"
|
||||
data-btn-trigger="#product-tour-btn"
|
||||
data-group="welcome"
|
||||
data-next-label={gettext("Next")}
|
||||
data-prev-label={gettext("Back")}
|
||||
@@ -139,48 +169,61 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="flex space-x-4 mt-4 border-b border-gray-200 pb-4" aria-label="Tabs">
|
||||
<button
|
||||
phx-click="change-tab"
|
||||
phx-value-tab="not_expired"
|
||||
class={"#{if @active_tab == "not_expired", do: "bg-primary-100 text-primary-700", else: "text-gray-500 hover:text-gray-700"} px-3 py-2 font-medium rounded-md"}
|
||||
>
|
||||
<%= gettext("Active") %>
|
||||
</button>
|
||||
<button
|
||||
phx-click="change-tab"
|
||||
phx-value-tab="expired"
|
||||
disabled={not @has_expired_events}
|
||||
class={"#{if @active_tab == "expired", do: "bg-primary-100 text-primary-700", else: "text-gray-500 hover:text-gray-700"} px-3 py-2 font-medium rounded-md #{if not @has_expired_events, do: "opacity-50"}"}
|
||||
>
|
||||
<%= gettext("Finished") %>
|
||||
</button>
|
||||
<button
|
||||
phx-click="change-tab"
|
||||
phx-value-tab="invited"
|
||||
disabled={not @has_invited_events}
|
||||
class={"#{if @active_tab == "invited", do: "bg-primary-100 text-primary-700", else: "text-gray-500 hover:text-gray-700"} px-3 py-2 font-medium rounded-md #{if not @has_invited_events, do: "opacity-50"}"}
|
||||
>
|
||||
<%= gettext("Shared with you") %>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="mt-2 relative">
|
||||
<ul role="event-list" phx-update="stream" id="events">
|
||||
<ul role="event-list" id="events">
|
||||
<% current_time = NaiveDateTime.utc_now() %>
|
||||
<.live_component
|
||||
:for={{id, event} <- @streams.events}
|
||||
module={ClaperWeb.EventLive.EventCardComponent}
|
||||
id={id}
|
||||
event={event}
|
||||
current_time={current_time}
|
||||
/>
|
||||
<%= for event <- @events do %>
|
||||
<.live_component
|
||||
module={ClaperWeb.EventLive.EventCardComponent}
|
||||
id={"event-#{event.id}"}
|
||||
event={event}
|
||||
current_time={current_time}
|
||||
is_leader={@active_tab == "invited"}
|
||||
/>
|
||||
<% end %>
|
||||
</ul>
|
||||
<%= if Enum.count(@streams.events) == 0 do %>
|
||||
<%= if @page < @total_pages do %>
|
||||
<div class="flex justify-center mt-8">
|
||||
<button
|
||||
phx-click="load-more"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-primary-700 bg-primary-100 hover:bg-primary-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
<%= gettext("Load more") %>
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if Enum.count(@events) == 0 do %>
|
||||
<div class="w-full text-2xl text-black opacity-25 text-center">
|
||||
<img src="/images/icons/arrow.svg" class="h-20 float-right mr-16 -mt-5" />
|
||||
<p class="pt-12 clear-both"><%= gettext("Create your first event") %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if length(@managed_events) > 0 do %>
|
||||
<div class="border-b border-gray-200 py-4 flex items-center justify-between mt-12">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 class="text-2xl font-medium leading-6 text-gray-900 sm:truncate">
|
||||
<%= gettext("Invited events") %>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 relative">
|
||||
<ul role="managed-event-list" id="event-cards" phx-update="replace">
|
||||
<% current_time = NaiveDateTime.utc_now() %>
|
||||
<%= for event <- @managed_events do %>
|
||||
<.live_component
|
||||
module={ClaperWeb.EventLive.EventCardComponent}
|
||||
id={"managed-event-#{event.uuid}"}
|
||||
is_leader={true}
|
||||
event={event}
|
||||
current_time={current_time}
|
||||
/>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,8 @@ defmodule ClaperWeb.EventLive.Manage do
|
||||
alias Claper.Polls
|
||||
alias Claper.Forms
|
||||
alias Claper.Embeds
|
||||
# Add this line
|
||||
alias Claper.Quizzes
|
||||
|
||||
@impl true
|
||||
def mount(%{"code" => code}, session, socket) do
|
||||
@@ -15,6 +17,7 @@ defmodule ClaperWeb.EventLive.Manage do
|
||||
event =
|
||||
Claper.Events.get_event_with_code(code, [
|
||||
:user,
|
||||
:lti_resource,
|
||||
presentation_file: [:polls, :presentation_state]
|
||||
])
|
||||
|
||||
@@ -27,13 +30,6 @@ defmodule ClaperWeb.EventLive.Manage do
|
||||
if connected?(socket) do
|
||||
Claper.Events.Event.subscribe(event.uuid)
|
||||
Claper.Presentations.subscribe(event.presentation_file.id)
|
||||
|
||||
Presence.track(
|
||||
self(),
|
||||
"event:#{event.uuid}",
|
||||
socket.assigns.current_user.id,
|
||||
%{}
|
||||
)
|
||||
end
|
||||
|
||||
posts = list_all_posts(socket, event.uuid)
|
||||
@@ -234,6 +230,13 @@ defmodule ClaperWeb.EventLive.Manage do
|
||||
|> interactions_at_position(form.position)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:quiz_updated, quiz}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> interactions_at_position(quiz.position)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:poll_deleted, poll}, socket) do
|
||||
{:noreply,
|
||||
@@ -255,6 +258,13 @@ defmodule ClaperWeb.EventLive.Manage do
|
||||
|> interactions_at_position(form.position)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:quiz_deleted, quiz}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> interactions_at_position(quiz.position)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(
|
||||
{:current_interaction, interaction},
|
||||
@@ -405,6 +415,39 @@ defmodule ClaperWeb.EventLive.Manage do
|
||||
|> interactions_at_position(socket.assigns.state.position)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("quiz-set-active", %{"id" => id}, socket) do
|
||||
with quiz <- Quizzes.get_quiz!(id, [:quiz_questions, quiz_questions: :quiz_question_opts]),
|
||||
:ok <- Claper.Interactions.enable_interaction(quiz) do
|
||||
Phoenix.PubSub.broadcast(
|
||||
Claper.PubSub,
|
||||
"event:#{socket.assigns.event.uuid}",
|
||||
{:current_interaction, quiz}
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:current_interaction, quiz)
|
||||
|> interactions_at_position(socket.assigns.state.position)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("quiz-set-inactive", %{"id" => id}, socket) do
|
||||
with quiz <- Quizzes.get_quiz!(id),
|
||||
{:ok, _} <- Claper.Interactions.disable_interaction(quiz) do
|
||||
Phoenix.PubSub.broadcast(
|
||||
Claper.PubSub,
|
||||
"event:#{socket.assigns.event.uuid}",
|
||||
{:current_interaction, nil}
|
||||
)
|
||||
end
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:current_interaction, nil)
|
||||
|> interactions_at_position(socket.assigns.state.position)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event(
|
||||
"ban",
|
||||
@@ -552,6 +595,57 @@ defmodule ClaperWeb.EventLive.Manage do
|
||||
{:noreply, socket |> assign(:state, new_state)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event(
|
||||
"checked",
|
||||
%{"key" => "quiz_show_results", "value" => value},
|
||||
%{assigns: %{current_interaction: interaction}} = socket
|
||||
) do
|
||||
{:ok, new_interaction} =
|
||||
Claper.Quizzes.update_quiz(
|
||||
socket.assigns.event.uuid,
|
||||
interaction,
|
||||
%{
|
||||
:show_results => value
|
||||
}
|
||||
)
|
||||
|
||||
{:noreply, socket |> assign(:current_interaction, new_interaction)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("checked", %{"key" => "review_quiz_questions"}, socket) do
|
||||
Phoenix.PubSub.broadcast(
|
||||
Claper.PubSub,
|
||||
"event:#{socket.assigns.event.uuid}",
|
||||
{:review_quiz_questions}
|
||||
)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("checked", %{"key" => "next_quiz_question"}, socket) do
|
||||
Phoenix.PubSub.broadcast(
|
||||
Claper.PubSub,
|
||||
"event:#{socket.assigns.event.uuid}",
|
||||
{:next_quiz_question}
|
||||
)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("checked", %{"key" => "prev_quiz_question"}, socket) do
|
||||
Phoenix.PubSub.broadcast(
|
||||
Claper.PubSub,
|
||||
"event:#{socket.assigns.event.uuid}",
|
||||
{:prev_quiz_question}
|
||||
)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
post = Claper.Posts.get_post!(id, [:event])
|
||||
@@ -647,6 +741,14 @@ defmodule ClaperWeb.EventLive.Manage do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete-quiz", %{"id" => id}, socket) do
|
||||
quiz = Quizzes.get_quiz!(id)
|
||||
{:ok, _} = Quizzes.delete_quiz(socket.assigns.event.uuid, quiz)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle-preview", _params, %{assigns: %{preview: preview}} = socket) do
|
||||
{:noreply, socket |> assign(:preview, !preview)}
|
||||
@@ -738,6 +840,36 @@ defmodule ClaperWeb.EventLive.Manage do
|
||||
|> assign(:embed, embed)
|
||||
end
|
||||
|
||||
defp apply_action(socket, :add_quiz, _params) do
|
||||
socket
|
||||
|> assign(:create, "quiz")
|
||||
|> assign(:quiz, %Quizzes.Quiz{
|
||||
presentation_file_id: socket.assigns.event.presentation_file.id,
|
||||
quiz_questions: [
|
||||
%Quizzes.QuizQuestion{
|
||||
id: 0,
|
||||
quiz_question_opts: [
|
||||
%Quizzes.QuizQuestionOpt{
|
||||
id: 0
|
||||
},
|
||||
%Quizzes.QuizQuestionOpt{
|
||||
id: 1
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
end
|
||||
|
||||
defp apply_action(socket, :edit_quiz, %{"id" => id}) do
|
||||
quiz = Quizzes.get_quiz!(id, [:quiz_questions, quiz_questions: :quiz_question_opts])
|
||||
|
||||
socket
|
||||
|> assign(:create, "quiz")
|
||||
|> assign(:create_action, :edit)
|
||||
|> assign(:quiz, quiz)
|
||||
end
|
||||
|
||||
defp pin(post, socket) do
|
||||
{:ok, _updated_post} = Claper.Posts.toggle_pin_post(post)
|
||||
|
||||
|
||||
@@ -6,6 +6,35 @@
|
||||
data-max-page={@event.presentation_file.length}
|
||||
data-current-page={@state.position}
|
||||
>
|
||||
<div id="product-tour-btn" class="hidden absolute bottom-5 right-5 z-30">
|
||||
<button class="close absolute -top-1.5 -right-1.5 bg-red-500 text-white rounded-full">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button class="open text-sm text-primary-500 bg-white shadow-md border border-primary-500 rounded-md pl-2 pr-4 py-2 flex items-center justify-center space-x-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-5 w-5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0ZM8.94 6.94a.75.75 0 1 1-1.061-1.061 3 3 0 1 1 2.871 5.026v.345a.75.75 0 0 1-1.5 0v-.5c0-.72.57-1.172 1.081-1.287A1.5 1.5 0 1 0 8.94 6.94ZM10 15a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span><%= gettext("How it works ?") %></span>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
:if={@preview}
|
||||
id="preview"
|
||||
@@ -100,6 +129,7 @@
|
||||
create={@create}
|
||||
state={@state}
|
||||
show_shortcut={false}
|
||||
current_interaction={@current_interaction}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -234,6 +264,38 @@
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
<li id="option-4" role="option" tabindex="-1">
|
||||
<a
|
||||
data-phx-link="patch"
|
||||
data-phx-link-state="push"
|
||||
href={~p"/e/#{@event.code}/manage/add/quiz"}
|
||||
class="group flex select-none rounded-xl p-3 w-full hover:bg-gray-200 cursor-pointer"
|
||||
>
|
||||
<div class="flex h-12 w-12 flex-none text-white items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-secondary-500">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-7 h-7"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-
|
||||
linejoin="round"
|
||||
d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-4 flex-auto text-left">
|
||||
<p class="font-medium text-gray-700"><%= gettext("Quiz") %></p>
|
||||
<p class="text-gray-500">
|
||||
<%= gettext("Add a quiz to test knowledge.") %>
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<% end %>
|
||||
<%= if @create=="poll" do %>
|
||||
@@ -298,6 +360,64 @@
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @create=="quiz" do %>
|
||||
<div class="scroll-py-3 overflow-y-auto bg-gray-100 p-3">
|
||||
<p class="text-xl font-bold">
|
||||
<%= case @create_action do
|
||||
:new -> gettext("New quiz")
|
||||
:edit -> gettext("Edit quiz")
|
||||
end %>
|
||||
</p>
|
||||
<.live_component
|
||||
module={ClaperWeb.QuizLive.QuizComponent}
|
||||
id="quiz-create"
|
||||
event={@event}
|
||||
presentation_file={@event.presentation_file}
|
||||
quiz={@quiz}
|
||||
live_action={@create_action}
|
||||
position={@state.position}
|
||||
return_to={~p"/e/#{@event.code}/manage"}
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @create == "import" do %>
|
||||
<div class="scroll-py-3 overflow-y-auto bg-gray-100 p-3">
|
||||
<p class="text-xl font-bold">
|
||||
<%= gettext("Select presentation") %>
|
||||
</p>
|
||||
<ul>
|
||||
<%= for event <- @events do %>
|
||||
<li class="my-3">
|
||||
<button
|
||||
phx-click="import"
|
||||
phx-value-event={event.uuid}
|
||||
class="bg-blue-500 text-white flex gap-x-2 items-center px-2 py-1 rounded-md"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
<%= event.name %>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -309,6 +429,7 @@
|
||||
data-prev-label={gettext("Back")}
|
||||
data-finish-label={gettext("Finish")}
|
||||
data-group="manage"
|
||||
data-btn-trigger="#product-tour-btn"
|
||||
phx-hook="TourGuide"
|
||||
>
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b-2 border-gray-200 w-full bg-white">
|
||||
@@ -350,7 +471,7 @@
|
||||
d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"
|
||||
/>
|
||||
</svg>
|
||||
<span><%= @event.code %></span>
|
||||
<span class="uppercase"><%= @event.code %></span>
|
||||
</div>
|
||||
<div class="flex items-center text-sm text-gray-500 gap-x-1">
|
||||
<svg
|
||||
@@ -368,6 +489,13 @@
|
||||
<span id="attendees-count" phx-update="ignore" phx-hook="UpdateAttendees">
|
||||
<%= @attendees_nb %>
|
||||
</span>
|
||||
<span>
|
||||
<%= link(gettext("Join"),
|
||||
to: ~p"/e/#{@event.code}",
|
||||
class: "text-xs text-primary-600 font-semibold text-sm ",
|
||||
target: "_blank"
|
||||
) %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -540,7 +668,7 @@
|
||||
data-type="row"
|
||||
data-gutter=".gutter"
|
||||
id="base-layout"
|
||||
class={"#{if @event.presentation_file.length > 0, do: "md:grid grid-rows-[1fr_10px_1fr] overflow-y-auto", else: ""}"}
|
||||
class={"#{if @event.presentation_file.length > 0, do: "md:grid grid-rows-[0.3fr_10px_1fr] overflow-y-auto", else: ""}"}
|
||||
>
|
||||
<div
|
||||
:if={@event.presentation_file.length > 0}
|
||||
@@ -597,7 +725,7 @@
|
||||
<div class="h- overflow-y-auto @container">
|
||||
<div
|
||||
:if={length(@interactions) == 0}
|
||||
class="text-center flex flex-col space-y-5 items-center justify-center text-gray-400 h-full"
|
||||
class="text-center flex flex-col space-y-5 items-center justify-center text-gray-400 h-full md:pt-8"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -639,57 +767,6 @@
|
||||
<div class="grid grid-cols-1 @sm:grid-cols-2 @lg:grid-cols-3 gap-4 p-4 overflow-y-auto">
|
||||
<%= for interaction <- @interactions do %>
|
||||
<div class="bg-white rounded-lg p-3 shadow-base transition-all flex flex-col justify-between relative">
|
||||
<div
|
||||
phx-hook="Dropdown"
|
||||
id={"poll-settings-#{interaction.id}"}
|
||||
class="hidden bg-white z-20 absolute text-sm w-full h-full top-0 left-0 p-3 rounded-lg overflow-y-auto"
|
||||
>
|
||||
<ul class="flex flex-col gap-y-2">
|
||||
<li>
|
||||
<button
|
||||
phx-click="checked"
|
||||
phx-value-key="poll_visible"
|
||||
value={"#{!@state.poll_visible}"}
|
||||
class="py-2 px-2 rounded text-gray-600 bg-gray-100 hover:bg-gray-200 flex justify-center items-center w-full gap-x-3"
|
||||
>
|
||||
<svg
|
||||
:if={@state.poll_visible}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-6 w-6"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 4h1m4 0h13" /><path d="M4 4v10a2 2 0 0 0 2 2h10m3.42 -.592c.359 -.362 .58 -.859 .58 -1.408v-10" /><path d="M12 16v4" /><path d="M9 20h6" /><path d="M8 12l2 -2m4 0l2 -2" /><path d="M3 3l18 18" />
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
:if={!@state.poll_visible}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-6 w-6"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 4l18 0" /><path d="M4 4v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-10" /><path d="M12 16l0 4" /><path d="M9 20l6 0" /><path d="M8 12l3 -3l2 2l3 -3" />
|
||||
</svg>
|
||||
|
||||
<span :if={@state.poll_visible}>
|
||||
<%= gettext("Hide on presentation") %>
|
||||
</span>
|
||||
<span :if={!@state.poll_visible}>
|
||||
<%= gettext("Show on presentation") %>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<%= case interaction do %>
|
||||
<% %Claper.Polls.Poll{} -> %>
|
||||
@@ -806,21 +883,72 @@
|
||||
class="p-2 rounded text-xs font-medium text-center text-primary-500"
|
||||
data-phx-link="patch"
|
||||
data-phx-link-state="push"
|
||||
href={
|
||||
case interaction do
|
||||
%Claper.Polls.Poll{} ->
|
||||
~p"/e/#{@event.code}/manage/edit/poll/#{interaction.id}"
|
||||
href={~p"/e/#{@event.code}/manage/edit/embed/#{interaction.id}"}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<% %Claper.Quizzes.Quiz{} -> %>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center w-full">
|
||||
<div class="flex h-8 w-8 flex-none text-white items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-secondary-500 mr-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-
|
||||
linejoin="round"
|
||||
d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-semibold">
|
||||
<%= gettext("Quiz") %>
|
||||
</span>
|
||||
<p
|
||||
:if={interaction.lti_resource_id}
|
||||
class="text-xs text-white rounded-md px-1 py-0.5 bg-gray-500 mx-2 flex items-center space-x-1"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="h-3 w-3"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9.664 1.319a.75.75 0 0 1 .672 0 41.059 41.059 0 0 1 8.198 5.424.75.75 0 0 1-.254 1.285 31.372 31.372 0 0 0-7.86 3.83.75.75 0 0 1-.84 0 31.508 31.508 0 0 0-2.08-1.287V9.394c0-.244.116-.463.302-.592a35.504 35.504 0 0 1 3.305-2.033.75.75 0 0 0-.714-1.319 37 37 0 0 0-3.446 2.12A2.216 2.216 0 0 0 6 9.393v.38a31.293 31.293 0 0 0-4.28-1.746.75.75 0 0 1-.254-1.285 41.059 41.059 0 0 1 8.198-5.424ZM6 11.459a29.848 29.848 0 0 0-2.455-1.158 41.029 41.029 0 0 0-.39 3.114.75.75 0 0 0 .419.74c.528.256 1.046.53 1.554.82-.21.324-.455.63-.739.914a.75.75 0 1 0 1.06 1.06c.37-.369.69-.77.96-1.193a26.61 26.61 0 0 1 3.095 2.348.75.75 0 0 0 .992 0 26.547 26.547 0 0 1 5.93-3.95.75.75 0 0 0 .42-.739 41.053 41.053 0 0 0-.39-3.114 29.925 29.925 0 0 0-5.199 2.801 2.25 2.25 0 0 1-2.514 0c-.41-.275-.826-.541-1.25-.797a6.985 6.985 0 0 1-1.084 3.45 26.503 26.503 0 0 0-1.281-.78A5.487 5.487 0 0 0 6 12v-.54Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
%Claper.Forms.Form{} ->
|
||||
~p"/e/#{@event.code}/manage/edit/form/#{interaction.id}"
|
||||
|
||||
%Claper.Embeds.Embed{} ->
|
||||
~p"/e/#{@event.code}/manage/edit/embed/#{interaction.id}"
|
||||
|
||||
_ ->
|
||||
"#"
|
||||
end
|
||||
}
|
||||
<span>LTI AGS</span>
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
class="p-2 rounded text-xs font-medium text-center text-primary-500"
|
||||
data-phx-link="patch"
|
||||
data-phx-link-state="push"
|
||||
href={~p"/e/#{@event.code}/manage/edit/quiz/#{interaction.id}"}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -968,6 +1096,7 @@
|
||||
%Claper.Polls.Poll{} -> "poll-set-inactive"
|
||||
%Claper.Forms.Form{} -> "form-set-inactive"
|
||||
%Claper.Embeds.Embed{} -> "embed-set-inactive"
|
||||
%Claper.Quizzes.Quiz{} -> "quiz-set-inactive"
|
||||
_ -> ""
|
||||
end
|
||||
}
|
||||
@@ -976,34 +1105,6 @@
|
||||
>
|
||||
<%= gettext("Disable") %>
|
||||
</button>
|
||||
<div
|
||||
:if={
|
||||
case interaction do
|
||||
%Claper.Polls.Poll{} -> true
|
||||
_ -> false
|
||||
end
|
||||
}
|
||||
class="w-full flex-1 relative"
|
||||
>
|
||||
<button
|
||||
phx-click-away={JS.hide(to: "#poll-settings-#{interaction.id}")}
|
||||
phx-click={JS.toggle(to: "#poll-settings-#{interaction.id}")}
|
||||
class="bg-gray-100 text-gray-800 px-2 py-2 rounded text-sm font-medium relative"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="h-5 w-5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M11.078 2.25c-.917 0-1.699.663-1.85 1.567L9.05 4.889c-.02.12-.115.26-.297.348a7.493 7.493 0 0 0-.986.57c-.166.115-.334.126-.45.083L6.3 5.508a1.875 1.875 0 0 0-2.282.819l-.922 1.597a1.875 1.875 0 0 0 .432 2.385l.84.692c.095.078.17.229.154.43a7.598 7.598 0 0 0 0 1.139c.015.2-.059.352-.153.43l-.841.692a1.875 1.875 0 0 0-.432 2.385l.922 1.597a1.875 1.875 0 0 0 2.282.818l1.019-.382c.115-.043.283-.031.45.082.312.214.641.405.985.57.182.088.277.228.297.35l.178 1.071c.151.904.933 1.567 1.85 1.567h1.844c.916 0 1.699-.663 1.85-1.567l.178-1.072c.02-.12.114-.26.297-.349.344-.165.673-.356.985-.57.167-.114.335-.125.45-.082l1.02.382a1.875 1.875 0 0 0 2.28-.819l.923-1.597a1.875 1.875 0 0 0-.432-2.385l-.84-.692c-.095-.078-.17-.229-.154-.43a7.614 7.614 0 0 0 0-1.139c-.016-.2.059-.352.153-.43l.84-.692c.708-.582.891-1.59.433-2.385l-.922-1.597a1.875 1.875 0 0 0-2.282-.818l-1.02.382c-.114.043-.282.031-.449-.083a7.49 7.49 0 0 0-.985-.57c-.183-.087-.277-.227-.297-.348l-.179-1.072a1.875 1.875 0 0 0-1.85-1.567h-1.843ZM12 15.75a3.75 3.75 0 1 0 0-7.5 3.75 3.75 0 0 0 0 7.5Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<button
|
||||
@@ -1012,6 +1113,7 @@
|
||||
%Claper.Polls.Poll{} -> "poll-set-active"
|
||||
%Claper.Forms.Form{} -> "form-set-active"
|
||||
%Claper.Embeds.Embed{} -> "embed-set-active"
|
||||
%Claper.Quizzes.Quiz{} -> "quiz-set-active"
|
||||
_ -> ""
|
||||
end
|
||||
}
|
||||
@@ -1051,7 +1153,7 @@
|
||||
phx-hook="Split"
|
||||
data-gutter=".gutter-2"
|
||||
data-type="row"
|
||||
class="md:grid grid-rows-[1fr_10px_1fr] overflow-y-auto"
|
||||
class="md:grid grid-rows-[0.5fr_10px_1fr] overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
class="bg-gray-200 border-2 overflow-auto relative grid grid-rows-[auto_1fr] h-full w-full"
|
||||
@@ -1356,17 +1458,22 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="hidden md:block px-5 py-3 z-20 bg-white @container"
|
||||
class="hidden md:block z-20 bg-white @container"
|
||||
data-tg-title={"#{gettext("Settings")}"}
|
||||
data-tg-order="3"
|
||||
data-tg-tour={"<p class='mb-3'>#{gettext("You can control each setting for the presentation (showing on the big screen) and on the attendee's room.")}</p><p class='opacity-50 text-xs'>#{gettext("Use the associated keyboard shortcuts for quick toggling of these settings.")}</p>"}
|
||||
data-tg-group="manage"
|
||||
>
|
||||
<div class="w-full h-12 bg-gray-100 font-semibold text-xl flex items-center justify-center">
|
||||
<%= gettext("Settings") %>
|
||||
</div>
|
||||
|
||||
<.live_component
|
||||
id="settings-pane"
|
||||
module={ClaperWeb.EventLive.ManagerSettingsComponent}
|
||||
create={@create}
|
||||
state={@state}
|
||||
current_interaction={@current_interaction}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
54
lib/claper_web/live/event_live/manageable_quiz_component.ex
Normal file
54
lib/claper_web/live/event_live/manageable_quiz_component.ex
Normal file
@@ -0,0 +1,54 @@
|
||||
defmodule ClaperWeb.EventLive.ManageableQuizComponent do
|
||||
use ClaperWeb, :live_component
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok,
|
||||
socket |> assign(current_question_idx: -1) |> assign_new(:current_question, fn -> nil end)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
id={"#{@id}"}
|
||||
class={"#{if @quiz.show_results, do: "opacity-100", else: "opacity-0"} h-full w-full flex flex-col justify-center bg-black bg-opacity-90 absolute z-30 left-1/2 top-1/2 transform -translate-y-1/2 -translate-x-1/2 p-10 transition-opacity"}
|
||||
>
|
||||
<div class="w-full md:w-1/2 mx-auto h-full">
|
||||
<p class={"#{if @iframe, do: "text-xl mb-12", else: "text-5xl mb-24"} text-white font-bold text-center"}>
|
||||
<span :if={@current_question_idx < 0}><%= @quiz.title %></span>
|
||||
<span :if={@current_question_idx >= 0}>
|
||||
<%= Enum.at(@quiz.quiz_questions, @current_question_idx).content %>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div
|
||||
:if={@current_question_idx == -1}
|
||||
class={"#{if @iframe, do: "space-y-5", else: "space-y-8"} flex flex-col text-white text-center"}
|
||||
>
|
||||
<p class="font-semibold text-2xl"><%= gettext("Average score") %>:</p>
|
||||
<p class="font-semibold text-7xl">
|
||||
<%= Claper.Quizzes.calculate_average_score(@quiz.id) %>/<%= length(@quiz.quiz_questions) %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={@current_question_idx >= 0}
|
||||
class={"#{if @iframe, do: "space-y-5", else: "space-y-8"} flex flex-col text-white text-center"}
|
||||
>
|
||||
<%= for {opt, _idx} <- Enum.with_index(Enum.at(@quiz.quiz_questions, @current_question_idx).quiz_question_opts) do %>
|
||||
<div class={"bg-gray-500 px-5 py-5 rounded-xl flex justify-between items-center relative text-white #{if opt.is_correct, do: "bg-green-600"} #{if not opt.is_correct, do: ""}"}>
|
||||
<div class="bg-gradient-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-3xl">
|
||||
</div>
|
||||
<div class="flex space-x-3 justify-between w-full items-center z-10 text-left">
|
||||
<span class="flex-1 pr-2 text-3xl"><%= opt.content %></span>
|
||||
<span class="text-xl"><%= opt.percentage %>% (<%= opt.response_count %>)</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
@@ -5,134 +5,546 @@ defmodule ClaperWeb.EventLive.ManagerSettingsComponent do
|
||||
assigns = assigns |> assign_new(:show_shortcut, fn -> true end)
|
||||
|
||||
~H"""
|
||||
<div class="grid grid-cols-1 @md:grid-cols-2 space-x-2">
|
||||
<div class="grid grid-cols-1 @md:grid-cols-2 @md:space-x-5 px-5 py-3">
|
||||
<div>
|
||||
<span class="font-semibold text-lg">
|
||||
<%= gettext("Presentation settings") %>
|
||||
</span>
|
||||
<div class="flex items-center space-x-2 font-semibold text-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path d="M6.111 11.89A5.5 5.5 0 1 1 15.501 8 .75.75 0 0 0 17 8a7 7 0 1 0-11.95 4.95.75.75 0 0 0 1.06-1.06Z" />
|
||||
<path d="M8.232 6.232a2.5 2.5 0 0 0 0 3.536.75.75 0 1 1-1.06 1.06A4 4 0 1 1 14 8a.75.75 0 0 1-1.5 0 2.5 2.5 0 0 0-4.268-1.768Z" />
|
||||
<path d="M10.766 7.51a.75.75 0 0 0-1.37.365l-.492 6.861a.75.75 0 0 0 1.204.65l1.043-.799.985 3.678a.75.75 0 0 0 1.45-.388l-.978-3.646 1.292.204a.75.75 0 0 0 .74-1.16l-3.874-5.764Z" />
|
||||
</svg>
|
||||
|
||||
<div class="flex space-x-2 items-center mt-3">
|
||||
<ClaperWeb.Component.Input.check
|
||||
key={:join_screen_visible}
|
||||
checked={@state.join_screen_visible}
|
||||
shortcut={if @create == nil, do: "Q", else: nil}
|
||||
/>
|
||||
<span>
|
||||
<%= gettext("Show instructions (QR Code)") %>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
>
|
||||
q
|
||||
</code>
|
||||
</span>
|
||||
<span><%= gettext("Interaction") %></span>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 items-center mt-3">
|
||||
<ClaperWeb.Component.Input.check
|
||||
key={:chat_visible}
|
||||
checked={@state.chat_visible}
|
||||
shortcut={if @create == nil, do: "W", else: nil}
|
||||
/>
|
||||
<span>
|
||||
<%= gettext("Show messages") %>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
>
|
||||
w
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
<%= case @current_interaction do %>
|
||||
<% %Claper.Polls.Poll{} -> %>
|
||||
<div class="flex space-x-2 space-y-1.5 items-center mt-1.5">
|
||||
<ClaperWeb.Component.Input.check_button
|
||||
key={:poll_visible}
|
||||
checked={@state.poll_visible}
|
||||
shortcut={if @create == nil, do: "Z", else: nil}
|
||||
>
|
||||
<svg
|
||||
:if={@state.poll_visible}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-6 w-6"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 4h1m4 0h13" /><path d="M4 4v10a2 2 0 0 0 2 2h10m3.42 -.592c.359 -.362 .58 -.859 .58 -1.408v-10" /><path d="M12 16v4" /><path d="M9 20h6" /><path d="M8 12l2 -2m4 0l2 -2" /><path d="M3 3l18 18" />
|
||||
</svg>
|
||||
|
||||
<div
|
||||
class={"#{if !@state.chat_visible, do: "opacity-50"} flex space-x-2 items-center mt-3"}
|
||||
title={
|
||||
if !@state.chat_visible,
|
||||
do: gettext("Show messages to change this option"),
|
||||
else: nil
|
||||
}
|
||||
>
|
||||
<ClaperWeb.Component.Input.check
|
||||
key={:show_only_pinned}
|
||||
checked={@state.show_only_pinned}
|
||||
disabled={!@state.chat_visible}
|
||||
shortcut={if @create == nil, do: "E", else: nil}
|
||||
/>
|
||||
<span>
|
||||
<%= gettext("Show only pinned messages") %>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
>
|
||||
e
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
:if={!@state.poll_visible}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-6 w-6"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 4l18 0" /><path d="M4 4v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-10" /><path d="M12 16l0 4" /><path d="M9 20l6 0" /><path d="M8 12l3 -3l2 2l3 -3" />
|
||||
</svg>
|
||||
|
||||
<span :if={@state.poll_visible}>
|
||||
<%= gettext("Hide results on presentation") %>
|
||||
</span>
|
||||
<span :if={!@state.poll_visible}>
|
||||
<%= gettext("Show results on presentation") %>
|
||||
</span>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
>
|
||||
z
|
||||
</code>
|
||||
<div :if={!@show_shortcut}></div>
|
||||
</ClaperWeb.Component.Input.check_button>
|
||||
</div>
|
||||
<% %Claper.Quizzes.Quiz{} -> %>
|
||||
<div class="grid grid-cols-1 space-y-1.5 items-center mt-1.5">
|
||||
<ClaperWeb.Component.Input.check_button
|
||||
key={:quiz_show_results}
|
||||
checked={@current_interaction.show_results}
|
||||
shortcut={if @create == nil, do: "Z", else: nil}
|
||||
>
|
||||
<svg
|
||||
:if={@current_interaction.show_results}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-6 w-6"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 4h1m4 0h13" /><path d="M4 4v10a2 2 0 0 0 2 2h10m3.42 -.592c.359 -.362 .58 -.859 .58 -1.408v-10" /><path d="M12 16v4" /><path d="M9 20h6" /><path d="M8 12l2 -2m4 0l2 -2" /><path d="M3 3l18 18" />
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
:if={!@current_interaction.show_results}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="h-6 w-6"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 4l18 0" /><path d="M4 4v10a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-10" /><path d="M12 16l0 4" /><path d="M9 20l6 0" /><path d="M8 12l3 -3l2 2l3 -3" />
|
||||
</svg>
|
||||
|
||||
<span :if={@current_interaction.show_results}>
|
||||
<%= gettext("Hide results on presentation") %>
|
||||
</span>
|
||||
<span :if={!@current_interaction.show_results}>
|
||||
<%= gettext("Show results on presentation") %>
|
||||
</span>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
>
|
||||
z
|
||||
</code>
|
||||
<div :if={!@show_shortcut}></div>
|
||||
</ClaperWeb.Component.Input.check_button>
|
||||
<div>
|
||||
<ClaperWeb.Component.Input.check_button
|
||||
disabled={!@current_interaction.show_results}
|
||||
key={:review_quiz_questions}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-6 h-6"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 13a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v6a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M15 9a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v10a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M9 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v14a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M4 20h14" />
|
||||
</svg>
|
||||
|
||||
<span>
|
||||
<%= gettext("Review questions") %>
|
||||
</span>
|
||||
<div></div>
|
||||
</ClaperWeb.Component.Input.check_button>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<ClaperWeb.Component.Input.check_button
|
||||
disabled={!@current_interaction.show_results}
|
||||
key={:prev_quiz_question}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-6 h-6"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span>
|
||||
<%= gettext("Previous") %>
|
||||
</span>
|
||||
</ClaperWeb.Component.Input.check_button>
|
||||
<ClaperWeb.Component.Input.check_button
|
||||
disabled={!@current_interaction.show_results}
|
||||
key={:next_quiz_question}
|
||||
>
|
||||
<span>
|
||||
<%= gettext("Next") %>
|
||||
</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-6 h-6"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</ClaperWeb.Component.Input.check_button>
|
||||
</div>
|
||||
</div>
|
||||
<% nil -> %>
|
||||
<p class="text-gray-400 italic mt-1.5">No interaction enabled</p>
|
||||
<% _ -> %>
|
||||
<p class="text-gray-400 italic mt-1.5">No settings available for this interaction</p>
|
||||
<% end %>
|
||||
|
||||
<div class="flex space-x-2 items-center mt-3"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="font-semibold text-lg">
|
||||
<%= gettext("Attendees settings") %>
|
||||
</span>
|
||||
|
||||
<div class="flex space-x-2 items-center mt-3">
|
||||
<ClaperWeb.Component.Input.check
|
||||
key={:chat_enabled}
|
||||
checked={@state.chat_enabled}
|
||||
shortcut={if @create == nil, do: "A", else: nil}
|
||||
/>
|
||||
<span>
|
||||
<%= gettext("Enable messages") %>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
<div class="grid grid-cols-1 space-y-5">
|
||||
<div class="grid grid-cols-1 space-y-1.5">
|
||||
<div class="flex items-center space-x-2 font-semibold text-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
a
|
||||
</code>
|
||||
</span>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M1 2.75A.75.75 0 0 1 1.75 2h16.5a.75.75 0 0 1 0 1.5H18v8.75A2.75 2.75 0 0 1 15.25 15h-1.072l.798 3.06a.75.75 0 0 1-1.452.38L13.41 18H6.59l-.114.44a.75.75 0 0 1-1.452-.38L5.823 15H4.75A2.75 2.75 0 0 1 2 12.25V3.5h-.25A.75.75 0 0 1 1 2.75ZM7.373 15l-.391 1.5h6.037l-.392-1.5H7.373ZM13.25 5a.75.75 0 0 1 .75.75v5.5a.75.75 0 0 1-1.5 0v-5.5a.75.75 0 0 1 .75-.75Zm-6.5 4a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 6.75 9Zm4-1.25a.75.75 0 0 0-1.5 0v3.5a.75.75 0 0 0 1.5 0v-3.5Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<span><%= gettext("Presentation") %></span>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-1 items-center mt-3">
|
||||
<ClaperWeb.Component.Input.check_button
|
||||
key={:join_screen_visible}
|
||||
checked={@state.join_screen_visible}
|
||||
shortcut={if @create == nil, do: "Q", else: nil}
|
||||
>
|
||||
<svg
|
||||
:if={!@state.join_screen_visible}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M4 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M7 17l0 .01" /><path d="M14 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M7 7l0 .01" /><path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M17 7l0 .01" /><path d="M14 14l3 0" /><path d="M20 14l0 .01" /><path d="M14 14l0 3" /><path d="M14 20l3 0" /><path d="M17 17l3 0" /><path d="M20 17l0 3" />
|
||||
</svg>
|
||||
|
||||
<svg
|
||||
:if={@state.join_screen_visible}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 4h1a1 1 0 0 1 1 1v1m-.297 3.711a1 1 0 0 1 -.703 .289h-4a1 1 0 0 1 -1 -1v-4c0 -.275 .11 -.524 .29 -.705" /><path d="M7 17v.01" /><path d="M14 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M7 7v.01" /><path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" /><path d="M17 7v.01" /><path d="M20 14v.01" /><path d="M14 14v3" /><path d="M14 20h3" /><path d="M3 3l18 18" />
|
||||
</svg>
|
||||
<div>
|
||||
<span :if={!@state.join_screen_visible}>
|
||||
<%= gettext("Show instructions to join") %>
|
||||
</span>
|
||||
<span :if={@state.join_screen_visible}>
|
||||
<%= gettext("Hide instructions to join") %>
|
||||
</span>
|
||||
</div>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
>
|
||||
q
|
||||
</code>
|
||||
<div :if={!@show_shortcut}></div>
|
||||
</ClaperWeb.Component.Input.check_button>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 items-center mt-3">
|
||||
<ClaperWeb.Component.Input.check_button
|
||||
key={:chat_visible}
|
||||
checked={@state.chat_visible}
|
||||
shortcut={if @create == nil, do: "W", else: nil}
|
||||
>
|
||||
<svg
|
||||
:if={!@state.chat_visible}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h8" /><path d="M8 13h6" /><path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12z" />
|
||||
</svg>
|
||||
<svg
|
||||
:if={@state.chat_visible}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h1m4 0h3" /><path d="M8 13h5" /><path d="M8 4h10a3 3 0 0 1 3 3v8c0 .577 -.163 1.116 -.445 1.573m-2.555 1.427h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8c0 -1.085 .576 -2.036 1.439 -2.562" /><path d="M3 3l18 18" />
|
||||
</svg>
|
||||
<div>
|
||||
<span :if={!@state.chat_visible}><%= gettext("Show messages") %></span>
|
||||
<span :if={@state.chat_visible}><%= gettext("Hide messages") %></span>
|
||||
</div>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
>
|
||||
w
|
||||
</code>
|
||||
<div :if={!@show_shortcut}></div>
|
||||
</ClaperWeb.Component.Input.check_button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={"#{if !@state.chat_visible, do: "opacity-50"} flex space-x-2 items-center mt-3"}
|
||||
title={
|
||||
if !@state.chat_visible,
|
||||
do: gettext("Show messages to change this option"),
|
||||
else: nil
|
||||
}
|
||||
>
|
||||
<ClaperWeb.Component.Input.check_button
|
||||
key={:show_only_pinned}
|
||||
checked={@state.show_only_pinned}
|
||||
disabled={!@state.chat_visible}
|
||||
shortcut={if @create == nil, do: "E", else: nil}
|
||||
>
|
||||
<svg
|
||||
:if={!@state.show_only_pinned}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h8" /><path d="M8 13h4.5" /><path d="M10.325 19.605l-2.325 1.395v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v4.5" /><path d="M17.8 20.817l-2.172 1.138a.392 .392 0 0 1 -.568 -.41l.415 -2.411l-1.757 -1.707a.389 .389 0 0 1 .217 -.665l2.428 -.352l1.086 -2.193a.392 .392 0 0 1 .702 0l1.086 2.193l2.428 .352a.39 .39 0 0 1 .217 .665l-1.757 1.707l.414 2.41a.39 .39 0 0 1 -.567 .411l-2.172 -1.138z" />
|
||||
</svg>
|
||||
<svg
|
||||
:if={@state.show_only_pinned}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h8" /><path d="M8 13h6" /><path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12z" />
|
||||
</svg>
|
||||
<div>
|
||||
<span :if={!@state.show_only_pinned}>
|
||||
<%= gettext("Show only pinned messages") %>
|
||||
</span>
|
||||
<span :if={@state.show_only_pinned}><%= gettext("Show all messages") %></span>
|
||||
</div>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
>
|
||||
e
|
||||
</code>
|
||||
<div :if={!@show_shortcut}></div>
|
||||
</ClaperWeb.Component.Input.check_button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={"#{if !@state.chat_enabled, do: "opacity-50"} flex space-x-2 items-center mt-3"}
|
||||
title={
|
||||
if !@state.chat_enabled,
|
||||
do: gettext("Enable messages to change this option"),
|
||||
else: nil
|
||||
}
|
||||
>
|
||||
<ClaperWeb.Component.Input.check
|
||||
key={:anonymous_chat_enabled}
|
||||
checked={@state.anonymous_chat_enabled}
|
||||
disabled={!@state.chat_enabled}
|
||||
shortcut={if @create == nil, do: "S", else: nil}
|
||||
/>
|
||||
<span>
|
||||
<%= gettext("Enable anonymous messages") %>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
<div class="grid grid-cols-1 space-y-1.5">
|
||||
<div class="flex items-center space-x-2 font-semibold text-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
s
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
<path d="M8 16.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 4a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H7a3 3 0 0 1-3-3V4Zm4-1.5v.75c0 .414.336.75.75.75h2.5a.75.75 0 0 0 .75-.75V2.5h1A1.5 1.5 0 0 1 14.5 4v12a1.5 1.5 0 0 1-1.5 1.5H7A1.5 1.5 0 0 1 5.5 16V4A1.5 1.5 0 0 1 7 2.5h1Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div class="flex space-x-2 items-center mt-3">
|
||||
<ClaperWeb.Component.Input.check
|
||||
key={:message_reaction_enabled}
|
||||
checked={@state.message_reaction_enabled}
|
||||
shortcut={if @create == nil, do: "D", else: nil}
|
||||
/>
|
||||
<span>
|
||||
<%= gettext("Enable reactions") %>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
<span><%= gettext("Attendees") %></span>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 items-center mt-3">
|
||||
<ClaperWeb.Component.Input.check_button
|
||||
key={:chat_enabled}
|
||||
checked={@state.chat_enabled}
|
||||
shortcut={if @create == nil, do: "A", else: nil}
|
||||
>
|
||||
d
|
||||
</code>
|
||||
</span>
|
||||
<svg
|
||||
:if={!@state.chat_enabled}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h8" /><path d="M8 13h6" /><path d="M18 4a3 3 0 0 1 3 3v8a3 3 0 0 1 -3 3h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12z" />
|
||||
</svg>
|
||||
<svg
|
||||
:if={@state.chat_enabled}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 9h1m4 0h3" /><path d="M8 13h5" /><path d="M8 4h10a3 3 0 0 1 3 3v8c0 .577 -.163 1.116 -.445 1.573m-2.555 1.427h-5l-5 3v-3h-2a3 3 0 0 1 -3 -3v-8c0 -1.085 .576 -2.036 1.439 -2.562" /><path d="M3 3l18 18" />
|
||||
</svg>
|
||||
<div>
|
||||
<span :if={!@state.chat_enabled}><%= gettext("Enable messages") %></span>
|
||||
<span :if={@state.chat_enabled}><%= gettext("Disable messages") %></span>
|
||||
</div>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
>
|
||||
a
|
||||
</code>
|
||||
<div :if={!@show_shortcut}></div>
|
||||
</ClaperWeb.Component.Input.check_button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class={"#{if !@state.chat_enabled, do: "opacity-50"} flex space-x-2 items-center mt-3"}
|
||||
title={
|
||||
if !@state.chat_enabled,
|
||||
do: gettext("Enable messages to change this option"),
|
||||
else: nil
|
||||
}
|
||||
>
|
||||
<ClaperWeb.Component.Input.check_button
|
||||
key={:anonymous_chat_enabled}
|
||||
checked={@state.anonymous_chat_enabled}
|
||||
disabled={!@state.chat_enabled}
|
||||
shortcut={if @create == nil, do: "S", else: nil}
|
||||
>
|
||||
<svg
|
||||
:if={!@state.anonymous_chat_enabled}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 11h18" /><path d="M5 11v-4a3 3 0 0 1 3 -3h8a3 3 0 0 1 3 3v4" /><path d="M7 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" /><path d="M17 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" /><path d="M10 17h4" />
|
||||
</svg>
|
||||
<svg
|
||||
:if={@state.anonymous_chat_enabled}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 11h8m4 0h6" /><path d="M5 11v-4c0 -.571 .16 -1.105 .437 -1.56m2.563 -1.44h8a3 3 0 0 1 3 3v4" /><path d="M7 17m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" /><path d="M14.88 14.877a3 3 0 1 0 4.239 4.247m.59 -3.414a3.012 3.012 0 0 0 -1.425 -1.422" /><path d="M10 17h4" /><path d="M3 3l18 18" />
|
||||
</svg>
|
||||
|
||||
<div>
|
||||
<span :if={!@state.anonymous_chat_enabled}>
|
||||
<%= gettext("Allow anonymous messages") %>
|
||||
</span>
|
||||
<span :if={@state.anonymous_chat_enabled}>
|
||||
<%= gettext("Deny anonymous messages") %>
|
||||
</span>
|
||||
</div>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
>
|
||||
s
|
||||
</code>
|
||||
<div :if={!@show_shortcut}></div>
|
||||
</ClaperWeb.Component.Input.check_button>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-2 items-center mt-3">
|
||||
<ClaperWeb.Component.Input.check_button
|
||||
key={:message_reaction_enabled}
|
||||
checked={@state.message_reaction_enabled}
|
||||
shortcut={if @create == nil, do: "D", else: nil}
|
||||
>
|
||||
<svg
|
||||
:if={!@state.message_reaction_enabled}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M19.5 12.572l-7.5 7.428l-7.5 -7.428a5 5 0 1 1 7.5 -6.566a5 5 0 1 1 7.5 6.572" />
|
||||
</svg>
|
||||
<svg
|
||||
:if={@state.message_reaction_enabled}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 3l18 18" /><path d="M19.5 12.572l-1.5 1.428m-2 2l-4 4l-7.5 -7.428a5 5 0 0 1 -1.288 -5.068a4.976 4.976 0 0 1 1.788 -2.504m3 -1c1.56 0 3.05 .727 4 2a5 5 0 1 1 7.5 6.572" />
|
||||
</svg>
|
||||
|
||||
<div>
|
||||
<span :if={!@state.message_reaction_enabled}>
|
||||
<%= gettext("Enable reactions") %>
|
||||
</span>
|
||||
<span :if={@state.message_reaction_enabled}>
|
||||
<%= gettext("Disable reactions") %>
|
||||
</span>
|
||||
</div>
|
||||
<code
|
||||
:if={@show_shortcut}
|
||||
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
|
||||
>
|
||||
d
|
||||
</code>
|
||||
<div :if={!@show_shortcut}></div>
|
||||
</ClaperWeb.Component.Input.check_button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ defmodule ClaperWeb.EventLive.PollComponent do
|
||||
<div>
|
||||
<div
|
||||
id="collapsed-poll"
|
||||
class="bg-black py-3 px-6 text-black shadow-lg mx-auto rounded-full w-max hidden"
|
||||
class="bg-gray-900 py-3 px-6 text-black shadow-lg mx-auto rounded-full w-max hidden"
|
||||
>
|
||||
<div class="block w-full h-full cursor-pointer" phx-click={toggle_poll()} phx-target={@myself}>
|
||||
<div class="text-white flex space-x-2 items-center">
|
||||
@@ -29,7 +29,7 @@ defmodule ClaperWeb.EventLive.PollComponent do
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="extended-poll" class="bg-black w-full py-3 px-6 text-black shadow-lg rounded-md">
|
||||
<div id="extended-poll" class="bg-gray-900 w-full py-3 px-6 text-black shadow-lg rounded-md">
|
||||
<div class="block w-full h-full cursor-pointer" phx-click={toggle_poll()} phx-target={@myself}>
|
||||
<div id="poll-pane" class="float-right mt-2">
|
||||
<svg
|
||||
@@ -44,12 +44,12 @@ defmodule ClaperWeb.EventLive.PollComponent do
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 my-1"><%= gettext("Current poll") %></p>
|
||||
<p class="text-white text-lg font-semibold mb-2"><%= @poll.title %></p>
|
||||
<p class="text-sm text-gray-400 my-1"><%= gettext("Current poll") %></p>
|
||||
<p class="text-white text-xl font-semibold mb-2"><%= @poll.title %></p>
|
||||
<%= if @poll.multiple do %>
|
||||
<p class="text-gray-600 text-sm mb-4"><%= gettext("Select one or multiple options") %></p>
|
||||
<p class="text-gray-400 text-sm mb-4"><%= gettext("Select one or multiple options") %></p>
|
||||
<% else %>
|
||||
<p class="text-gray-600 text-sm mb-4"><%= gettext("Select one option") %></p>
|
||||
<p class="text-gray-400 text-sm mb-4"><%= gettext("Select one option") %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<div>
|
||||
@@ -57,10 +57,10 @@ defmodule ClaperWeb.EventLive.PollComponent do
|
||||
<%= if (length @poll.poll_opts) > 0 do %>
|
||||
<%= for {opt, idx} <- Enum.with_index(@poll.poll_opts) do %>
|
||||
<%= if (length @current_poll_vote) > 0 do %>
|
||||
<button class="bg-gray-500 px-3 py-2 rounded-3xl flex justify-between items-center relative text-white">
|
||||
<button class="bg-gray-500 px-3 py-2 rounded-lg flex justify-between items-center relative text-white">
|
||||
<div
|
||||
style={"width: #{if @show_results, do: opt.percentage, else: 0}%;"}
|
||||
class={"bg-gradient-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-3xl #{if opt.percentage == "100", do: "rounded-r-3xl"}"}
|
||||
class={"bg-gradient-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-lg #{if opt.percentage == "100", do: "rounded-r-lg"}"}
|
||||
>
|
||||
</div>
|
||||
<div class="flex space-x-3 items-center z-10 text-left">
|
||||
@@ -89,11 +89,11 @@ defmodule ClaperWeb.EventLive.PollComponent do
|
||||
id={"poll-opt-#{idx}"}
|
||||
phx-click="select-poll-opt"
|
||||
phx-value-opt={idx}
|
||||
class="bg-gray-500 px-3 py-2 flex justify-between items-center rounded-3xl relative text-white"
|
||||
class="bg-gray-500 px-3 py-2 flex justify-between items-center rounded-lg relative text-white"
|
||||
>
|
||||
<div
|
||||
style={"width: #{if @show_results, do: opt.percentage, else: 0}%;"}
|
||||
class={"bg-gradient-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-3xl #{if opt.percentage == "100", do: "rounded-r-3xl"}"}
|
||||
class={"bg-gradient-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-lg #{if opt.percentage == "100", do: "rounded-r-lg"}"}
|
||||
>
|
||||
</div>
|
||||
<div class="flex space-x-3 items-center z-10 text-left">
|
||||
@@ -123,14 +123,14 @@ defmodule ClaperWeb.EventLive.PollComponent do
|
||||
</div>
|
||||
|
||||
<%= if (length @selected_poll_opt) == 0 || (length @current_poll_vote) > 0 do %>
|
||||
<button class="px-3 py-2 text-white font-semibold bg-gray-500 rounded-md mt-3 mb-4 cursor-default">
|
||||
<button class="px-3 py-2 text-white font-medium bg-gray-500 rounded-md mt-3 mb-4 cursor-default">
|
||||
<%= gettext("Vote") %>
|
||||
</button>
|
||||
<% else %>
|
||||
<button
|
||||
phx-click="vote"
|
||||
phx-disable-with="..."
|
||||
class="px-3 py-2 text-white font-semibold bg-primary-500 hover:bg-primary-600 rounded-md mt-3 mb-4"
|
||||
class="px-3 py-2 text-white font-medium bg-primary-400 hover:bg-primary-500 rounded-md mt-3 mb-4"
|
||||
>
|
||||
<%= gettext("Vote") %>
|
||||
</button>
|
||||
|
||||
@@ -5,7 +5,7 @@ defmodule ClaperWeb.EventLive.Presenter do
|
||||
alias Claper.Embeds.Embed
|
||||
alias Claper.Polls.Poll
|
||||
alias Claper.Forms.Form
|
||||
|
||||
alias Claper.Quizzes.Quiz
|
||||
@impl true
|
||||
def mount(%{"code" => code} = params, session, socket) do
|
||||
with %{"locale" => locale} <- session do
|
||||
@@ -64,6 +64,7 @@ defmodule ClaperWeb.EventLive.Presenter do
|
||||
|> poll_at_position
|
||||
|> form_at_position
|
||||
|> embed_at_position
|
||||
|> quiz_at_position
|
||||
|
||||
{:ok, socket, temporary_assigns: []}
|
||||
end
|
||||
@@ -201,6 +202,20 @@ defmodule ClaperWeb.EventLive.Presenter do
|
||||
|> update(:current_embed, fn _current_embed -> nil end)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:quiz_updated, quiz}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> update(:current_quiz, fn _current_quiz -> quiz end)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:quiz_deleted, _quiz}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> update(:current_quiz, fn _current_quiz -> nil end)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:chat_visible, value}, socket) do
|
||||
{:noreply,
|
||||
@@ -249,7 +264,8 @@ defmodule ClaperWeb.EventLive.Presenter do
|
||||
socket
|
||||
|> assign(:current_poll, interaction)
|
||||
|> assign(:current_embed, nil)
|
||||
|> assign(:current_form, nil)}
|
||||
|> assign(:current_form, nil)
|
||||
|> assign(:current_quiz, nil)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
@@ -261,7 +277,8 @@ defmodule ClaperWeb.EventLive.Presenter do
|
||||
socket
|
||||
|> assign(:current_embed, interaction)
|
||||
|> assign(:current_poll, nil)
|
||||
|> assign(:current_form, nil)}
|
||||
|> assign(:current_form, nil)
|
||||
|> assign(:current_quiz, nil)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
@@ -273,7 +290,21 @@ defmodule ClaperWeb.EventLive.Presenter do
|
||||
socket
|
||||
|> assign(:current_form, interaction)
|
||||
|> assign(:current_poll, nil)
|
||||
|> assign(:current_embed, nil)}
|
||||
|> assign(:current_embed, nil)
|
||||
|> assign(:current_quiz, nil)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(
|
||||
{:current_interaction, %Quiz{} = interaction},
|
||||
socket
|
||||
) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:current_quiz, interaction)
|
||||
|> assign(:current_poll, nil)
|
||||
|> assign(:current_embed, nil)
|
||||
|> assign(:current_form, nil)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
@@ -285,7 +316,61 @@ defmodule ClaperWeb.EventLive.Presenter do
|
||||
socket
|
||||
|> assign(:current_poll, nil)
|
||||
|> assign(:current_embed, nil)
|
||||
|> assign(:current_form, nil)}
|
||||
|> assign(:current_form, nil)
|
||||
|> assign(:current_quiz, nil)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(
|
||||
{:review_quiz_questions},
|
||||
socket
|
||||
) do
|
||||
send_update(
|
||||
ClaperWeb.EventLive.ManageableQuizComponent,
|
||||
id: "#{socket.assigns.current_quiz.id}-quiz",
|
||||
current_question_idx: 0
|
||||
)
|
||||
|
||||
{:noreply, socket |> assign(:current_question_idx, 0)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(
|
||||
{:next_quiz_question},
|
||||
socket
|
||||
) do
|
||||
idx =
|
||||
if socket.assigns.current_question_idx <
|
||||
length(socket.assigns.current_quiz.quiz_questions) - 1,
|
||||
do: socket.assigns.current_question_idx + 1,
|
||||
else: -1
|
||||
|
||||
send_update(
|
||||
ClaperWeb.EventLive.ManageableQuizComponent,
|
||||
id: "#{socket.assigns.current_quiz.id}-quiz",
|
||||
current_question_idx: idx
|
||||
)
|
||||
|
||||
{:noreply, socket |> assign(:current_question_idx, idx)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(
|
||||
{:prev_quiz_question},
|
||||
socket
|
||||
) do
|
||||
idx =
|
||||
if socket.assigns.current_question_idx > 0,
|
||||
do: socket.assigns.current_question_idx - 1,
|
||||
else: 0
|
||||
|
||||
send_update(
|
||||
ClaperWeb.EventLive.ManageableQuizComponent,
|
||||
id: "#{socket.assigns.current_quiz.id}-quiz",
|
||||
current_question_idx: idx
|
||||
)
|
||||
|
||||
{:noreply, socket |> assign(:current_question_idx, idx)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
@@ -332,6 +417,16 @@ defmodule ClaperWeb.EventLive.Presenter do
|
||||
end
|
||||
end
|
||||
|
||||
defp quiz_at_position(%{assigns: %{event: event, state: state}} = socket) do
|
||||
with quiz <-
|
||||
Claper.Quizzes.get_quiz_current_position(
|
||||
event.presentation_file.id,
|
||||
state.position
|
||||
) do
|
||||
socket |> assign(:current_quiz, quiz) |> assign(:current_question_idx, 0)
|
||||
end
|
||||
end
|
||||
|
||||
defp list_posts(_socket, event_id) do
|
||||
Claper.Posts.list_posts(event_id, [:event, :reactions])
|
||||
end
|
||||
|
||||
@@ -77,6 +77,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<!-- QUIZ -->
|
||||
<%= if @current_quiz do %>
|
||||
<.live_component
|
||||
module={ClaperWeb.EventLive.ManageableQuizComponent}
|
||||
id={"#{@current_quiz.id}-quiz"}
|
||||
quiz={@current_quiz}
|
||||
iframe={@iframe}
|
||||
/>
|
||||
<% end %>
|
||||
<!-- MESSAGES -->
|
||||
<div
|
||||
id="slider-wrapper"
|
||||
|
||||
227
lib/claper_web/live/event_live/quiz_component.ex
Normal file
227
lib/claper_web/live/event_live/quiz_component.ex
Normal file
@@ -0,0 +1,227 @@
|
||||
defmodule ClaperWeb.EventLive.QuizComponent do
|
||||
use ClaperWeb, :live_component
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:is_submitted, length(assigns.current_quiz_responses) > 0)
|
||||
|> assign(
|
||||
:current_question,
|
||||
check_current_question(assigns)
|
||||
)
|
||||
|> assign(
|
||||
:has_selection,
|
||||
length(assigns.selected_quiz_question_opts) > 0
|
||||
)
|
||||
|
||||
~H"""
|
||||
<div>
|
||||
<div
|
||||
id="collapsed-quiz"
|
||||
class="bg-gray-900 py-3 px-6 text-black shadow-lg mx-auto rounded-full w-max hidden"
|
||||
>
|
||||
<div class="block w-full h-full cursor-pointer" phx-click={toggle_quiz()} phx-target={@myself}>
|
||||
<div class="text-white flex space-x-2 items-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="h-6 w-6"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-
|
||||
linejoin="round"
|
||||
d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="font-bold"><%= gettext("See current quiz") %></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="extended-quiz" class="bg-gray-900 w-full p-4 text-black shadow-lg rounded-md">
|
||||
<div class="block w-full h-full cursor-pointer" phx-click={toggle_quiz()} phx-target={@myself}>
|
||||
<div id="poll-pane" class="float-right mt-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-400 my-1"><%= gettext("Current quiz") %></p>
|
||||
<%= if is_nil(@current_question) do %>
|
||||
<p class="text-white text-xl font-semibold mb-2"><%= @quiz.title %></p>
|
||||
<% else %>
|
||||
<p class="text-white text-xl font-semibold mb-2"><%= @current_question.content %></p>
|
||||
<p class="text-gray-400 text-sm mb-4">
|
||||
<%= @current_quiz_question_idx + 1 %>/<%= length(@quiz.quiz_questions) %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex flex-col space-y-3 overflow-y-auto max-h-[500px]">
|
||||
<%= if @current_question do %>
|
||||
<%= for {opt, _idx} <- Enum.with_index(@current_question.quiz_question_opts) do %>
|
||||
<%= if @is_submitted do %>
|
||||
<div class={"bg-gray-500 px-3 py-2 rounded-lg flex justify-between items-center relative text-white #{if opt.is_correct, do: "bg-green-600"} #{if not opt.is_correct && Enum.member?(Enum.map(@current_quiz_responses, &(&1.quiz_question_opt_id)), opt.id), do: "bg-red-600"}"}>
|
||||
<div class="flex justify-between items-center z-10 text-left w-full">
|
||||
<div class="flex items-center text-left space-x-3">
|
||||
<%= if Enum.member?(Enum.map(@current_quiz_responses, &(&1.quiz_question_opt_id)), opt.id) do %>
|
||||
<div class="h-5 w-5 mt-0.5 rounded-md point-select bg-white"></div>
|
||||
<% else %>
|
||||
<div class="h-5 w-5 mt-0.5 rounded-md point-select border-2 border-white">
|
||||
</div>
|
||||
<% end %>
|
||||
<span class="flex-1 pr-2"><%= opt.content %></span>
|
||||
</div>
|
||||
|
||||
<span class="text-sm"><%= opt.percentage %>% (<%= opt.response_count %>)</span>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<button
|
||||
phx-click="select-quiz-question-opt"
|
||||
phx-value-opt={opt.id}
|
||||
class="bg-gray-500 px-3 py-2 rounded-lg flex justify-between items-center relative text-white"
|
||||
>
|
||||
<div class="bg-gradient-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-3xl">
|
||||
</div>
|
||||
<div class="flex space-x-3 items-center z-10 text-left">
|
||||
<%= if Enum.member?(@selected_quiz_question_opts, opt) do %>
|
||||
<span class="h-5 w-5 mt-0.5 rounded-md point-select bg-white"></span>
|
||||
<% else %>
|
||||
<span class="h-5 w-5 mt-0.5 rounded-md point-select border-2 border-white">
|
||||
</span>
|
||||
<% end %>
|
||||
<span class="flex-1 pr-2"><%= opt.content %></span>
|
||||
</div>
|
||||
</button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="text-gray-400 flex flex-col items-center justify-center font-semibold text-lg mt-4">
|
||||
<%= if @quiz.show_results do %>
|
||||
<p><%= gettext("Your score") %></p>
|
||||
<p class="text-6xl font-bold mt-2">
|
||||
<%= elem(@quiz_score, 0) %>/<%= elem(@quiz_score, 1) %>
|
||||
</p>
|
||||
<button
|
||||
phx-click="show-quiz-results"
|
||||
class="mt-7 px-3 py-2 text-white font-medium bg-primary-400 hover:bg-primary-500 rounded-md mt-3 mb-4"
|
||||
>
|
||||
<%= gettext("Show results") %>
|
||||
</button>
|
||||
<% else %>
|
||||
<p><%= gettext("Waiting for results...") %></p>
|
||||
<svg
|
||||
class="w-32 h-32 mt-4"
|
||||
viewBox="0 0 360 360"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clip-path="url(#clip0_1103_889)">
|
||||
<path
|
||||
d="M180 33C262.845 33 330 100.155 330 183C330 265.845 262.845 333 180 333C97.155 333 30 265.845 30 183C30 100.155 97.155 33 180 33ZM180 93C176.022 93 172.206 94.5804 169.393 97.3934C166.58 100.206 165 104.022 165 108V183C165.001 186.978 166.582 190.793 169.395 193.605L214.395 238.605C217.224 241.337 221.013 242.849 224.946 242.815C228.879 242.781 232.641 241.203 235.422 238.422C238.203 235.641 239.781 231.879 239.815 227.946C239.849 224.013 238.337 220.224 235.605 217.395L195 176.79V108C195 104.022 193.42 100.206 190.607 97.3934C187.794 94.5804 183.978 93 180 93Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1103_889">
|
||||
<rect width="100%" height="100%" fill="currentColor" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div :if={not @is_submitted} class="flex justify-between items-baseline w-full h-12 mt-5">
|
||||
<%= if @current_quiz_question_idx > 0 do %>
|
||||
<button phx-click="prev-question" class="px-3 py-2 text-white font-medium">
|
||||
<%= gettext("Back") %>
|
||||
</button>
|
||||
<% else %>
|
||||
<div class="w-1/2"></div>
|
||||
<% end %>
|
||||
|
||||
<button
|
||||
:if={@current_quiz_question_idx < length(@quiz.quiz_questions) - 1}
|
||||
phx-click="next-question"
|
||||
disabled={not @has_selection}
|
||||
class={"px-3 py-2 text-white font-medium rounded-md h-full #{if @has_selection, do: "bg-primary-400 hover:bg-primary-500", else: "bg-gray-500 cursor-not-allowed"}"}
|
||||
>
|
||||
<%= gettext("Next") %>
|
||||
</button>
|
||||
|
||||
<button
|
||||
:if={@current_quiz_question_idx == length(@quiz.quiz_questions) - 1}
|
||||
phx-click="submit-quiz"
|
||||
disabled={not @has_selection}
|
||||
class={"px-3 py-2 text-white font-medium rounded-md h-full #{if @has_selection, do: "bg-primary-400 hover:bg-primary-500", else: "bg-gray-500 cursor-not-allowed"}"}
|
||||
>
|
||||
<%= gettext("Submit") %>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:if={
|
||||
@is_submitted && @quiz.show_results &&
|
||||
@current_quiz_question_idx <= length(@quiz.quiz_questions) - 1
|
||||
}
|
||||
class="flex justify-between items-baseline w-full h-12 mt-5"
|
||||
>
|
||||
<%= if (@current_quiz_question_idx > 0 && @current_quiz_question_idx <= length(@quiz.quiz_questions) - 1) do %>
|
||||
<button phx-click="prev-question" class="px-3 py-2 text-white font-medium">
|
||||
<%= gettext("Back") %>
|
||||
</button>
|
||||
<% else %>
|
||||
<div class="w-1/2"></div>
|
||||
<% end %>
|
||||
|
||||
<button
|
||||
:if={@current_quiz_question_idx <= length(@quiz.quiz_questions) - 1}
|
||||
phx-click="next-question"
|
||||
class="px-3 py-2 text-white font-medium bg-primary-400 hover:bg-primary-500 rounded-md h-full"
|
||||
>
|
||||
<%= gettext("Next") %>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
def toggle_quiz(js \\ %JS{}) do
|
||||
js
|
||||
|> JS.toggle(
|
||||
out: "animate__animated animate__zoomOut",
|
||||
in: "animate__animated animate__zoomIn",
|
||||
to: "#collapsed-quiz",
|
||||
time: 50
|
||||
)
|
||||
|> JS.toggle(
|
||||
out: "animate__animated animate__zoomOut",
|
||||
in: "animate__animated animate__zoomIn",
|
||||
to: "#extended-quiz"
|
||||
)
|
||||
end
|
||||
|
||||
defp check_current_question(assigns) do
|
||||
if length(assigns.current_quiz_responses) > 0 && not assigns.quiz.show_results do
|
||||
nil
|
||||
else
|
||||
Enum.at(assigns.quiz.quiz_questions, assigns.current_quiz_question_idx)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,7 +2,7 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
alias Claper.Interactions
|
||||
use ClaperWeb, :live_view
|
||||
|
||||
alias Claper.{Posts, Polls, Forms}
|
||||
alias Claper.{Posts, Polls, Forms, Quizzes, Stats}
|
||||
alias ClaperWeb.Presence
|
||||
|
||||
on_mount(ClaperWeb.AttendeeLiveAuth)
|
||||
@@ -63,25 +63,26 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
socket.assigns.attendee_identifier,
|
||||
%{}
|
||||
)
|
||||
|
||||
online = Presence.list("event:#{event.uuid}") |> Enum.count()
|
||||
update_stats(socket, event)
|
||||
maybe_update_audience_peak(event, online)
|
||||
end
|
||||
|
||||
post_changeset = Posts.Post.changeset(%Posts.Post{}, %{})
|
||||
|
||||
online = Presence.list("event:#{event.uuid}") |> Enum.count()
|
||||
|
||||
maybe_update_audience_peak(event, online)
|
||||
|
||||
posts = list_posts(socket, event.uuid)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:attendees_nb, 1)
|
||||
|> assign(:post_changeset, post_changeset)
|
||||
|> assign(:liked_posts, reacted_posts(socket, event.id, "👍"))
|
||||
|> assign(:loved_posts, reacted_posts(socket, event.id, "❤️"))
|
||||
|> assign(:loled_posts, reacted_posts(socket, event.id, "😂"))
|
||||
|> assign(:like_posts, reacted_posts(socket, event.id, "👍"))
|
||||
|> assign(:love_posts, reacted_posts(socket, event.id, "❤️"))
|
||||
|> assign(:lol_posts, reacted_posts(socket, event.id, "😂"))
|
||||
|> assign(:selected_poll_opt, [])
|
||||
|> assign(:poll_opt_saved, false)
|
||||
|> assign(:selected_quiz_question_opts, [])
|
||||
|> assign(:current_quiz_question_idx, 0)
|
||||
|> assign(:event, event)
|
||||
|> assign(:state, event.presentation_file.presentation_state)
|
||||
|> assign(:nickname, "")
|
||||
@@ -316,6 +317,20 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
|> update(:current_embed, fn _current_embed -> nil end)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:quiz_updated, %Claper.Quizzes.Quiz{enabled: true} = quiz}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> load_current_interaction(quiz, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:quiz_deleted, %Claper.Quizzes.Quiz{enabled: true}}, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> update(:current_interaction, fn _current_interaction -> nil end)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:react, type}, socket) do
|
||||
{:noreply,
|
||||
@@ -431,9 +446,14 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
)
|
||||
when is_map(current_user) do
|
||||
case type do
|
||||
"👍" -> {:noreply, add_post_like(socket, post_id, %{icon: type, user_id: current_user.id})}
|
||||
"❤️" -> {:noreply, add_post_love(socket, post_id, %{icon: type, user_id: current_user.id})}
|
||||
"😂" -> {:noreply, add_post_lol(socket, post_id, %{icon: type, user_id: current_user.id})}
|
||||
"👍" ->
|
||||
{:noreply, add_reaction(socket, post_id, %{icon: type, user_id: current_user.id}, :like)}
|
||||
|
||||
"❤️" ->
|
||||
{:noreply, add_reaction(socket, post_id, %{icon: type, user_id: current_user.id}, :love)}
|
||||
|
||||
"😂" ->
|
||||
{:noreply, add_reaction(socket, post_id, %{icon: type, user_id: current_user.id}, :lol)}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -446,15 +466,30 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
case type do
|
||||
"👍" ->
|
||||
{:noreply,
|
||||
add_post_like(socket, post_id, %{icon: type, attendee_identifier: attendee_identifier})}
|
||||
add_reaction(
|
||||
socket,
|
||||
post_id,
|
||||
%{icon: type, attendee_identifier: attendee_identifier},
|
||||
:like
|
||||
)}
|
||||
|
||||
"❤️" ->
|
||||
{:noreply,
|
||||
add_post_love(socket, post_id, %{icon: type, attendee_identifier: attendee_identifier})}
|
||||
add_reaction(
|
||||
socket,
|
||||
post_id,
|
||||
%{icon: type, attendee_identifier: attendee_identifier},
|
||||
:love
|
||||
)}
|
||||
|
||||
"😂" ->
|
||||
{:noreply,
|
||||
add_post_lol(socket, post_id, %{icon: type, attendee_identifier: attendee_identifier})}
|
||||
add_reaction(
|
||||
socket,
|
||||
post_id,
|
||||
%{icon: type, attendee_identifier: attendee_identifier},
|
||||
:lol
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -467,13 +502,16 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
when is_map(current_user) do
|
||||
case type do
|
||||
"👍" ->
|
||||
{:noreply, remove_post_like(socket, post_id, %{icon: type, user_id: current_user.id})}
|
||||
{:noreply,
|
||||
remove_reaction(socket, post_id, %{icon: type, user_id: current_user.id}, :like)}
|
||||
|
||||
"❤️" ->
|
||||
{:noreply, remove_post_love(socket, post_id, %{icon: type, user_id: current_user.id})}
|
||||
{:noreply,
|
||||
remove_reaction(socket, post_id, %{icon: type, user_id: current_user.id}, :love)}
|
||||
|
||||
"😂" ->
|
||||
{:noreply, remove_post_lol(socket, post_id, %{icon: type, user_id: current_user.id})}
|
||||
{:noreply,
|
||||
remove_reaction(socket, post_id, %{icon: type, user_id: current_user.id}, :lol)}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -486,15 +524,30 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
case type do
|
||||
"👍" ->
|
||||
{:noreply,
|
||||
remove_post_like(socket, post_id, %{icon: type, attendee_identifier: attendee_identifier})}
|
||||
remove_reaction(
|
||||
socket,
|
||||
post_id,
|
||||
%{icon: type, attendee_identifier: attendee_identifier},
|
||||
:like
|
||||
)}
|
||||
|
||||
"❤️" ->
|
||||
{:noreply,
|
||||
remove_post_love(socket, post_id, %{icon: type, attendee_identifier: attendee_identifier})}
|
||||
remove_reaction(
|
||||
socket,
|
||||
post_id,
|
||||
%{icon: type, attendee_identifier: attendee_identifier},
|
||||
:love
|
||||
)}
|
||||
|
||||
"😂" ->
|
||||
{:noreply,
|
||||
remove_post_lol(socket, post_id, %{icon: type, attendee_identifier: attendee_identifier})}
|
||||
remove_reaction(
|
||||
socket,
|
||||
post_id,
|
||||
%{icon: type, attendee_identifier: attendee_identifier},
|
||||
:lol
|
||||
)}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -570,6 +623,108 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event(
|
||||
"next-question",
|
||||
_params,
|
||||
%{assigns: %{current_quiz_question_idx: current_quiz_question_idx}} = socket
|
||||
) do
|
||||
{:noreply, socket |> assign(:current_quiz_question_idx, current_quiz_question_idx + 1)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event(
|
||||
"prev-question",
|
||||
_params,
|
||||
%{assigns: %{current_quiz_question_idx: current_quiz_question_idx}} = socket
|
||||
) do
|
||||
{:noreply, socket |> assign(:current_quiz_question_idx, current_quiz_question_idx - 1)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event(
|
||||
"show-quiz-results",
|
||||
_params,
|
||||
socket
|
||||
) do
|
||||
{:noreply, socket |> assign(:current_quiz_question_idx, 0)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event(
|
||||
"select-quiz-question-opt",
|
||||
%{"opt" => opt},
|
||||
socket
|
||||
) do
|
||||
opt = Integer.parse(opt) |> elem(0)
|
||||
|
||||
current_quiz_question =
|
||||
Enum.at(
|
||||
socket.assigns.current_interaction.quiz_questions,
|
||||
socket.assigns.current_quiz_question_idx
|
||||
)
|
||||
|
||||
quiz_question_opt =
|
||||
Enum.find(current_quiz_question.quiz_question_opts, fn x -> x.id == opt end)
|
||||
|
||||
if Enum.member?(socket.assigns.selected_quiz_question_opts, quiz_question_opt) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(
|
||||
:selected_quiz_question_opts,
|
||||
Enum.filter(socket.assigns.selected_quiz_question_opts, fn x ->
|
||||
x.id != quiz_question_opt.id
|
||||
end)
|
||||
)}
|
||||
else
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:selected_quiz_question_opts, [
|
||||
quiz_question_opt | socket.assigns.selected_quiz_question_opts
|
||||
])}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event(
|
||||
"submit-quiz",
|
||||
_params,
|
||||
%{assigns: %{current_user: current_user, selected_quiz_question_opts: opts}} = socket
|
||||
)
|
||||
when is_map(current_user) do
|
||||
case Claper.Quizzes.submit_quiz(
|
||||
current_user.id,
|
||||
opts,
|
||||
socket.assigns.current_interaction.id
|
||||
) do
|
||||
{:ok, quiz} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> load_current_interaction(quiz, true)
|
||||
|> assign(:current_quiz_question_idx, socket.assigns.current_quiz_question_idx + 1)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event(
|
||||
"submit-quiz",
|
||||
_params,
|
||||
%{assigns: %{attendee_identifier: attendee_identifier, selected_quiz_question_opts: opts}} =
|
||||
socket
|
||||
) do
|
||||
case Claper.Quizzes.submit_quiz(
|
||||
attendee_identifier,
|
||||
opts,
|
||||
socket.assigns.current_interaction.id
|
||||
) do
|
||||
{:ok, quiz} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> load_current_interaction(quiz, true)
|
||||
|> assign(:current_quiz_question_idx, socket.assigns.current_quiz_question_idx + 1)}
|
||||
end
|
||||
end
|
||||
|
||||
def toggle_side_menu(js \\ %JS{}) do
|
||||
js
|
||||
|> JS.toggle(
|
||||
@@ -594,51 +749,25 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
)
|
||||
end
|
||||
|
||||
defp add_post_like(socket, post_id, params) do
|
||||
defp add_reaction(socket, post_id, params, type) do
|
||||
with post <- Posts.get_post!(post_id, [:event]),
|
||||
{:ok, _} <- Posts.create_reaction(Map.merge(params, %{post: post})) do
|
||||
{:ok, _} = Posts.update_post(post, %{like_count: post.like_count + 1})
|
||||
update(socket, :liked_posts, fn liked_posts -> [post.id | liked_posts] end)
|
||||
count_field = String.to_atom("#{type}_count")
|
||||
posts_field = String.to_atom("#{type}_posts")
|
||||
|
||||
{:ok, _} = Posts.update_post(post, %{count_field => Map.get(post, count_field) + 1})
|
||||
update(socket, posts_field, fn posts -> [post.id | posts] end)
|
||||
end
|
||||
end
|
||||
|
||||
defp remove_post_like(socket, post_id, params) do
|
||||
defp remove_reaction(socket, post_id, params, type) do
|
||||
with post <- Posts.get_post!(post_id, [:event]),
|
||||
{:ok, _} <- Posts.delete_reaction(Map.merge(params, %{post: post})) do
|
||||
{:ok, _} = Posts.update_post(post, %{like_count: post.like_count - 1})
|
||||
update(socket, :liked_posts, fn liked_posts -> List.delete(liked_posts, post.id) end)
|
||||
end
|
||||
end
|
||||
count_field = String.to_atom("#{type}_count")
|
||||
posts_field = String.to_atom("#{type}_posts")
|
||||
|
||||
defp add_post_love(socket, post_id, params) do
|
||||
with post <- Posts.get_post!(post_id, [:event]),
|
||||
{:ok, _} <- Posts.create_reaction(Map.merge(params, %{post: post})) do
|
||||
{:ok, _} = Posts.update_post(post, %{love_count: post.love_count + 1})
|
||||
update(socket, :loved_posts, fn loved_posts -> [post.id | loved_posts] end)
|
||||
end
|
||||
end
|
||||
|
||||
defp remove_post_love(socket, post_id, params) do
|
||||
with post <- Posts.get_post!(post_id, [:event]),
|
||||
{:ok, _} <- Posts.delete_reaction(Map.merge(params, %{post: post})) do
|
||||
{:ok, _} = Posts.update_post(post, %{love_count: post.love_count - 1})
|
||||
update(socket, :loved_posts, fn loved_posts -> List.delete(loved_posts, post.id) end)
|
||||
end
|
||||
end
|
||||
|
||||
defp add_post_lol(socket, post_id, params) do
|
||||
with post <- Posts.get_post!(post_id, [:event]),
|
||||
{:ok, _} <- Posts.create_reaction(Map.merge(params, %{post: post})) do
|
||||
{:ok, _} = Posts.update_post(post, %{lol_count: post.lol_count + 1})
|
||||
update(socket, :loled_posts, fn loled_posts -> [post.id | loled_posts] end)
|
||||
end
|
||||
end
|
||||
|
||||
defp remove_post_lol(socket, post_id, params) do
|
||||
with post <- Posts.get_post!(post_id, [:event]),
|
||||
{:ok, _} <- Posts.delete_reaction(Map.merge(params, %{post: post})) do
|
||||
{:ok, _} = Posts.update_post(post, %{lol_count: post.lol_count - 1})
|
||||
update(socket, :loled_posts, fn loled_posts -> List.delete(loled_posts, post.id) end)
|
||||
{:ok, _} = Posts.update_post(post, %{count_field => Map.get(post, count_field) - 1})
|
||||
update(socket, posts_field, fn posts -> List.delete(posts, post.id) end)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -671,6 +800,26 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
socket |> assign(:current_form_submit, fs)
|
||||
end
|
||||
|
||||
defp get_current_quiz_reponses(%{assigns: %{current_user: current_user}} = socket, quiz_id)
|
||||
when is_map(current_user) do
|
||||
responses = Quizzes.get_quiz_responses(current_user.id, quiz_id)
|
||||
|
||||
socket
|
||||
|> assign(:current_quiz_responses, responses)
|
||||
|> assign(:quiz_score, Quizzes.calculate_user_score(current_user.id, quiz_id))
|
||||
end
|
||||
|
||||
defp get_current_quiz_reponses(
|
||||
%{assigns: %{attendee_identifier: attendee_identifier}} = socket,
|
||||
quiz_id
|
||||
) do
|
||||
responses = Quizzes.get_quiz_responses(attendee_identifier, quiz_id)
|
||||
|
||||
socket
|
||||
|> assign(:current_quiz_responses, responses)
|
||||
|> assign(:quiz_score, Quizzes.calculate_user_score(attendee_identifier, quiz_id))
|
||||
end
|
||||
|
||||
defp reacted_posts(
|
||||
%{assigns: %{current_user: current_user} = _assigns} = _socket,
|
||||
event_id,
|
||||
@@ -717,6 +866,22 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
socket |> assign(:current_interaction, interaction) |> get_current_form_submit(interaction.id)
|
||||
end
|
||||
|
||||
defp load_current_interaction(socket, %Quizzes.Quiz{} = interaction, _same_interaction) do
|
||||
quiz = Quizzes.set_percentages(interaction)
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:current_interaction, quiz)
|
||||
|> get_current_quiz_reponses(interaction.id)
|
||||
|
||||
if length(socket.assigns.current_quiz_responses) > 0 do
|
||||
socket
|
||||
|> assign(:current_quiz_question_idx, length(interaction.quiz_questions))
|
||||
else
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
defp load_current_interaction(socket, interaction, _same_interaction) do
|
||||
socket |> assign(:current_interaction, interaction)
|
||||
end
|
||||
@@ -728,4 +893,16 @@ defmodule ClaperWeb.EventLive.Show do
|
||||
defp maybe_reset_selected_poll_opt(socket, _same_interaction) do
|
||||
socket |> assign(:selected_poll_opt, [])
|
||||
end
|
||||
|
||||
defp update_stats(%{assigns: %{current_user: current_user}}, event) when is_map(current_user) do
|
||||
Stats.create_stat(event, %{
|
||||
user_id: current_user.id
|
||||
})
|
||||
end
|
||||
|
||||
defp update_stats(%{assigns: %{attendee_identifier: attendee_identifier}}, event) do
|
||||
Stats.create_stat(event, %{
|
||||
attendee_identifier: attendee_identifier
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -111,6 +111,26 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<% %Claper.Quizzes.Quiz{} -> %>
|
||||
<div
|
||||
id="quiz-wrapper-parent"
|
||||
class="animate__animated animate__zoomInDown w-full lg:w-1/3 lg:mx-auto fixed top-16 z-10 px-2 pb-6 lg:px-7 max-h-screen overflow-y-auto"
|
||||
>
|
||||
<div class="transition-all" id="quiz-wrapper">
|
||||
<.live_component
|
||||
module={ClaperWeb.EventLive.QuizComponent}
|
||||
id={"#{@current_interaction.id}-quiz"}
|
||||
quiz={@current_interaction}
|
||||
current_user={@current_user}
|
||||
attendee_identifier={@attendee_identifier}
|
||||
event={@event}
|
||||
selected_quiz_question_opts={@selected_quiz_question_opts}
|
||||
current_quiz_question_idx={@current_quiz_question_idx}
|
||||
current_quiz_responses={@current_quiz_responses}
|
||||
quiz_score={@quiz_score}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<% _ -> %>
|
||||
<!-- Handle any other types of interactions here if needed -->
|
||||
<% end %>
|
||||
@@ -134,9 +154,9 @@
|
||||
attendee_identifier={@attendee_identifier}
|
||||
event={@event}
|
||||
reaction_enabled={@state.message_reaction_enabled}
|
||||
liked_posts={@liked_posts}
|
||||
loved_posts={@loved_posts}
|
||||
loled_posts={@loled_posts}
|
||||
liked_posts={@like_posts}
|
||||
loved_posts={@love_posts}
|
||||
loled_posts={@lol_posts}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
137
lib/claper_web/live/quiz_live/quiz_component.ex
Normal file
137
lib/claper_web/live/quiz_live/quiz_component.ex
Normal file
@@ -0,0 +1,137 @@
|
||||
defmodule ClaperWeb.QuizLive.QuizComponent do
|
||||
use ClaperWeb, :live_component
|
||||
|
||||
alias Claper.Quizzes
|
||||
|
||||
@impl true
|
||||
def update(%{quiz: quiz} = assigns, socket) do
|
||||
changeset = Quizzes.change_quiz(quiz)
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:dark, fn -> false end)
|
||||
|> assign(:changeset, changeset)
|
||||
|> assign(:current_quiz_question_index, 0)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
quiz = Quizzes.get_quiz!(id, [:quiz_questions, quiz_questions: :quiz_question_opts])
|
||||
{:ok, _} = Quizzes.delete_quiz(socket.assigns.event.uuid, quiz)
|
||||
|
||||
{:noreply, socket |> push_navigate(to: socket.assigns.return_to)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"quiz" => quiz_params}, socket) do
|
||||
changeset =
|
||||
socket.assigns.quiz
|
||||
|> Quizzes.change_quiz(quiz_params)
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
{:noreply, socket |> assign(:changeset, changeset)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save", %{"quiz" => quiz_params}, socket) do
|
||||
save_quiz(socket, socket.assigns.live_action, quiz_params)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event(
|
||||
"add_quiz_question",
|
||||
_params,
|
||||
%{
|
||||
assigns: %{
|
||||
changeset: changeset
|
||||
}
|
||||
} = socket
|
||||
) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:changeset, changeset |> Quizzes.add_quiz_question())
|
||||
|> assign(
|
||||
:current_quiz_question_index,
|
||||
length(Ecto.Changeset.get_field(changeset, :quiz_questions))
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event(
|
||||
"remove_quiz_question",
|
||||
_params,
|
||||
%{assigns: %{current_quiz_question_index: current_quiz_question_index}} = socket
|
||||
) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:current_quiz_question_index, max(0, current_quiz_question_index - 1))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event(
|
||||
"add_quiz_question_opt",
|
||||
%{"question_index" => index},
|
||||
%{assigns: %{changeset: changeset}} = socket
|
||||
) do
|
||||
index = String.to_integer(index)
|
||||
{:noreply, assign(socket, :changeset, changeset |> Quizzes.add_quiz_question_opt(index))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("set_current_quiz_question_index", %{"index" => index}, socket) do
|
||||
index = String.to_integer(index)
|
||||
{:noreply, assign(socket, :current_quiz_question_index, index)}
|
||||
end
|
||||
|
||||
defp save_quiz(socket, :edit, quiz_params) do
|
||||
case Quizzes.update_quiz(
|
||||
socket.assigns.event.uuid,
|
||||
socket.assigns.quiz,
|
||||
quiz_params
|
||||
) do
|
||||
{:ok, _quiz} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> push_navigate(to: socket.assigns.return_to)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, :changeset, changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
defp save_quiz(socket, :new, quiz_params) do
|
||||
lti_resource = socket.assigns.event.lti_resource
|
||||
|
||||
case Quizzes.create_quiz(
|
||||
quiz_params
|
||||
|> Map.put("presentation_file_id", socket.assigns.presentation_file.id)
|
||||
|> Map.put("lti_resource_id", lti_resource.id)
|
||||
|> Map.put("position", socket.assigns.position)
|
||||
|> Map.put("enabled", false)
|
||||
) do
|
||||
{:ok, quiz} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> maybe_change_current_quiz(quiz)
|
||||
|> push_navigate(to: socket.assigns.return_to)}
|
||||
|
||||
{:error, %Ecto.Changeset{} = changeset} ->
|
||||
{:noreply, assign(socket, changeset: changeset)}
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_change_current_quiz(socket, %{enabled: true} = quiz) do
|
||||
quiz = Quizzes.get_quiz!(quiz.id, [:quiz_questions, quiz_questions: :quiz_question_opts])
|
||||
|
||||
Phoenix.PubSub.broadcast(
|
||||
Claper.PubSub,
|
||||
"event:#{socket.assigns.event.uuid}",
|
||||
{:current_quiz, quiz}
|
||||
)
|
||||
|
||||
socket
|
||||
end
|
||||
|
||||
defp maybe_change_current_quiz(socket, _), do: socket
|
||||
end
|
||||
211
lib/claper_web/live/quiz_live/quiz_component.html.heex
Normal file
211
lib/claper_web/live/quiz_live/quiz_component.html.heex
Normal file
@@ -0,0 +1,211 @@
|
||||
<div>
|
||||
<.form
|
||||
:let={f}
|
||||
for={@changeset}
|
||||
id="form-quiz"
|
||||
phx-target={@myself}
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
>
|
||||
<div class="my-3 mb-5">
|
||||
<ClaperWeb.Component.Input.text
|
||||
form={f}
|
||||
key={:title}
|
||||
name={gettext("Title")}
|
||||
labelClass={if @dark, do: "text-white"}
|
||||
fieldClass={if @dark, do: "bg-gray-700 text-white"}
|
||||
autofocus="true"
|
||||
required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="w-full bg-gray-300 text-gray-700 h-9 text-sm flex items-center rounded-t-md">
|
||||
<%= for i <- 0..(Ecto.Changeset.get_field(@changeset, :quiz_questions) |> length()) - 1 do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="set_current_quiz_question_index"
|
||||
phx-value-index={i}
|
||||
phx-target={@myself}
|
||||
class={[
|
||||
"px-3 py-1 h-full",
|
||||
if(@current_quiz_question_index == i, do: "bg-white text-gray-800"),
|
||||
if(i == 0, do: "rounded-tl-md")
|
||||
]}
|
||||
>
|
||||
<%= i %>
|
||||
</button>
|
||||
<% end %>
|
||||
<%= if Ecto.Changeset.get_field(@changeset, :quiz_questions) |> length() <= 10 do %>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="add_quiz_question"
|
||||
class="text-xs px-3"
|
||||
phx-target={@myself}
|
||||
>
|
||||
+ <%= gettext("Add Question") %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= inputs_for f, :quiz_questions, fn q -> %>
|
||||
<div class={[
|
||||
"mb-8 p-4 border rounded-b-md",
|
||||
if(@current_quiz_question_index != q.index, do: "hidden", else: "")
|
||||
]}>
|
||||
<div class="flex gap-x-3 mt-3 items-center justify-start">
|
||||
<ClaperWeb.Component.Input.text
|
||||
form={q}
|
||||
labelClass={if @dark, do: "text-white"}
|
||||
fieldClass={if @dark, do: "bg-gray-700 text-white"}
|
||||
key={:content}
|
||||
name={gettext("Your question")}
|
||||
autofocus="true"
|
||||
required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%= if Keyword.has_key?(q.errors, :quiz_question_opts) do %>
|
||||
<p class="text-supporting-red-500 text-sm my-2">
|
||||
<%= elem(Keyword.get(q.errors, :quiz_question_opts), 0) %>
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<div class="mt-5">
|
||||
<%= inputs_for q, :quiz_question_opts, fn o -> %>
|
||||
<div class="mt-2" id={"answer-#{o.index}"}>
|
||||
<div class="flex items-center gap-x-2">
|
||||
<div class="flex-1">
|
||||
<ClaperWeb.Component.Input.text
|
||||
form={o}
|
||||
labelClass={if @dark, do: "text-white"}
|
||||
fieldClass={if @dark, do: "bg-gray-700 text-white"}
|
||||
key={:content}
|
||||
name={gettext("Answer %{index}", index: o.index + 1)}
|
||||
required="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<%= label(class: "mt-6 cursor-pointer flex items-center text-white rounded-md px-2.5 py-2.5 h-full #{if (o.source.changes[:is_correct] != nil && o.source.changes[:is_correct]) || (!Map.has_key?(o.source.changes, :is_correct) && o.source.data.is_correct), do: "bg-green-500", else: "bg-red-500"}") do %>
|
||||
<%= checkbox(o, :is_correct, class: "hidden") %>
|
||||
<%= if (o.source.changes[:is_correct] != nil && o.source.changes[:is_correct]) || (!Map.has_key?(o.source.changes, :is_correct) && o.source.data.is_correct) do %>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="w-6 h-6"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="m4.5 12.75 6 6 9-13.5"
|
||||
/>
|
||||
</svg>
|
||||
<% else %>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="w-6 h-6"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6 18 18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= if o.index > 1 do %>
|
||||
<label>
|
||||
<div class="cursor-pointer mt-1 text-xs font-semibold text-red-600">
|
||||
<%= gettext("Delete") %>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={
|
||||
"quiz[quiz_questions][#{q.index}][quiz_question_opts_delete][]"
|
||||
}
|
||||
value={o.index}
|
||||
class="hidden"
|
||||
/>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
phx-click="add_quiz_question_opt"
|
||||
phx-value-question_index={q.index}
|
||||
phx-target={@myself}
|
||||
class="mt-5 text-xs text-gray-700"
|
||||
>
|
||||
+ <%= gettext("Add answer") %>
|
||||
</button>
|
||||
|
||||
<%= if Ecto.Changeset.get_field(@changeset, :quiz_questions) |> length() > 1 do %>
|
||||
<label phx-click="remove_quiz_question" phx-target={@myself}>
|
||||
<div class="cursor-pointer mt-4 w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500">
|
||||
<%= gettext("Remove question") %>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="quiz[quiz_questions_delete][]"
|
||||
value={q.index}
|
||||
class="hidden"
|
||||
/>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex space-x-3">
|
||||
<button
|
||||
type="submit"
|
||||
phx_disable_with="Loading..."
|
||||
disabled={!@changeset.valid?}
|
||||
class="w-full lg:w-auto px-6 text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<%= case @live_action do
|
||||
:new -> gettext("Create")
|
||||
:edit -> gettext("Save")
|
||||
end %>
|
||||
</button>
|
||||
|
||||
<%= if @live_action == :edit do %>
|
||||
<%= link(gettext("Delete"),
|
||||
to: "#",
|
||||
phx_click: "delete",
|
||||
phx_target: @myself,
|
||||
phx_value_id: @quiz.id,
|
||||
data: [
|
||||
confirm:
|
||||
gettext(
|
||||
"This will delete all responses associated and the quiz itself, are you sure?"
|
||||
)
|
||||
],
|
||||
class:
|
||||
"w-full lg:w-auto px-6 text-center text-white py-2 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline bg-gradient-to-tl from-supporting-red-600 to-supporting-red-400 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
|
||||
) %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if @live_action == :edit do %>
|
||||
<%= link to: ~p"/export/quizzes/#{@quiz.id}/qti", class: "text-xs text-primary-500 font-medium flex items-center gap-1", method: :post, target: "_blank" do %>
|
||||
<%= gettext("Export to QTI (XML)") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
@@ -13,10 +13,24 @@ defmodule ClaperWeb.StatLive.Index do
|
||||
|
||||
event =
|
||||
Events.get_managed_event!(socket.assigns.current_user, id,
|
||||
presentation_file: [polls: [:poll_opts], forms: [:form_submits], embeds: []]
|
||||
presentation_file: [
|
||||
polls: [:poll_opts],
|
||||
forms: [:form_submits],
|
||||
embeds: [],
|
||||
quizzes: [:quiz_questions, quiz_questions: :quiz_question_opts]
|
||||
]
|
||||
)
|
||||
|
||||
grouped_total_votes = Claper.Stats.total_vote_count(event.presentation_file.id)
|
||||
# Calculate percentages for each quiz
|
||||
event = %{
|
||||
event
|
||||
| presentation_file: %{
|
||||
event.presentation_file
|
||||
| quizzes: Enum.map(event.presentation_file.quizzes, &Claper.Quizzes.set_percentages/1)
|
||||
}
|
||||
}
|
||||
|
||||
distinct_attendee_count = Claper.Stats.get_unique_attendees_for_event(event.id)
|
||||
distinct_poster_count = Claper.Stats.distinct_poster_count(event.id)
|
||||
|
||||
{:ok,
|
||||
@@ -27,15 +41,15 @@ defmodule ClaperWeb.StatLive.Index do
|
||||
distinct_poster_count
|
||||
)
|
||||
|> assign(
|
||||
:grouped_total_votes,
|
||||
grouped_total_votes
|
||||
:distinct_attendee_count,
|
||||
distinct_attendee_count
|
||||
)
|
||||
|> assign(:average_voters, average_voters(grouped_total_votes))
|
||||
|> assign(
|
||||
:engagement_rate,
|
||||
calculate_engagement_rate(grouped_total_votes, distinct_poster_count, event)
|
||||
calculate_engagement_rate(event, distinct_attendee_count)
|
||||
)
|
||||
|> assign(:posts, list_posts(socket, event.uuid))}
|
||||
|> assign(:posts, list_posts(socket, event.uuid))
|
||||
|> assign(:current_tab, :messages)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
@@ -45,37 +59,57 @@ defmodule ClaperWeb.StatLive.Index do
|
||||
|
||||
defp apply_action(socket, :index, _params) do
|
||||
socket
|
||||
|> assign(:page_title, "Report")
|
||||
|> assign(:page_title, gettext("Report"))
|
||||
end
|
||||
|
||||
defp calculate_engagement_rate(grouped_total_votes, distinct_poster_count, event) do
|
||||
total_polls = Enum.count(grouped_total_votes)
|
||||
|
||||
if total_polls == 0 do
|
||||
(distinct_poster_count / event.audience_peak * 100)
|
||||
|> Float.round()
|
||||
|> :erlang.float_to_binary(decimals: 0)
|
||||
|> :erlang.binary_to_integer()
|
||||
else
|
||||
((distinct_poster_count / event.audience_peak +
|
||||
average_voters(grouped_total_votes) / event.audience_peak) / 2 * 100)
|
||||
|> Float.round()
|
||||
|> :erlang.float_to_binary(decimals: 0)
|
||||
|> :erlang.binary_to_integer()
|
||||
end
|
||||
@impl true
|
||||
def handle_event("change_tab", %{"tab" => tab}, socket) do
|
||||
{:noreply, assign(socket, :current_tab, String.to_existing_atom(tab))}
|
||||
end
|
||||
|
||||
defp average_voters(grouped_total_votes) do
|
||||
total_polls = Enum.count(grouped_total_votes)
|
||||
defp calculate_engagement_rate(event, unique_attendees) do
|
||||
total =
|
||||
average_messages(event, unique_attendees) + average_polls(event, unique_attendees) +
|
||||
average_quizzes(event, unique_attendees) + average_forms(event, unique_attendees)
|
||||
|
||||
if total_polls == 0 do
|
||||
0
|
||||
else
|
||||
(Enum.sum(grouped_total_votes) / total_polls)
|
||||
|> Float.round()
|
||||
|> :erlang.float_to_binary(decimals: 0)
|
||||
|> :erlang.binary_to_integer()
|
||||
end
|
||||
(total / 4 * 100)
|
||||
|> Float.round()
|
||||
|> :erlang.float_to_binary(decimals: 0)
|
||||
|> :erlang.binary_to_integer()
|
||||
end
|
||||
|
||||
defp average_messages(_event, 0), do: 0
|
||||
|
||||
defp average_messages(event, unique_attendees) do
|
||||
distinct_poster_count = Claper.Stats.distinct_poster_count(event.id)
|
||||
distinct_poster_count / unique_attendees
|
||||
end
|
||||
|
||||
defp average_polls(_event, 0), do: 0
|
||||
|
||||
defp average_polls(event, unique_attendees) do
|
||||
poll_ids = Claper.Polls.list_polls(event.presentation_file.id) |> Enum.map(& &1.id)
|
||||
distinct_votes = Claper.Stats.get_distinct_poll_votes(poll_ids)
|
||||
|
||||
distinct_votes / (Enum.count(poll_ids) * unique_attendees)
|
||||
end
|
||||
|
||||
defp average_quizzes(_event, 0), do: 0
|
||||
|
||||
defp average_quizzes(event, unique_attendees) do
|
||||
quiz_ids = Claper.Quizzes.list_quizzes(event.presentation_file.id) |> Enum.map(& &1.id)
|
||||
distinct_votes = Claper.Stats.get_distinct_quiz_responses(quiz_ids)
|
||||
|
||||
distinct_votes / (Enum.count(quiz_ids) * unique_attendees)
|
||||
end
|
||||
|
||||
defp average_forms(_event, 0), do: 0
|
||||
|
||||
defp average_forms(event, unique_attendees) do
|
||||
form_ids = Claper.Forms.list_forms(event.presentation_file.id) |> Enum.map(& &1.id)
|
||||
distinct_submits = Claper.Stats.get_distinct_form_submits(form_ids)
|
||||
|
||||
distinct_submits / (Enum.count(form_ids) * unique_attendees)
|
||||
end
|
||||
|
||||
defp list_posts(_socket, event_id) do
|
||||
|
||||
@@ -21,16 +21,16 @@
|
||||
<div class="absolute bg-primary-500 rounded-md p-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="h-6 w-6 text-white"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
d="M2.25 18 9 11.25l4.306 4.306a11.95 11.95 0 0 1 5.814-5.518l2.74-1.22m0 0-5.94-2.281m5.94 2.28-2.28 5.941"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -51,7 +51,38 @@
|
||||
<div class="relative bg-white pt-5 px-4 sm:pt-6 sm:px-6 pb-4 shadow rounded-lg overflow-hidden">
|
||||
<dt>
|
||||
<div class="absolute bg-primary-500 rounded-md p-3">
|
||||
<!-- Heroicon name: outline/users -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="h-6 w-6 text-white"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="ml-16 text-sm font-medium text-gray-500 truncate">
|
||||
<%= gettext("Unique attendees") %>
|
||||
</p>
|
||||
</dt>
|
||||
<dd class="ml-16 flex items-baseline">
|
||||
<p class="text-2xl font-semibold text-gray-900">
|
||||
<%= @distinct_attendee_count %>
|
||||
<span class="text-xs text-gray-500 font-normal">
|
||||
<%= ngettext("attendee", "attendees", @distinct_attendee_count) %>
|
||||
</span>
|
||||
</p>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="relative bg-white pt-5 px-4 sm:pt-6 sm:px-6 pb-4 shadow rounded-lg overflow-hidden">
|
||||
<dt>
|
||||
<div class="absolute bg-primary-500 rounded-md p-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-white"
|
||||
@@ -88,44 +119,6 @@
|
||||
<div class="relative bg-white pt-5 px-4 sm:pt-6 sm:px-6 pb-4 shadow rounded-lg overflow-hidden">
|
||||
<dt>
|
||||
<div class="absolute bg-primary-500 rounded-md p-3">
|
||||
<!-- Heroicon name: outline/mail-open -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16 8v8m-4-5v5m-4-2v2m-2 4h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="ml-16 text-sm font-medium text-gray-500 truncate">
|
||||
<%= gettext("Average voters") %>
|
||||
</p>
|
||||
</dt>
|
||||
<dd class="ml-16 flex items-baseline">
|
||||
<p class="text-2xl font-semibold text-gray-900">
|
||||
<%= @average_voters %>
|
||||
<span class="text-xs text-gray-500 font-normal">
|
||||
<%= ngettext(
|
||||
"from %{count} poll",
|
||||
"from %{count} polls",
|
||||
length(@event.presentation_file.polls)
|
||||
) %>
|
||||
</span>
|
||||
</p>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div class="relative bg-white pt-5 px-4 sm:pt-6 sm:px-6 pb-4 shadow rounded-lg overflow-hidden">
|
||||
<dt>
|
||||
<div class="absolute bg-primary-500 rounded-md p-3">
|
||||
<!-- Heroicon name: outline/cursor-click -->
|
||||
<svg
|
||||
class="h-6 w-6 text-white"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -142,12 +135,31 @@
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p class="ml-16 text-sm font-medium text-gray-500 truncate">
|
||||
<p class="ml-16 text-sm font-medium truncate text-gray-500">
|
||||
<%= gettext("Engagement rate") %>
|
||||
</p>
|
||||
</dt>
|
||||
<dd class="ml-16 flex items-baseline">
|
||||
<dd class="ml-16 flex items-baseline flex items-center">
|
||||
<p class="text-2xl font-semibold text-gray-900"><%= @engagement_rate %>%</p>
|
||||
<a
|
||||
href="https://docs.claper.co/usage/reports.html#metrics"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ml-1"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
class="text-gray-400 w-4 h-4"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M15 8A7 7 0 1 1 1 8a7 7 0 0 1 14 0Zm-6 3.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM7.293 5.293a1 1 0 1 1 .99 1.667c-.459.134-1.033.566-1.033 1.29v.25a.75.75 0 1 0 1.5 0v-.115a2.5 2.5 0 1 0-2.518-4.153.75.75 0 1 0 1.061 1.06Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -155,137 +167,309 @@
|
||||
|
||||
<div class="pt-5 pb-5">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
|
||||
<%= gettext("Interactions history") %>
|
||||
<%= gettext("Interactions") %>
|
||||
</h3>
|
||||
<%= for position <- 0..max(0, @event.presentation_file.length-1) do %>
|
||||
<div class="my-10">
|
||||
<%= if @event.presentation_file.length > 0 do %>
|
||||
<%= if Application.get_env(:claper, :presentations) |> Keyword.get(:storage) == "local" do %>
|
||||
<img
|
||||
class="w-1/3 mx-auto"
|
||||
src={"/uploads/#{@event.presentation_file.hash}/#{position+1}.jpg"}
|
||||
/>
|
||||
<% else %>
|
||||
<img
|
||||
class="w-1/2 md:w-1/3 mb-4"
|
||||
src={"https://#{Application.get_env(:claper, :presentations) |> Keyword.get(:aws_bucket)}.s3.#{Application.get_env(:ex_aws, :region)}.amazonaws.com/presentations/#{@event.presentation_file.hash}/#{position+1}.jpg"}
|
||||
/>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= for poll <- Enum.filter(@event.presentation_file.polls, fn p -> p.position == position end) do %>
|
||||
<% total = Enum.map(poll.poll_opts, fn e -> e.vote_count end) |> Enum.sum() %>
|
||||
<div class="bg-black w-full py-3 px-6 my-5 text-black shadow-lg rounded-md">
|
||||
<div class="block w-full h-full cursor-pointer">
|
||||
<p class="text-white text-lg font-semibold mb-4"><%= poll.title %></p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex flex-col space-y-3">
|
||||
<%= if (length poll.poll_opts) > 0 do %>
|
||||
<%= for {opt, idx} <- Enum.with_index(poll.poll_opts) do %>
|
||||
<% percentage =
|
||||
if total > 0,
|
||||
do:
|
||||
Float.round(opt.vote_count / total * 100)
|
||||
|> :erlang.float_to_binary(decimals: 0),
|
||||
else: 0 %>
|
||||
<button
|
||||
id={"poll-opt-#{idx}"}
|
||||
class="bg-gray-500 px-3 py-2 rounded-3xl flex justify-between items-center relative text-white"
|
||||
>
|
||||
<div
|
||||
style={"width: #{percentage}%;"}
|
||||
class={"bg-gradient-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-3xl #{if percentage == "100", do: "rounded-r-3xl"}"}
|
||||
<div class="border-b border-gray-200">
|
||||
<nav class="-mb-px flex space-x-8" aria-label="Tabs">
|
||||
<button
|
||||
phx-click="change_tab"
|
||||
phx-value-tab="messages"
|
||||
class={"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm #{if @current_tab == :messages, do: 'border-primary-500 text-primary-600', else: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}"}
|
||||
>
|
||||
<%= gettext("Messages") %>
|
||||
</button>
|
||||
<button
|
||||
phx-click="change_tab"
|
||||
phx-value-tab="polls"
|
||||
class={"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm #{if @current_tab == :polls, do: 'border-primary-500 text-primary-600', else: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}"}
|
||||
>
|
||||
<%= gettext("Polls") %>
|
||||
</button>
|
||||
<button
|
||||
phx-click="change_tab"
|
||||
phx-value-tab="forms"
|
||||
class={"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm #{if @current_tab == :forms, do: 'border-primary-500 text-primary-600', else: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}"}
|
||||
>
|
||||
<%= gettext("Forms") %>
|
||||
</button>
|
||||
<button
|
||||
phx-click="change_tab"
|
||||
phx-value-tab="web_content"
|
||||
class={"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm #{if @current_tab == :web_content, do: 'border-primary-500 text-primary-600', else: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}"}
|
||||
>
|
||||
<%= gettext("Web Content") %>
|
||||
</button>
|
||||
<button
|
||||
phx-click="change_tab"
|
||||
phx-value-tab="quizzes"
|
||||
class={"whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm #{if @current_tab == :quizzes, do: 'border-primary-500 text-primary-600', else: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}"}
|
||||
>
|
||||
<%= gettext("Quizzes") %>
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="my-4">
|
||||
<%= case @current_tab do %>
|
||||
<% :polls -> %>
|
||||
<div>
|
||||
<%= if length(@event.presentation_file.polls) > 0 do %>
|
||||
<%= for poll <- @event.presentation_file.polls do %>
|
||||
<% total = Enum.map(poll.poll_opts, fn e -> e.vote_count end) |> Enum.sum() %>
|
||||
<div class="bg-gray-900 w-full p-6 my-5 text-black shadow-lg rounded-md">
|
||||
<div class="w-full h-full flex items-center justify-between mb-4">
|
||||
<p class="text-white text-xl font-semibold"><%= poll.title %></p>
|
||||
<%= link to: ~p"/export/polls/#{poll.id}", class: "text-sm text-white bg-primary-500 hover:bg-primary-600 rounded-md px-3 py-1 flex items-center gap-1", method: :post, target: "_blank" do %>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
</div>
|
||||
<div class="flex space-x-3 items-center z-10 text-left">
|
||||
<span class="flex-1 pr-2"><%= opt.content %></span>
|
||||
</div>
|
||||
<span class="text-sm z-10"><%= percentage %>% (<%= opt.vote_count %>)</span>
|
||||
</button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% forms = Enum.filter(@event.presentation_file.forms, fn f -> f.position == position end) %>
|
||||
|
||||
<%= for form <- forms do %>
|
||||
<span class="text-xl font-semibold text-gray-900 mb-4">
|
||||
<%= gettext("Form") %>: <%= form.title %>
|
||||
</span>
|
||||
|
||||
<%= if length(form.form_submits) > 0 do %>
|
||||
<%= link to: ~p"/export/#{form.id}", class: "text-xs text-white bg-primary-500 rounded-md px-2 py-0.5", method: :post do %>
|
||||
<%= gettext("Export all submissions") %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= if length(form.form_submits) == 0 do %>
|
||||
<p class="italic text-gray-500"><%= gettext("No form submission has been sent") %></p>
|
||||
<% end %>
|
||||
|
||||
<%= for fs <- form.form_submits do %>
|
||||
<div id={"#{fs.id}-form"}>
|
||||
<div class="px-4 pb-2 pt-3 rounded-b-lg rounded-tr-lg bg-white relative shadow-md text-black break-all mt-4">
|
||||
<div class="flex space-x-3 items-center">
|
||||
<%= if fs.attendee_identifier do %>
|
||||
<img
|
||||
class="h-8 w-8"
|
||||
src={"https://api.dicebear.com/7.x/personas/svg?seed=#{fs.attendee_identifier}.svg"}
|
||||
/>
|
||||
<% else %>
|
||||
<img
|
||||
class="h-8 w-8"
|
||||
src={"https://api.dicebear.com/7.x/personas/svg?seed=#{fs.user_id}.svg"}
|
||||
/>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<%= for res <- fs.response do %>
|
||||
<p><strong><%= elem(res, 0) %>:</strong> <%= elem(res, 1) %></p>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M14 3v4a1 1 0 0 0 1 1h4" /><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z" /><path d="M9 15h6" /><path d="M12.5 17.5l2.5 -2.5l-2.5 -2.5" />
|
||||
</svg>
|
||||
<span><%= gettext("Export") %> (CSV)</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex flex-col space-y-3">
|
||||
<%= if (length poll.poll_opts) > 0 do %>
|
||||
<%= for {opt, idx} <- Enum.with_index(poll.poll_opts) do %>
|
||||
<% percentage =
|
||||
if total > 0,
|
||||
do:
|
||||
Float.round(opt.vote_count / total * 100)
|
||||
|> :erlang.float_to_binary(decimals: 0),
|
||||
else: 0 %>
|
||||
<div
|
||||
id={"poll-opt-#{idx}"}
|
||||
class="bg-gray-500 px-3 py-2 rounded-lg flex justify-between items-center relative text-white"
|
||||
>
|
||||
<div
|
||||
style={"width: #{percentage}%;"}
|
||||
class={"bg-gradient-to-r from-primary-500 to-secondary-500 h-full absolute left-0 transition-all rounded-l-lg #{if percentage == "100", do: "rounded-r-lg"}"}
|
||||
>
|
||||
</div>
|
||||
<div class="flex space-x-3 items-center z-10 text-left">
|
||||
<span class="flex-1 pr-2"><%= opt.content %></span>
|
||||
</div>
|
||||
<span class="text-sm z-10">
|
||||
<%= percentage %>% (<%= opt.vote_count %>)
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<p class="italic text-gray-500"><%= gettext("No poll has been created") %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% :forms -> %>
|
||||
<div>
|
||||
<%= if length(@event.presentation_file.forms) > 0 do %>
|
||||
<%= for form <- @event.presentation_file.forms do %>
|
||||
<div class="flex justify-between items-center mb-5">
|
||||
<span class="text-xl font-semibold text-gray-900">
|
||||
<%= form.title %>
|
||||
</span>
|
||||
|
||||
<%= for embed <- Enum.filter(@event.presentation_file.embeds , fn e -> e.position == position end) do %>
|
||||
<span class="text-xl font-semibold text-gray-900 mb-4">
|
||||
<%= gettext("Web content") %>: <%= embed.title %>
|
||||
</span>
|
||||
<div class="px-4 pb-2 pt-3 rounded-b-lg rounded-tr-lg bg-white relative shadow-md text-black break-all mt-4">
|
||||
<div class="flex space-x-3 items-center">
|
||||
<%= raw(embed.content) %>
|
||||
<%= if length(form.form_submits) > 0 do %>
|
||||
<%= link to: ~p"/export/forms/#{form.id}", class: "text-sm text-white bg-primary-500 hover:bg-primary-600 rounded-md px-3 py-1 flex items-center gap-1", method: :post, target: "_blank" do %>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M14 3v4a1 1 0 0 0 1 1h4" /><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z" /><path d="M9 15h6" /><path d="M12.5 17.5l2.5 -2.5l-2.5 -2.5" />
|
||||
</svg>
|
||||
<span><%= gettext("Export") %> (CSV)</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= if length(form.form_submits) == 0 do %>
|
||||
<p class="italic text-gray-500">
|
||||
<%= gettext("No form submission has been sent") %>
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<div class="mb-10">
|
||||
<%= for fs <- form.form_submits do %>
|
||||
<div id={"#{fs.id}-form"}>
|
||||
<div class="px-4 pb-2 pt-3 rounded-b-lg rounded-tr-lg bg-white relative shadow-md text-black break-all mt-4">
|
||||
<div class="flex space-x-3 items-center">
|
||||
<%= if fs.attendee_identifier do %>
|
||||
<img
|
||||
class="h-8 w-8"
|
||||
src={"https://api.dicebear.com/7.x/personas/svg?seed=#{fs.attendee_identifier}.svg"}
|
||||
/>
|
||||
<% else %>
|
||||
<img
|
||||
class="h-8 w-8"
|
||||
src={"https://api.dicebear.com/7.x/personas/svg?seed=#{fs.user_id}.svg"}
|
||||
/>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<%= for res <- fs.response do %>
|
||||
<p><strong><%= elem(res, 0) %>:</strong> <%= elem(res, 1) %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<p class="italic text-gray-500"><%= gettext("No form has been created") %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% :web_content -> %>
|
||||
<div>
|
||||
<%= if length(@event.presentation_file.embeds) > 0 do %>
|
||||
<%= for embed <- @event.presentation_file.embeds do %>
|
||||
<span class="text-xl font-semibold text-gray-900 mb-4">
|
||||
<%= embed.title %>
|
||||
</span>
|
||||
<div class="text-black break-all mt-4 mb-10">
|
||||
<.live_component
|
||||
id={"embed-component-#{embed.id}"}
|
||||
module={ClaperWeb.EventLive.EmbedIframeComponent}
|
||||
provider={embed.provider}
|
||||
content={embed.content}
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<p class="italic text-gray-500">
|
||||
<%= gettext("No web content has been created") %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% :messages -> %>
|
||||
<div>
|
||||
<%= if length(@posts) > 0 do %>
|
||||
<div class="flex justify-start mb-4">
|
||||
<%= link to: ~p"/export/#{@event.uuid}/messages", class: "text-sm text-white bg-primary-500 hover:bg-primary-600 rounded-md px-3 py-1 flex items-center gap-1", method: :post, target: "_blank" do %>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M14 3v4a1 1 0 0 0 1 1h4" /><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z" /><path d="M9 15h6" /><path d="M12.5 17.5l2.5 -2.5l-2.5 -2.5" />
|
||||
</svg>
|
||||
<span><%= gettext("Export") %> (CSV)</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if length(@posts) == 0 do %>
|
||||
<p class="italic text-gray-500"><%= gettext("No messages has been sent") %></p>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<.live_component
|
||||
:for={post <- @posts}
|
||||
module={ClaperWeb.EventLive.ManageablePostComponent}
|
||||
readonly={true}
|
||||
id={post.uuid}
|
||||
event={@event}
|
||||
post={post}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% :quizzes -> %>
|
||||
<div>
|
||||
<%= if length(@event.presentation_file.quizzes) > 0 do %>
|
||||
<div>
|
||||
<%= for quiz <- @event.presentation_file.quizzes do %>
|
||||
<div class="bg-gray-900 w-full p-4 text-black shadow-lg rounded-md mb-10">
|
||||
<div class="mb-4">
|
||||
<div class="w-full flex items-center justify-between">
|
||||
<p class="text-white text-xl font-semibold mb-2"><%= quiz.title %></p>
|
||||
<%= link to: ~p"/export/quizzes/#{quiz.id}", class: "text-sm text-white bg-primary-500 hover:bg-primary-600 rounded-md px-3 py-1 flex items-center gap-1", method: :post, target: "_blank" do %>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M14 3v4a1 1 0 0 0 1 1h4" /><path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z" /><path d="M9 15h6" /><path d="M12.5 17.5l2.5 -2.5l-2.5 -2.5" />
|
||||
</svg>
|
||||
<span><%= gettext("Export") %> (CSV)</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="text-gray-400 text-sm">
|
||||
<%= gettext("Average score") %>:
|
||||
<span class="font-semibold">
|
||||
<%= Claper.Quizzes.calculate_average_score(quiz.id) %>/<%= length(
|
||||
quiz.quiz_questions
|
||||
) %>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<% posts = Enum.filter(@posts, fn p -> p.position == position end) %>
|
||||
|
||||
<span class="text-xl font-semibold text-gray-900 mb-4">
|
||||
<%= gettext("Messages") %>
|
||||
</span>
|
||||
|
||||
<%= if length(posts) == 0 do %>
|
||||
<p class="italic text-gray-500"><%= gettext("No messages has been sent") %></p>
|
||||
<% end %>
|
||||
|
||||
<div class="h-64 pb-5 px-2 overflow-y-auto">
|
||||
<.live_component
|
||||
:for={post <- posts}
|
||||
module={ClaperWeb.EventLive.ManageablePostComponent}
|
||||
readonly={true}
|
||||
id={post.uuid}
|
||||
event={@event}
|
||||
post={post}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="flex flex-col space-y-3 overflow-y-auto max-h-[500px]">
|
||||
<%= for {question, _idx} <- Enum.with_index(quiz.quiz_questions) do %>
|
||||
<div class="border-t border-gray-700 pt-4 mt-4 first:border-t-0 first:pt-0 first:mt-0">
|
||||
<p class="text-white text-lg font-medium mb-3">
|
||||
<%= question.content %>
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<%= for opt <- question.quiz_question_opts do %>
|
||||
<div class={"bg-gray-500 px-3 py-2 rounded-lg flex justify-between items-center relative text-white #{if opt.is_correct, do: "bg-green-600"}"}>
|
||||
<div class="flex justify-between items-center z-10 text-left w-full">
|
||||
<div class="flex items-center text-left space-x-3">
|
||||
<%= if opt.is_correct do %>
|
||||
<div class="h-5 w-5 mt-0.5 rounded-md point-select bg-white">
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="h-5 w-5 mt-0.5 rounded-md point-select border-2 border-white">
|
||||
</div>
|
||||
<% end %>
|
||||
<span class="flex-1 pr-2"><%= opt.content %></span>
|
||||
</div>
|
||||
<span class="text-sm">
|
||||
<%= opt.percentage %>% (<%= opt.response_count %>)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="italic text-gray-500"><%= gettext("No quiz has been created") %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,7 +51,11 @@ defmodule ClaperWeb.Router do
|
||||
scope "/", ClaperWeb do
|
||||
pipe_through([:browser, :require_authenticated_user])
|
||||
|
||||
post "/export/:form_id", StatController, :export
|
||||
post "/export/forms/:form_id", StatController, :export_form
|
||||
post "/export/polls/:poll_id", StatController, :export_poll
|
||||
post "/export/quizzes/:quiz_id", StatController, :export_quiz
|
||||
post "/export/quizzes/:quiz_id/qti", StatController, :export_quiz_qti
|
||||
post "/export/:event_id/messages", StatController, :export_all_messages
|
||||
|
||||
live("/events", EventLive.Index, :index)
|
||||
live("/events/new", EventLive.Index, :new)
|
||||
@@ -78,6 +82,8 @@ defmodule ClaperWeb.Router do
|
||||
live("/e/:code/manage/edit/form/:id", EventLive.Manage, :edit_form)
|
||||
live("/e/:code/manage/add/embed", EventLive.Manage, :add_embed)
|
||||
live("/e/:code/manage/edit/embed/:id", EventLive.Manage, :edit_embed)
|
||||
live("/e/:code/manage/add/quiz", EventLive.Manage, :add_quiz)
|
||||
live("/e/:code/manage/edit/quiz/:id", EventLive.Manage, :edit_quiz)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -141,7 +147,6 @@ defmodule ClaperWeb.Router do
|
||||
post("/lti/login", Lti.LaunchController, :login)
|
||||
get("/lti/login", Lti.LaunchController, :login)
|
||||
post("/lti/launch", Lti.LaunchController, :launch)
|
||||
get("/lti/grades", Lti.GradeController, :create)
|
||||
end
|
||||
|
||||
scope "/", ClaperWeb do
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
<h1>success</h1>
|
||||
|
||||
<p>Course label: <%= @course_label %></p>
|
||||
<p>Course title: <%= @course_title %></p>
|
||||
|
||||
<p>Resource title: <%= @resource_title %></p>
|
||||
|
||||
<a href={~p"/lti/grades"}>Send grades</a>
|
||||
@@ -33,11 +33,15 @@
|
||||
<input type="hidden" name="openid_configuration" value={@conf} />
|
||||
<input type="hidden" name="registration_token" value={@token} />
|
||||
<button
|
||||
:if={@current_user}
|
||||
type="submit"
|
||||
class="flex justify-center text-white p-4 rounded-md tracking-wide font-bold focus:outline-none focus:shadow-outline shadow-lg bg-gradient-to-tl from-primary-500 to-secondary-500 bg-size-200 bg-pos-0 hover:bg-pos-100 transition-all duration-500"
|
||||
>
|
||||
<%= gettext("Add Claper") %>
|
||||
</button>
|
||||
<p :if={!@current_user} class="text-white italic">
|
||||
<%= gettext("You must login to continue") %>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -133,6 +133,30 @@ defmodule ClaperWeb.Component.Input do
|
||||
"""
|
||||
end
|
||||
|
||||
def check_button(assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> assign_new(:disabled, fn -> false end)
|
||||
|> assign_new(:shortcut, fn -> nil end)
|
||||
|> assign_new(:checked, fn -> false end)
|
||||
|
||||
~H"""
|
||||
<button
|
||||
phx-click={checked(@checked, @key)}
|
||||
disabled={@disabled}
|
||||
phx-value-key={@key}
|
||||
type="button"
|
||||
class={"py-2 px-2 rounded #{if @checked, do: "bg-primary-500 hover:bg-primary-600 text-white", else: "bg-gray-200 hover:bg-gray-300 text-gray-600"} flex justify-between items-center w-full gap-x-2 disabled:opacity-50 disabled:cursor-not-allowed transition ease-in-out duration-300"}
|
||||
role="switch"
|
||||
aria-checked="false"
|
||||
phx-key={@shortcut}
|
||||
phx-window-keydown={if @shortcut && not @disabled, do: checked(@checked, @key)}
|
||||
>
|
||||
<%= render_slot(@inner_block) %>
|
||||
</button>
|
||||
"""
|
||||
end
|
||||
|
||||
def checked(is_checked, key, js \\ %JS{})
|
||||
|
||||
def checked(false, key, js) do
|
||||
|
||||
31
lib/lti_13/quiz_score_reporter.ex
Normal file
31
lib/lti_13/quiz_score_reporter.ex
Normal file
@@ -0,0 +1,31 @@
|
||||
defmodule Lti13.QuizScoreReporter do
|
||||
alias Claper.Quizzes
|
||||
|
||||
def report_quiz_score(%Quizzes.Quiz{} = quiz, user_id) do
|
||||
quiz =
|
||||
quiz
|
||||
|> Claper.Repo.preload(lti_resource: [:registration])
|
||||
|
||||
if quiz.lti_resource do
|
||||
# Calculate score as percentage of correct answers
|
||||
score = calculate_score(quiz, user_id)
|
||||
timestamp = get_timestamp()
|
||||
|
||||
Claper.Workers.QuizLti.post_score(quiz.id, user_id, score, timestamp) |> Oban.insert()
|
||||
else
|
||||
# No LTI resource
|
||||
{:ok, quiz}
|
||||
end
|
||||
end
|
||||
|
||||
defp calculate_score(quiz, user_id) do
|
||||
{correct_answers, total_questions} = Quizzes.calculate_user_score(user_id, quiz.id)
|
||||
|
||||
correct_answers / total_questions * 100
|
||||
end
|
||||
|
||||
defp get_timestamp do
|
||||
{:ok, dt} = DateTime.now("Etc/UTC")
|
||||
DateTime.to_iso8601(dt)
|
||||
end
|
||||
end
|
||||
@@ -5,6 +5,7 @@ defmodule Lti13.Registrations.Registration do
|
||||
@type t :: %__MODULE__{
|
||||
id: integer(),
|
||||
issuer: String.t() | nil,
|
||||
user_id: integer() | nil,
|
||||
client_id: String.t() | nil,
|
||||
key_set_url: String.t() | nil,
|
||||
auth_token_url: String.t() | nil,
|
||||
@@ -25,6 +26,7 @@ defmodule Lti13.Registrations.Registration do
|
||||
|
||||
has_many :deployments, Lti13.Deployments.Deployment
|
||||
belongs_to :tool_jwk, Lti13.Jwks.Jwk, foreign_key: :tool_jwk_id
|
||||
belongs_to :user, Claper.Accounts.User
|
||||
|
||||
timestamps()
|
||||
end
|
||||
@@ -34,6 +36,7 @@ defmodule Lti13.Registrations.Registration do
|
||||
registration
|
||||
|> cast(attrs, [
|
||||
:issuer,
|
||||
:user_id,
|
||||
:client_id,
|
||||
:key_set_url,
|
||||
:auth_token_url,
|
||||
@@ -43,6 +46,7 @@ defmodule Lti13.Registrations.Registration do
|
||||
])
|
||||
|> validate_required([
|
||||
:issuer,
|
||||
:user_id,
|
||||
:client_id,
|
||||
:key_set_url,
|
||||
:auth_token_url,
|
||||
|
||||
@@ -24,14 +24,17 @@ defmodule Lti13.Resources do
|
||||
Creates a resource and event with the given title and resource_id
|
||||
|
||||
## Examples
|
||||
iex> create_resource_with_event(%{title: "Test", resource_id: "123", lti_user: %Lti13.Users.User{}})
|
||||
iex> create_resource_with_event(%{title: "Test", resource_id: "123", line_items_url: "https://example.com", lti_user: %Lti13.Users.User{}})
|
||||
{:ok, %Claper.Events.Event{}, %Lti13.Resources.Resource{}}
|
||||
iex> create_resource_with_event(%{})
|
||||
{:error, %{reason: :invalid_resource, msg: "Failed to create resource"}}
|
||||
"""
|
||||
@spec create_resource_with_event(map()) ::
|
||||
{:ok, Resource.t()} | {:error, map()}
|
||||
def create_resource_with_event(%{title: title, resource_id: resource_id, lti_user: lti_user}) do
|
||||
def create_resource_with_event(%{
|
||||
title: title,
|
||||
resource_id: resource_id,
|
||||
line_items_url: line_items_url,
|
||||
lti_user: lti_user
|
||||
}) do
|
||||
with {:ok, event} <-
|
||||
Claper.Events.create_event(%{
|
||||
name: title,
|
||||
@@ -49,6 +52,7 @@ defmodule Lti13.Resources do
|
||||
create_resource(%{
|
||||
title: title,
|
||||
resource_id: resource_id,
|
||||
line_items_url: line_items_url,
|
||||
event_id: event.id,
|
||||
registration_id: lti_user.registration_id
|
||||
}) do
|
||||
|
||||
@@ -8,6 +8,7 @@ defmodule Lti13.Resources.Resource do
|
||||
resource_id: integer() | nil,
|
||||
event_id: integer(),
|
||||
registration_id: integer(),
|
||||
line_items_url: String.t() | nil,
|
||||
inserted_at: NaiveDateTime.t(),
|
||||
updated_at: NaiveDateTime.t()
|
||||
}
|
||||
@@ -15,6 +16,7 @@ defmodule Lti13.Resources.Resource do
|
||||
schema "lti_13_resources" do
|
||||
field :title, :string
|
||||
field :resource_id, :integer
|
||||
field :line_items_url, :string
|
||||
|
||||
belongs_to :event, Claper.Events.Event
|
||||
belongs_to :registration, Lti13.Registrations.Registration
|
||||
@@ -25,7 +27,7 @@ defmodule Lti13.Resources.Resource do
|
||||
@doc false
|
||||
def changeset(registration, attrs \\ %{}) do
|
||||
registration
|
||||
|> cast(attrs, [:title, :resource_id, :event_id, :registration_id])
|
||||
|> cast(attrs, [:title, :resource_id, :event_id, :line_items_url, :registration_id])
|
||||
|> validate_required([:title, :resource_id, :event_id, :registration_id])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -81,17 +81,14 @@ defmodule Lti13.Tool.LaunchValidation do
|
||||
end
|
||||
end
|
||||
|
||||
@spec validate_resource(
|
||||
map(),
|
||||
Lti13.Users.User.t(),
|
||||
Lti13.Registrations.Registration.t(),
|
||||
boolean()
|
||||
) :: {:ok, Lti13.Resources.Resource.t()} | {:error, map()}
|
||||
defp validate_resource(
|
||||
%{
|
||||
"https://purl.imsglobal.org/spec/lti/claim/custom" => %{
|
||||
"resource_title" => title,
|
||||
"resource_id" => resource_id
|
||||
},
|
||||
"https://purl.imsglobal.org/spec/lti-ags/claim/endpoint" => %{
|
||||
"lineitems" => line_items_url
|
||||
}
|
||||
},
|
||||
lti_user,
|
||||
@@ -99,15 +96,16 @@ defmodule Lti13.Tool.LaunchValidation do
|
||||
is_instructor
|
||||
) do
|
||||
case Lti13.Resources.get_resource_by_id_and_registration(resource_id, registration.id) do
|
||||
nil -> handle_missing_resource(title, resource_id, lti_user, is_instructor)
|
||||
nil -> handle_missing_resource(title, resource_id, lti_user, line_items_url, is_instructor)
|
||||
resource -> handle_existing_resource(resource, lti_user, is_instructor)
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_missing_resource(title, resource_id, lti_user, true) do
|
||||
defp handle_missing_resource(title, resource_id, lti_user, line_items_url, true) do
|
||||
case Lti13.Resources.create_resource_with_event(%{
|
||||
title: title,
|
||||
resource_id: resource_id,
|
||||
line_items_url: line_items_url,
|
||||
lti_user: lti_user
|
||||
}) do
|
||||
{:ok, resource} -> {:ok, resource}
|
||||
@@ -115,9 +113,6 @@ defmodule Lti13.Tool.LaunchValidation do
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_missing_resource(_, _, _, false),
|
||||
do: {:error, %{reason: :invalid_resource, msg: "User is not authorized to create resource"}}
|
||||
|
||||
defp handle_existing_resource(resource, lti_user, true) do
|
||||
maybe_create_activity_leader(resource, lti_user)
|
||||
{:ok, resource}
|
||||
|
||||
@@ -13,8 +13,6 @@ defmodule Lti13.Tool.Services.AccessToken do
|
||||
scope: String.t()
|
||||
}
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Requests an OAuth2 access token. Returns {:ok, %AccessToken{}} on success, {:error, error}
|
||||
otherwise.
|
||||
@@ -39,7 +37,6 @@ defmodule Lti13.Tool.Services.AccessToken do
|
||||
iex> fetch_access_token(bad_tool)
|
||||
{:error, "invalid_scope"}
|
||||
"""
|
||||
|
||||
def fetch_access_token(
|
||||
%{auth_token_url: auth_token_url, client_id: client_id, auth_server: auth_audience},
|
||||
scopes,
|
||||
@@ -55,6 +52,21 @@ defmodule Lti13.Tool.Services.AccessToken do
|
||||
request_token(auth_token_url, client_assertion, scopes)
|
||||
end
|
||||
|
||||
def fetch_access_token(lti_resource) do
|
||||
Lti13.Tool.Services.AccessToken.fetch_access_token(
|
||||
%{
|
||||
auth_token_url: lti_resource.registration.auth_token_url,
|
||||
client_id: lti_resource.registration.client_id,
|
||||
auth_server: lti_resource.registration.auth_server
|
||||
},
|
||||
[
|
||||
"https://purl.imsglobal.org/spec/lti-ags/scope/lineitem",
|
||||
"https://purl.imsglobal.org/spec/lti-ags/scope/score"
|
||||
],
|
||||
Application.get_env(:claper, ClaperWeb.Endpoint)[:url][:host]
|
||||
)
|
||||
end
|
||||
|
||||
defp request_token(url, client_assertion, scopes) do
|
||||
body =
|
||||
[
|
||||
@@ -67,24 +79,22 @@ defmodule Lti13.Tool.Services.AccessToken do
|
||||
|
||||
headers = %{"Content-Type" => "application/x-www-form-urlencoded"}
|
||||
|
||||
Logger.debug("Fetching access token with the following parameters")
|
||||
Logger.debug("client_assertion: #{inspect(client_assertion)}")
|
||||
Logger.debug("scopes #{inspect(scopes)}")
|
||||
|
||||
with {:ok, %Req.Response{status: 200, body: body}} <-
|
||||
Req.post(url, body: body, headers: headers),
|
||||
{:ok, result} <- body do
|
||||
{:ok, parsed_body} <- Jason.decode(body) do
|
||||
{:ok,
|
||||
%__MODULE__{
|
||||
access_token: Map.get(result, "access_token"),
|
||||
token_type: Map.get(result, "token_type"),
|
||||
expires_in: Map.get(result, "expires_in"),
|
||||
scope: Map.get(result, "scope")
|
||||
access_token: Map.get(parsed_body, "access_token"),
|
||||
token_type: Map.get(parsed_body, "token_type"),
|
||||
expires_in: Map.get(parsed_body, "expires_in"),
|
||||
scope: Map.get(parsed_body, "scope")
|
||||
}}
|
||||
else
|
||||
{:error, %Jason.DecodeError{}} ->
|
||||
{:error, "Invalid JSON response"}
|
||||
|
||||
e ->
|
||||
Logger.error("Error encountered fetching access token #{inspect(e)}")
|
||||
{:error, "Error fetching access token"}
|
||||
{:error, "Error fetching access token: #{inspect(e)}"}
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -14,14 +14,10 @@ defmodule Lti13.Tool.Services.AGS do
|
||||
alias Lti13.Tool.Services.AGS.LineItem
|
||||
alias Lti13.Tool.Services.AccessToken
|
||||
|
||||
require Logger
|
||||
|
||||
@doc """
|
||||
Post a score to an existing line item, using an already acquired access token.
|
||||
"""
|
||||
def post_score(%Score{} = score, %LineItem{} = line_item, %AccessToken{} = access_token) do
|
||||
Logger.info("Posting score for user #{score.userId} for line item '#{line_item.label}'")
|
||||
|
||||
body = score |> Jason.encode!()
|
||||
|
||||
case Req.post(
|
||||
@@ -32,11 +28,7 @@ defmodule Lti13.Tool.Services.AGS do
|
||||
{:ok, %Req.Response{status: code, body: body}} when code in [200, 201] ->
|
||||
{:ok, body}
|
||||
|
||||
e ->
|
||||
Logger.error(
|
||||
"Error encountered posting score for user #{score.userId} for line item '#{line_item.label}' #{inspect(e)}"
|
||||
)
|
||||
|
||||
_e ->
|
||||
{:error, "Error posting score"}
|
||||
end
|
||||
end
|
||||
@@ -53,58 +45,53 @@ defmodule Lti13.Tool.Services.AGS do
|
||||
label,
|
||||
%AccessToken{} = access_token
|
||||
) do
|
||||
Logger.info("fetch_or_create_line_item #{resource_id} #{label}")
|
||||
|
||||
# Grade pass back 2.0 line items endpoint allows a GET request with a query
|
||||
# param filter. We use that to request only the line item that corresponds
|
||||
# to this particular resource_id. "resource_id", from grade pass back 2.0
|
||||
# perspective is simply an identifier that the tool uses for a line item and its use
|
||||
# here as a Torus "resource_id" is strictly coincidence.
|
||||
|
||||
prefixed_resource_id = LineItem.to_resource_id(resource_id)
|
||||
|
||||
request_url =
|
||||
build_url_with_params(line_items_service_url, "resource_id=#{prefixed_resource_id}&limit=1")
|
||||
|
||||
Logger.info("fetch_or_create_line_item: URL #{request_url}")
|
||||
|
||||
with {:ok, %Req.Response{status: code, body: body}} when code in [200, 201] <-
|
||||
Req.get(request_url, headers: headers(access_token)),
|
||||
{:ok, result} <- Jason.decode(body) do
|
||||
case result do
|
||||
[] ->
|
||||
Logger.info("fetch_or_create_line_item #{resource_id} #{label}")
|
||||
|
||||
create_line_item(
|
||||
line_items_service_url,
|
||||
resource_id,
|
||||
maximum_score_provider.(),
|
||||
label,
|
||||
access_token
|
||||
)
|
||||
|
||||
# it is important to match against a possible array of items, in case an LMS does
|
||||
# not properly support the limit parameter
|
||||
[raw_line_item | _] ->
|
||||
Logger.info(
|
||||
"fetch_or_create_line_item: Retrieved raw line item #{inspect(raw_line_item)} for #{resource_id} #{label}"
|
||||
)
|
||||
|
||||
line_item = to_line_item(raw_line_item)
|
||||
|
||||
if line_item.label != label do
|
||||
update_line_item(line_item, %{label: label}, access_token)
|
||||
else
|
||||
{:ok, line_item}
|
||||
end
|
||||
end
|
||||
Req.get(line_items_service_url, headers: headers(access_token)),
|
||||
result <- if(is_binary(body), do: Jason.decode!(body), else: body) do
|
||||
process_line_items(
|
||||
result,
|
||||
line_items_service_url,
|
||||
resource_id,
|
||||
maximum_score_provider,
|
||||
label,
|
||||
access_token
|
||||
)
|
||||
else
|
||||
e ->
|
||||
Logger.error(
|
||||
"Error encountered fetching line item for #{resource_id} #{label}: #{inspect(e)}"
|
||||
)
|
||||
_error -> {:error, "Error retrieving existing line items"}
|
||||
end
|
||||
end
|
||||
|
||||
{:error, "Error retrieving existing line items"}
|
||||
defp process_line_items(
|
||||
[],
|
||||
line_items_service_url,
|
||||
resource_id,
|
||||
maximum_score_provider,
|
||||
label,
|
||||
access_token
|
||||
) do
|
||||
create_line_item(
|
||||
line_items_service_url,
|
||||
resource_id,
|
||||
maximum_score_provider.(),
|
||||
label,
|
||||
access_token
|
||||
)
|
||||
end
|
||||
|
||||
defp process_line_items(
|
||||
[raw_line_item | _],
|
||||
_url,
|
||||
_resource_id,
|
||||
_score_provider,
|
||||
label,
|
||||
access_token
|
||||
) do
|
||||
line_item = to_line_item(raw_line_item)
|
||||
|
||||
if line_item.label != label do
|
||||
update_line_item(line_item, %{label: label}, access_token)
|
||||
else
|
||||
{:ok, line_item}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -118,21 +105,18 @@ defmodule Lti13.Tool.Services.AGS do
|
||||
end
|
||||
|
||||
def fetch_line_items(line_items_service_url, %AccessToken{} = access_token) do
|
||||
Logger.info("Fetch line items from #{line_items_service_url}")
|
||||
|
||||
# Unfortunately, at least Canvas implements a default limit of 10 line items
|
||||
# when one makes a request without a 'limit' parameter specified. Setting it explicitly to 1000
|
||||
# bypasses this default limit, of course, and works in all cases until a course more than
|
||||
# a thousand grade book entries.
|
||||
url = build_url_with_params(line_items_service_url, "limit=1000")
|
||||
|
||||
with {:ok, %Req.Response{status: 200, body: body}} <-
|
||||
Req.get(url, headers: headers(access_token)),
|
||||
{:ok, results} <- Jason.decode(body) do
|
||||
{:ok, Enum.map(results, fn r -> to_line_item(r) end)}
|
||||
else
|
||||
e ->
|
||||
Logger.error("Error encountered fetching line items from #{url} #{inspect(e)}")
|
||||
case Req.get(url, headers: headers(access_token)) do
|
||||
{:ok, %Req.Response{status: 200, body: body}} ->
|
||||
results = if is_binary(body), do: Jason.decode!(body), else: body
|
||||
{:ok, Enum.map(results, fn r -> to_line_item(r) end)}
|
||||
|
||||
_e ->
|
||||
{:error, "Error retrieving all line items"}
|
||||
end
|
||||
end
|
||||
@@ -148,8 +132,6 @@ defmodule Lti13.Tool.Services.AGS do
|
||||
label,
|
||||
%AccessToken{} = access_token
|
||||
) do
|
||||
Logger.info("Create line item for #{resource_id} #{label}")
|
||||
|
||||
line_item = %LineItem{
|
||||
scoreMaximum: score_maximum,
|
||||
resourceId: LineItem.to_resource_id(resource_id),
|
||||
@@ -158,16 +140,12 @@ defmodule Lti13.Tool.Services.AGS do
|
||||
|
||||
body = line_item |> Jason.encode!()
|
||||
|
||||
with {:ok, %Req.Response{status: code, body: body}} when code in [200, 201] <-
|
||||
Req.post(line_items_service_url, body: body, headers: headers(access_token)),
|
||||
{:ok, result} <- Jason.decode(body) do
|
||||
{:ok, to_line_item(result)}
|
||||
else
|
||||
e ->
|
||||
Logger.error(
|
||||
"Error encountered creating line item for #{resource_id} #{label}: #{inspect(e)}"
|
||||
)
|
||||
case Req.post(line_items_service_url, body: body, headers: headers(access_token)) do
|
||||
{:ok, %Req.Response{status: code, body: body}} when code in [200, 201] ->
|
||||
result = if is_binary(body), do: Jason.decode!(body), else: body
|
||||
{:ok, to_line_item(result)}
|
||||
|
||||
_ ->
|
||||
{:error, "Error creating new line item"}
|
||||
end
|
||||
end
|
||||
@@ -177,8 +155,6 @@ defmodule Lti13.Tool.Services.AGS do
|
||||
a {:ok, line_item} tuple. On error, returns a {:error, error} tuple.
|
||||
"""
|
||||
def update_line_item(%LineItem{} = line_item, changes, %AccessToken{} = access_token) do
|
||||
Logger.info("Updating line item #{line_item.id} for changes #{inspect(changes)}")
|
||||
|
||||
updated_line_item = %LineItem{
|
||||
id: line_item.id,
|
||||
scoreMaximum: Map.get(changes, :scoreMaximum, line_item.scoreMaximum),
|
||||
@@ -192,16 +168,12 @@ defmodule Lti13.Tool.Services.AGS do
|
||||
# url to use is the id of the line item
|
||||
url = line_item.id
|
||||
|
||||
with {:ok, %Req.Response{status: 200, body: body}} <-
|
||||
Req.put(url, body: body, headers: headers(access_token)),
|
||||
{:ok, result} <- body do
|
||||
{:ok, to_line_item(result)}
|
||||
else
|
||||
e ->
|
||||
Logger.error(
|
||||
"Error encountered updating line item #{line_item.id} for changes #{inspect(changes)}: #{inspect(e)}"
|
||||
)
|
||||
case Req.put(url, body: body, headers: headers(access_token)) do
|
||||
{:ok, %Req.Response{status: 200, body: body}} ->
|
||||
result = if is_binary(body), do: Jason.decode!(body), else: body
|
||||
{:ok, to_line_item(result)}
|
||||
|
||||
_e ->
|
||||
{:error, "Error updating existing line item"}
|
||||
end
|
||||
end
|
||||
|
||||
5
mix.exs
5
mix.exs
@@ -1,7 +1,7 @@
|
||||
defmodule Claper.MixProject do
|
||||
use Mix.Project
|
||||
|
||||
@version "2.2.0"
|
||||
@version "2.3.0"
|
||||
|
||||
def project do
|
||||
[
|
||||
@@ -114,7 +114,8 @@ defmodule Claper.MixProject do
|
||||
{:jose, "~> 1.11"},
|
||||
{:req, "~> 0.5.0"},
|
||||
{:uuid, "~> 1.1"},
|
||||
{:oidcc, "~> 3.2"}
|
||||
{:oidcc, "~> 3.2"},
|
||||
{:oban, "~> 2.17"}
|
||||
]
|
||||
end
|
||||
|
||||
|
||||
1
mix.lock
1
mix.lock
@@ -52,6 +52,7 @@
|
||||
"nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"},
|
||||
"nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
|
||||
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||
"oban": {:hex, :oban, "2.18.3", "1608c04f8856c108555c379f2f56bc0759149d35fa9d3b825cb8a6769f8ae926", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "36ca6ca84ef6518f9c2c759ea88efd438a3c81d667ba23b02b062a0aa785475e"},
|
||||
"oidcc": {:hex, :oidcc, "3.2.0", "f80a4826a946ce07dc8cbd8212392b4ff436ae3c4b4cd6680fa0d84d0ff2fec1", [:mix, :rebar3], [{:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.3.1", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "38fd9092ab5d5d10c71b8011b019316411afe466bef07ba57f57ec3f919278c3"},
|
||||
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
|
||||
"phoenix": {:hex, :phoenix, "1.7.11", "1d88fc6b05ab0c735b250932c4e6e33bfa1c186f76dcf623d8dd52f07d6379c7", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "b1ec57f2e40316b306708fe59b92a16b9f6f4bf50ccfa41aa8c7feb79e0ec02a"},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
50
priv/repo/migrations/20240928085505_create_quizzes.exs
Normal file
50
priv/repo/migrations/20240928085505_create_quizzes.exs
Normal file
@@ -0,0 +1,50 @@
|
||||
defmodule Claper.Repo.Migrations.CreateQuizzes do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:quizzes) do
|
||||
add :title, :string, size: 255
|
||||
add :position, :integer, default: 0
|
||||
add :presentation_file_id, references(:presentation_files, on_delete: :delete_all)
|
||||
add :enabled, :boolean, default: false
|
||||
add :show_results, :boolean, default: false
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create table(:quiz_questions) do
|
||||
add :content, :string, size: 255
|
||||
add :type, :string, default: "qcm"
|
||||
add :quiz_id, references(:quizzes, on_delete: :delete_all)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create table(:quiz_question_opts) do
|
||||
add :content, :string, size: 255
|
||||
add :is_correct, :boolean, default: false
|
||||
add :response_count, :integer, default: 0
|
||||
add :quiz_question_id, references(:quiz_questions, on_delete: :delete_all)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create table(:quiz_responses) do
|
||||
add :attendee_identifier, :string
|
||||
add :quiz_id, references(:quizzes, on_delete: :delete_all)
|
||||
add :quiz_question_id, references(:quiz_questions, on_delete: :delete_all)
|
||||
add :quiz_question_opt_id, references(:quiz_question_opts, on_delete: :delete_all)
|
||||
add :user_id, references(:users, on_delete: :delete_all)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
create index(:quizzes, [:presentation_file_id])
|
||||
create index(:quiz_questions, [:quiz_id])
|
||||
create index(:quiz_question_opts, [:quiz_question_id])
|
||||
create index(:quiz_responses, [:quiz_id])
|
||||
create index(:quiz_responses, [:quiz_question_id])
|
||||
create index(:quiz_responses, [:quiz_question_opt_id])
|
||||
create index(:quiz_responses, [:user_id])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,25 @@
|
||||
defmodule Claper.Repo.Migrations.AddAttendeesColumnsToStats do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:stats) do
|
||||
add :attendee_identifier, :string
|
||||
add :user_id, references(:users, on_delete: :delete_all)
|
||||
remove :status
|
||||
end
|
||||
|
||||
create unique_index(:stats, [:event_id, :user_id])
|
||||
create unique_index(:stats, [:event_id, :attendee_identifier])
|
||||
end
|
||||
|
||||
def down do
|
||||
drop unique_index(:stats, [:event_id, :attendee_identifier])
|
||||
drop unique_index(:stats, [:event_id, :user_id])
|
||||
|
||||
alter table(:stats) do
|
||||
remove :attendee_identifier
|
||||
remove :user_id
|
||||
add :status, :string
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
defmodule Claper.Repo.Migrations.AddLtiLineItemColumnsToQuizzesAndEvents do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:quizzes) do
|
||||
add :lti_line_item_url, :string
|
||||
end
|
||||
|
||||
alter table(:lti_13_resources) do
|
||||
add :line_items_url, :string
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
defmodule Claper.Repo.Migrations.AddUserIdToLtiRegistrations do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:lti_13_registrations) do
|
||||
add :user_id, references(:users, on_delete: :delete_all)
|
||||
end
|
||||
end
|
||||
end
|
||||
13
priv/repo/migrations/20241207195849_add_oban_jobs_table.exs
Normal file
13
priv/repo/migrations/20241207195849_add_oban_jobs_table.exs
Normal file
@@ -0,0 +1,13 @@
|
||||
defmodule Claper.Repo.Migrations.AddObanJobsTable do
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
Oban.Migration.up(version: 12)
|
||||
end
|
||||
|
||||
# We specify `version: 1` in `down`, ensuring that we'll roll all the way back down if
|
||||
# necessary, regardless of which version we've migrated `up` to.
|
||||
def down do
|
||||
Oban.Migration.down(version: 1)
|
||||
end
|
||||
end
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 1.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 20 KiB |
@@ -39,10 +39,7 @@ defmodule Claper.AccountsTest do
|
||||
end
|
||||
|
||||
test "sends magic link through notification", %{user: user} do
|
||||
token =
|
||||
extract_magic_token(fn url ->
|
||||
Accounts.deliver_magic_link(user.email, url)
|
||||
end)
|
||||
{:ok, token} = Accounts.deliver_magic_link(user.email, &"/users/magic/#{&1}")
|
||||
|
||||
{:ok, token} = Base.url_decode64(token, padding: false)
|
||||
|
||||
@@ -142,10 +139,12 @@ defmodule Claper.AccountsTest do
|
||||
end
|
||||
|
||||
test "sends token through notification", %{user: user} do
|
||||
token =
|
||||
extract_user_token(fn url ->
|
||||
Accounts.deliver_update_email_instructions(user, "current@example.com", url)
|
||||
end)
|
||||
{:ok, token} =
|
||||
Accounts.deliver_update_email_instructions(
|
||||
user,
|
||||
"current@example.com",
|
||||
&"/users/settings/confirm_email/#{&1}"
|
||||
)
|
||||
|
||||
{:ok, token} = Base.url_decode64(token, padding: false)
|
||||
assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
|
||||
@@ -160,10 +159,12 @@ defmodule Claper.AccountsTest do
|
||||
user = user_fixture()
|
||||
email = unique_user_email()
|
||||
|
||||
token =
|
||||
extract_user_token(fn url ->
|
||||
Accounts.deliver_update_email_instructions(%{user | email: email}, user.email, url)
|
||||
end)
|
||||
{:ok, token} =
|
||||
Accounts.deliver_update_email_instructions(
|
||||
%{user | email: email},
|
||||
user.email,
|
||||
&"/users/settings/confirm_email/#{&1}"
|
||||
)
|
||||
|
||||
%{user: user, token: token, email: email}
|
||||
end
|
||||
@@ -266,10 +267,8 @@ defmodule Claper.AccountsTest do
|
||||
end
|
||||
|
||||
test "sends token through notification", %{user: user} do
|
||||
token =
|
||||
extract_user_token(fn url ->
|
||||
Accounts.deliver_user_confirmation_instructions(user, url)
|
||||
end)
|
||||
{:ok, token} =
|
||||
Accounts.deliver_user_confirmation_instructions(user, &"/users/confirm/#{&1}")
|
||||
|
||||
{:ok, token} = Base.url_decode64(token, padding: false)
|
||||
assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
|
||||
@@ -283,10 +282,8 @@ defmodule Claper.AccountsTest do
|
||||
setup do
|
||||
user = user_fixture()
|
||||
|
||||
token =
|
||||
extract_user_token(fn url ->
|
||||
Accounts.deliver_user_confirmation_instructions(user, url)
|
||||
end)
|
||||
{:ok, token} =
|
||||
Accounts.deliver_user_confirmation_instructions(user, &"/users/confirm/#{&1}")
|
||||
|
||||
%{user: user, token: token}
|
||||
end
|
||||
|
||||
179
test/claper/quizzes_test.exs
Normal file
179
test/claper/quizzes_test.exs
Normal file
@@ -0,0 +1,179 @@
|
||||
defmodule Claper.QuizzesTest do
|
||||
use Claper.DataCase
|
||||
|
||||
alias Claper.Quizzes
|
||||
alias Claper.Quizzes.Quiz
|
||||
|
||||
import Claper.QuizzesFixtures
|
||||
import Claper.PresentationsFixtures
|
||||
import Claper.AccountsFixtures
|
||||
|
||||
describe "quizzes" do
|
||||
test "list_quizzes/1 returns all quizzes for a presentation file" do
|
||||
presentation_file = presentation_file_fixture()
|
||||
quiz = quiz_fixture(%{presentation_file: presentation_file})
|
||||
assert Quizzes.list_quizzes(presentation_file.id) == [quiz]
|
||||
end
|
||||
|
||||
test "list_quizzes_at_position/2 returns quizzes at specific position" do
|
||||
presentation_file = presentation_file_fixture()
|
||||
quiz = quiz_fixture(%{presentation_file: presentation_file, position: 1})
|
||||
|
||||
assert Quizzes.list_quizzes_at_position(presentation_file.id, 1) == [quiz]
|
||||
assert Quizzes.list_quizzes_at_position(presentation_file.id, 2) == []
|
||||
end
|
||||
|
||||
test "get_quiz!/1 returns the quiz with given id" do
|
||||
quiz = quiz_fixture()
|
||||
fetched_quiz = Quizzes.get_quiz!(quiz.id, quiz_questions: :quiz_question_opts)
|
||||
|
||||
# Compare all fields except the virtual percentage field in quiz_question_opts
|
||||
assert Map.drop(fetched_quiz, [:quiz_questions]) == Map.drop(quiz, [:quiz_questions])
|
||||
assert length(fetched_quiz.quiz_questions) == length(quiz.quiz_questions)
|
||||
|
||||
Enum.zip(fetched_quiz.quiz_questions, quiz.quiz_questions)
|
||||
|> Enum.each(fn {fetched_question, original_question} ->
|
||||
assert Map.drop(fetched_question, [:quiz_question_opts]) ==
|
||||
Map.drop(original_question, [:quiz_question_opts])
|
||||
|
||||
assert length(fetched_question.quiz_question_opts) ==
|
||||
length(original_question.quiz_question_opts)
|
||||
|
||||
Enum.zip(fetched_question.quiz_question_opts, original_question.quiz_question_opts)
|
||||
|> Enum.each(fn {fetched_opt, original_opt} ->
|
||||
assert Map.drop(fetched_opt, [:percentage]) == Map.drop(original_opt, [:percentage])
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
test "create_quiz/1 with valid data creates a quiz" do
|
||||
presentation_file = presentation_file_fixture()
|
||||
|
||||
valid_attrs = %{
|
||||
title: "Test Quiz",
|
||||
position: 1,
|
||||
presentation_file_id: presentation_file.id,
|
||||
quiz_questions: [
|
||||
%{
|
||||
content: "Test Question",
|
||||
type: "qcm",
|
||||
quiz_question_opts: [
|
||||
%{content: "Option 1", is_correct: true},
|
||||
%{content: "Option 2", is_correct: false}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
assert {:ok, %Quiz{} = quiz} = Quizzes.create_quiz(valid_attrs)
|
||||
assert quiz.title == "Test Quiz"
|
||||
assert quiz.position == 1
|
||||
assert length(quiz.quiz_questions) == 1
|
||||
assert length(List.first(quiz.quiz_questions).quiz_question_opts) == 2
|
||||
end
|
||||
|
||||
test "create_quiz/1 with invalid data returns error changeset" do
|
||||
assert {:error, %Ecto.Changeset{}} = Quizzes.create_quiz(%{})
|
||||
end
|
||||
|
||||
test "update_quiz/3 with valid data updates the quiz" do
|
||||
quiz = quiz_fixture()
|
||||
event_uuid = Ecto.UUID.generate()
|
||||
update_attrs = %{title: "Updated Title"}
|
||||
|
||||
assert {:ok, %Quiz{} = updated_quiz} =
|
||||
Quizzes.update_quiz(event_uuid, quiz, update_attrs)
|
||||
|
||||
assert updated_quiz.title == "Updated Title"
|
||||
end
|
||||
|
||||
test "delete_quiz/2 deletes the quiz" do
|
||||
quiz = quiz_fixture()
|
||||
event_uuid = Ecto.UUID.generate()
|
||||
assert {:ok, %Quiz{}} = Quizzes.delete_quiz(event_uuid, quiz)
|
||||
assert_raise Ecto.NoResultsError, fn -> Quizzes.get_quiz!(quiz.id) end
|
||||
end
|
||||
|
||||
test "change_quiz/2 returns a quiz changeset" do
|
||||
quiz = quiz_fixture()
|
||||
assert %Ecto.Changeset{} = Quizzes.change_quiz(quiz)
|
||||
end
|
||||
|
||||
test "add_quiz_question/1 adds a new question with default options" do
|
||||
quiz = quiz_fixture()
|
||||
changeset = Quizzes.change_quiz(quiz)
|
||||
updated_changeset = Quizzes.add_quiz_question(changeset)
|
||||
|
||||
questions = Ecto.Changeset.get_field(updated_changeset, :quiz_questions)
|
||||
# Original + new question
|
||||
assert length(questions) == 2
|
||||
|
||||
new_question = List.last(questions)
|
||||
# Two default options
|
||||
assert length(new_question.quiz_question_opts) == 2
|
||||
end
|
||||
|
||||
test "submit_quiz/3 with user_id records responses and updates counts" do
|
||||
quiz = quiz_fixture()
|
||||
user = user_fixture()
|
||||
question = List.first(quiz.quiz_questions)
|
||||
option = List.first(question.quiz_question_opts)
|
||||
|
||||
assert {:ok, updated_quiz} =
|
||||
Quizzes.submit_quiz(user.id, [option], quiz.id)
|
||||
|
||||
updated_option =
|
||||
updated_quiz.quiz_questions
|
||||
|> List.first()
|
||||
|> Map.get(:quiz_question_opts)
|
||||
|> List.first()
|
||||
|
||||
assert updated_option.response_count == 1
|
||||
end
|
||||
|
||||
test "submit_quiz/3 with attendee_identifier records responses" do
|
||||
quiz = quiz_fixture()
|
||||
question = List.first(quiz.quiz_questions)
|
||||
option = List.first(question.quiz_question_opts)
|
||||
attendee_id = "test-attendee"
|
||||
|
||||
assert {:ok, _updated_quiz} =
|
||||
Quizzes.submit_quiz(attendee_id, [option], quiz.id)
|
||||
|
||||
responses = Quizzes.get_quiz_responses(attendee_id, quiz.id)
|
||||
assert length(responses) == 1
|
||||
end
|
||||
|
||||
test "calculate_user_score/2 correctly calculates score" do
|
||||
quiz = quiz_fixture()
|
||||
user = user_fixture()
|
||||
question = List.first(quiz.quiz_questions)
|
||||
correct_option = Enum.find(question.quiz_question_opts, & &1.is_correct)
|
||||
|
||||
# Submit correct answer
|
||||
{:ok, _} = Quizzes.submit_quiz(user.id, [correct_option], quiz.id)
|
||||
assert {1, 1} = Quizzes.calculate_user_score(user.id, quiz.id)
|
||||
end
|
||||
|
||||
test "disable_all/2 disables all quizzes at position" do
|
||||
presentation_file = presentation_file_fixture()
|
||||
quiz = quiz_fixture(%{presentation_file: presentation_file, position: 1, enabled: true})
|
||||
|
||||
Quizzes.disable_all(presentation_file.id, 1)
|
||||
updated_quiz = Quizzes.get_quiz!(quiz.id)
|
||||
refute updated_quiz.enabled
|
||||
end
|
||||
|
||||
test "set_enabled/1 enables a quiz" do
|
||||
quiz = quiz_fixture(%{enabled: false})
|
||||
assert {:ok, updated_quiz} = Quizzes.set_enabled(quiz.id)
|
||||
assert updated_quiz.enabled
|
||||
end
|
||||
|
||||
test "set_disabled/1 disables a quiz" do
|
||||
quiz = quiz_fixture(%{enabled: true})
|
||||
assert {:ok, updated_quiz} = Quizzes.set_disabled(quiz.id)
|
||||
refute updated_quiz.enabled
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -53,10 +53,8 @@ defmodule ClaperWeb.UserConfirmationControllerTest do
|
||||
|
||||
describe "POST /users/confirm/:token" do
|
||||
test "confirms the given token once", %{conn: conn, user: user} do
|
||||
token =
|
||||
extract_user_token(fn url ->
|
||||
Accounts.deliver_user_confirmation_instructions(user, url)
|
||||
end)
|
||||
{:ok, token} =
|
||||
Accounts.deliver_user_confirmation_instructions(user, &"/users/confirm/#{&1}")
|
||||
|
||||
conn = get(conn, ~p"/users/confirm/#{token}")
|
||||
assert redirected_to(conn) == ~p"/users/log_in"
|
||||
|
||||
@@ -4,10 +4,12 @@ defmodule Lti13.RegistrationsTest do
|
||||
alias Lti13.Registrations
|
||||
|
||||
import Lti13.JwksFixtures
|
||||
import Claper.AccountsFixtures
|
||||
|
||||
describe "registrations" do
|
||||
test "create and get registration by issuer client id" do
|
||||
jwk = jwk_fixture()
|
||||
user = confirmed_user_fixture()
|
||||
|
||||
registration = %{
|
||||
issuer: "some issuer",
|
||||
@@ -16,6 +18,7 @@ defmodule Lti13.RegistrationsTest do
|
||||
auth_token_url: "some auth_token_url",
|
||||
auth_login_url: "some auth_login_url",
|
||||
auth_server: "some auth_server",
|
||||
user_id: user.id,
|
||||
tool_jwk_id: jwk.id
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ defmodule Lti13.ResourcesTest do
|
||||
attrs = %{
|
||||
title: "Resource 1",
|
||||
resource_id: 1,
|
||||
line_items_url: "https://example.com/line_items",
|
||||
lti_user: lti_user
|
||||
}
|
||||
|
||||
@@ -104,6 +105,7 @@ defmodule Lti13.ResourcesTest do
|
||||
attrs = %{
|
||||
title: nil,
|
||||
resource_id: nil,
|
||||
line_items_url: nil,
|
||||
lti_user: lti_user
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ defmodule Claper.DataCase do
|
||||
|
||||
using do
|
||||
quote do
|
||||
use Oban.Testing, repo: Claper.Repo
|
||||
alias Claper.Repo
|
||||
|
||||
import Ecto
|
||||
|
||||
@@ -39,16 +39,4 @@ defmodule Claper.AccountsFixtures do
|
||||
|
||||
user
|
||||
end
|
||||
|
||||
def extract_magic_token(fun) do
|
||||
{:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]")
|
||||
[_, token | _] = String.split(captured_email.html_body, "[TOKEN]")
|
||||
token
|
||||
end
|
||||
|
||||
def extract_user_token(fun) do
|
||||
{:ok, captured_email} = fun.(&"[TOKEN]#{&1}[TOKEN]")
|
||||
[_, token | _] = String.split(captured_email.html_body || captured_email.text_body, "[TOKEN]")
|
||||
token
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,12 +5,14 @@ defmodule Lti13.RegistrationsFixtures do
|
||||
"""
|
||||
|
||||
import Lti13.JwksFixtures
|
||||
import Claper.AccountsFixtures
|
||||
|
||||
@doc """
|
||||
Generate a registration.
|
||||
"""
|
||||
def registration_fixture(attrs \\ %{}) do
|
||||
jwk = jwk_fixture()
|
||||
user = confirmed_user_fixture()
|
||||
|
||||
{:ok, registration} =
|
||||
attrs
|
||||
@@ -21,7 +23,8 @@ defmodule Lti13.RegistrationsFixtures do
|
||||
auth_login_url: "https://example.com/auth_login_url",
|
||||
key_set_url: "https://example.com/key_set_url",
|
||||
auth_server: "https://example.com/",
|
||||
tool_jwk_id: attrs[:tool_jwk_id] || jwk.id
|
||||
tool_jwk_id: attrs[:tool_jwk_id] || jwk.id,
|
||||
user_id: user.id
|
||||
})
|
||||
|> Lti13.Registrations.create_registration()
|
||||
|
||||
|
||||
48
test/support/fixtures/quizzes_fixtures.ex
Normal file
48
test/support/fixtures/quizzes_fixtures.ex
Normal file
@@ -0,0 +1,48 @@
|
||||
defmodule Claper.QuizzesFixtures do
|
||||
@moduledoc """
|
||||
This module defines test helpers for creating
|
||||
entities via the `Claper.Quizzes` context.
|
||||
"""
|
||||
|
||||
import Claper.PresentationsFixtures
|
||||
|
||||
def quiz_fixture(attrs \\ %{}) do
|
||||
presentation_file = attrs[:presentation_file] || presentation_file_fixture()
|
||||
|
||||
attrs =
|
||||
attrs
|
||||
|> Enum.into(%{
|
||||
title: "some quiz title",
|
||||
position: 42,
|
||||
enabled: false,
|
||||
show_results: false,
|
||||
presentation_file_id: presentation_file.id,
|
||||
quiz_questions: [
|
||||
%{
|
||||
content: "some question content",
|
||||
type: "qcm",
|
||||
quiz_question_opts: [
|
||||
%{
|
||||
content: "option 1",
|
||||
is_correct: true
|
||||
},
|
||||
%{
|
||||
content: "option 2",
|
||||
is_correct: false
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
lti_resource_id: nil,
|
||||
lti_line_item_url: nil
|
||||
})
|
||||
|
||||
case Claper.Quizzes.create_quiz(attrs) do
|
||||
{:ok, quiz} ->
|
||||
quiz
|
||||
|
||||
{:error, changeset} ->
|
||||
raise "Failed to create quiz: #{inspect(changeset.errors)}"
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user