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: cec1a97 7476269
Author: 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: 1977959 6f8a2fd
Author: 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

commit 1977959efb
Author: Alex <dev@alexandrelion.com>
Date:   Sat Oct 5 12:57:09 2024 +0200

    WIP
This commit is contained in:
Alex Lion
2024-12-21 10:09:29 -05:00
parent 74762691d2
commit 093bb79b42
82 changed files with 7755 additions and 2464 deletions

View File

@@ -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 ## v2.2.0
### Features ### Features

View File

@@ -68,6 +68,10 @@ Hooks.EmbeddedBanner = {
Hooks.TourGuide = { Hooks.TourGuide = {
mounted() { 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({ this.tour = new TourGuideClient({
nextLabel: this.el.dataset.nextLabel, nextLabel: this.el.dataset.nextLabel,
prevLabel: this.el.dataset.prevLabel, prevLabel: this.el.dataset.prevLabel,
@@ -77,12 +81,35 @@ Hooks.TourGuide = {
}); });
if (!this.tour.isFinished(this.el.dataset.group)) { 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.onBeforeExit(() => {
this.tour.finishTour(true, this.el.dataset.group); 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 = const columnSlitValue =
localStorage.getItem(`column-split-${id}`) || "1fr 10px 1fr"; localStorage.getItem(`column-split-${id}`) || "1fr 10px 1fr";
const rowSlitValue = const rowSlitValue =
localStorage.getItem(`row-split-${id}`) || "1fr 10px 1fr"; localStorage.getItem(`row-split-${id}`) || "0.5fr 10px 1fr";
if (type === "column") { if (type === "column") {
this.columnSplit = Split({ this.columnSplit = Split({

View File

@@ -20,6 +20,15 @@ config :claper, ClaperWeb.Gettext,
default_locale: "en", default_locale: "en",
locales: ~w(fr en de es nl) 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, config :dart_sass,
version: "1.61.0", version: "1.61.0",
default: [ default: [

View File

@@ -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) secret_key_base = get_var_from_path_or_env(config_dir, "SECRET_KEY_BASE", nil)
case secret_key_base do if System.get_env("MIX_ENV") == "prod" or Application.get_env(:claper, :server, false) do
nil -> case secret_key_base do
raise "SECRET_KEY_BASE configuration option is required. See https://docs.claper.co/configuration.html#production-docker" nil ->
raise "SECRET_KEY_BASE configuration option is required. See https://docs.claper.co/configuration.html#production-docker"
key when byte_size(key) < 32 -> 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" raise "SECRET_KEY_BASE must be at least 32 bytes long. See https://docs.claper.co/configuration.html#production-docker"
_ -> _ ->
nil nil
end
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 if System.get_env("MIX_ENV") == "prod" or Application.get_env(:claper, :server, false) do
raise "BASE_URL configuration option is required. See https://docs.claper.co/configuration.html#production-docker" case base_url do
nil ->
raise "BASE_URL configuration option is required. See https://docs.claper.co/configuration.html#production-docker"
_ ->
nil
end
end end
base_url = URI.parse(base_url) base_url = URI.parse(base_url)
if base_url.scheme not in ["http", "https"] do if System.get_env("MIX_ENV") == "prod" or Application.get_env(:claper, :server, false) do
raise "BASE_URL must start with `http` or `https`. Currently configured as `#{System.get_env("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")}`"
end
end end
max_file_size = get_int_from_path_or_env(config_dir, "MAX_FILE_SIZE_MB", 15) max_file_size = get_int_from_path_or_env(config_dir, "MAX_FILE_SIZE_MB", 15)

View File

@@ -23,6 +23,8 @@ config :claper, ClaperWeb.Endpoint,
# In test we don't send emails. # In test we don't send emails.
config :claper, Claper.Mailer, adapter: Swoosh.Adapters.Test config :claper, Claper.Mailer, adapter: Swoosh.Adapters.Test
config :claper, Oban, testing: :inline
# Print only warnings and errors during test # Print only warnings and errors during test
config :logger, level: :warning config :logger, level: :warning

2
dev.sh
View File

@@ -6,6 +6,8 @@ args=("$@")
if [ "${args[0]}" == "start" ]; then if [ "${args[0]}" == "start" ]; then
mix phx.server mix phx.server
elif [ "${args[0]}" == "iex" ]; then
iex -S mix
else else
mix "$@" mix "$@"
fi fi

View File

@@ -276,6 +276,7 @@ defmodule Claper.Accounts do
Repo.insert!(user_token) Repo.insert!(user_token)
UserNotifier.deliver_magic_link(email, magic_link_url_fun.(encoded_token)) UserNotifier.deliver_magic_link(email, magic_link_url_fun.(encoded_token))
{:ok, encoded_token}
end end
@doc """ @doc """
@@ -293,6 +294,7 @@ defmodule Claper.Accounts do
Repo.insert!(user_token) Repo.insert!(user_token)
UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token)) UserNotifier.deliver_update_email_instructions(user, update_email_url_fun.(encoded_token))
{:ok, encoded_token}
end end
@doc """ @doc """
@@ -393,6 +395,7 @@ defmodule Claper.Accounts do
{encoded_token, user_token} = UserToken.build_email_token(user, "confirm") {encoded_token, user_token} = UserToken.build_email_token(user, "confirm")
Repo.insert!(user_token) Repo.insert!(user_token)
UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token)) UserNotifier.deliver_confirmation_instructions(user, confirmation_url_fun.(encoded_token))
{:ok, encoded_token}
end end
end end

View File

@@ -27,6 +27,7 @@ defmodule Claper.Accounts.User do
field :locale, :string field :locale, :string
has_many :events, Claper.Events.Event has_many :events, Claper.Events.Event
has_one :lti_user, Lti13.Users.User
timestamps() timestamps()
end end

View File

@@ -1,8 +1,6 @@
defmodule Claper.Accounts.UserNotifier do defmodule Claper.Accounts.UserNotifier do
# import Swoosh.Email # import Swoosh.Email
alias Claper.Mailer
# Delivers the email using the application mailer. # Delivers the email using the application mailer.
# defp deliver(recipient, subject, body) do # defp deliver(recipient, subject, body) do
# from_name = Application.get_env(:claper, :mail)[:from_name] # from_name = Application.get_env(:claper, :mail)[:from_name]
@@ -21,51 +19,41 @@ defmodule Claper.Accounts.UserNotifier do
# end # end
def deliver_magic_link(email, url) do 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, :enqueued}
{:ok, email}
end
end end
def deliver_welcome(email) do 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, :enqueued}
{:ok, email}
end
end end
@doc """ @doc """
Deliver instructions to confirm account. Deliver instructions to confirm account.
""" """
def deliver_confirmation_instructions(user, url) do 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, :enqueued}
{:ok, email}
end
end end
@doc """ @doc """
Deliver instructions to reset a user password. Deliver instructions to reset a user password.
""" """
def deliver_reset_password_instructions(user, url) do 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, :enqueued}
{:ok, email}
end
end end
@doc """ @doc """
Deliver instructions to update a user email. Deliver instructions to update a user email.
""" """
def deliver_update_email_instructions(user, url) do 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, :enqueued}
{:ok, email}
end
end end
end end

View File

@@ -9,6 +9,7 @@ defmodule Claper.Application do
def start(_type, _args) do def start(_type, _args) do
topologies = Application.get_env(:libcluster, :topologies) || [] topologies = Application.get_env(:libcluster, :topologies) || []
oidc_config = Application.get_env(:claper, :oidc) || [] oidc_config = Application.get_env(:claper, :oidc) || []
Oban.Telemetry.attach_default_logger()
children = [ children = [
{Cluster.Supervisor, [topologies, [name: Claper.ClusterSupervisor]]}, {Cluster.Supervisor, [topologies, [name: Claper.ClusterSupervisor]]},
@@ -26,7 +27,8 @@ defmodule Claper.Application do
{Finch, name: Swoosh.Finch}, {Finch, name: Swoosh.Finch},
{Task.Supervisor, name: Claper.TaskSupervisor}, {Task.Supervisor, name: Claper.TaskSupervisor},
{Oidcc.ProviderConfiguration.Worker, {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 # See https://hexdocs.pm/elixir/Supervisor.html

View File

@@ -10,6 +10,8 @@ defmodule Claper.Events do
alias Claper.Events.{Event, ActivityLeader} alias Claper.Events.{Event, ActivityLeader}
@default_page_size 5
@doc """ @doc """
Returns the list of events of a given user. Returns the list of events of a given user.
@@ -25,6 +27,108 @@ defmodule Claper.Events do
|> Repo.preload(preload) |> Repo.preload(preload)
end 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 """ @doc """
Returns the list of events managed by a given user email. Returns the list of events managed by a given user email.
@@ -48,6 +152,53 @@ defmodule Claper.Events do
|> Repo.preload(preload) |> Repo.preload(preload)
end 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 def count_events_month(user_id) do
# minus 30 days, calculated as seconds # minus 30 days, calculated as seconds
seconds = -30 * 24 * 3600 seconds = -30 * 24 * 3600
@@ -386,6 +537,7 @@ defmodule Claper.Events do
polls: [:poll_opts], polls: [:poll_opts],
forms: [], forms: [],
embeds: [], embeds: [],
quizzes: [:quiz_questions, quiz_questions: :quiz_question_opts],
presentation_state: [] presentation_state: []
] ]
)} )}
@@ -491,6 +643,37 @@ defmodule Claper.Events do
new_embed new_embed
end)} end)}
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 |> Repo.transaction() do
{:ok, %{new_event: new_event}} -> {:ok, new_event} {:ok, %{new_event: new_event}} -> {:ok, new_event}
{:error, _failed_operation, failed_value, _changes_so_far} -> {:error, failed_value} {:error, _failed_operation, failed_value, _changes_so_far} -> {:error, failed_value}

View File

@@ -22,15 +22,16 @@ defmodule Claper.Events.Event do
field :uuid, :binary_id field :uuid, :binary_id
field :name, :string field :name, :string
field :code, :string field :code, :string
field :audience_peak, :integer, default: 1 field :audience_peak, :integer, default: 0
field :started_at, :naive_datetime field :started_at, :naive_datetime
field :expired_at, :naive_datetime field :expired_at, :naive_datetime
has_many :posts, Claper.Posts.Post has_many :posts, Claper.Posts.Post
has_many :leaders, Claper.Events.ActivityLeader, on_replace: :delete has_many :leaders, Claper.Events.ActivityLeader, on_replace: :delete
has_one :presentation_file, Claper.Presentations.PresentationFile has_one :presentation_file, Claper.Presentations.PresentationFile
has_one :lti_resource, Lti13.Resources.Resource
belongs_to :user, Claper.Accounts.User belongs_to :user, Claper.Accounts.User
timestamps() timestamps()

View File

@@ -4,7 +4,7 @@ defmodule Claper.Interactions do
alias Claper.Embeds alias Claper.Embeds
alias Claper.Events alias Claper.Events
alias Claper.Presentations alias Claper.Presentations
alias Claper.Quizzes
import Ecto.Query, warn: false import Ecto.Query, warn: false
@type interaction :: Polls.Poll | Forms.Form | Embeds.Embed @type interaction :: Polls.Poll | Forms.Form | Embeds.Embed
@@ -29,6 +29,13 @@ defmodule Claper.Interactions do
) )
|> Claper.Repo.one() |> 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 end
def get_active_interaction(event, position) do def get_active_interaction(event, position) do
@@ -46,9 +53,10 @@ defmodule Claper.Interactions do
) do ) do
with polls <- Polls.list_polls_at_position(presentation_file_id, position), with polls <- Polls.list_polls_at_position(presentation_file_id, position),
forms <- Forms.list_forms_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 = interactions =
(polls ++ forms ++ embeds) (polls ++ forms ++ embeds ++ quizzes)
|> Enum.sort_by(& &1.inserted_at, {:asc, NaiveDateTime}) |> Enum.sort_by(& &1.inserted_at, {:asc, NaiveDateTime})
if broadcast do if broadcast do
@@ -79,6 +87,10 @@ defmodule Claper.Interactions do
{count, _} = Embeds.disable_all(interaction.presentation_file_id, interaction.position) {count, _} = Embeds.disable_all(interaction.presentation_file_id, interaction.position)
{:ok, count} {:ok, count}
end) 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, _ -> |> Ecto.Multi.run(:enable_interaction, fn _repo, _ ->
set_enabled(interaction) set_enabled(interaction)
end) end)
@@ -101,6 +113,10 @@ defmodule Claper.Interactions do
Embeds.set_enabled(interaction.id) Embeds.set_enabled(interaction.id)
end end
defp set_enabled(%Quizzes.Quiz{} = interaction) do
Quizzes.set_enabled(interaction.id)
end
def disable_interaction(%Polls.Poll{} = interaction) do def disable_interaction(%Polls.Poll{} = interaction) do
Polls.set_disabled(interaction.id) Polls.set_disabled(interaction.id)
end end
@@ -112,4 +128,8 @@ defmodule Claper.Interactions do
def disable_interaction(%Embeds.Embed{} = interaction) do def disable_interaction(%Embeds.Embed{} = interaction) do
Embeds.set_disabled(interaction.id) Embeds.set_disabled(interaction.id)
end end
def disable_interaction(%Quizzes.Quiz{} = interaction) do
Quizzes.set_disabled(interaction.id)
end
end end

View File

@@ -11,6 +11,7 @@ defmodule Claper.Presentations.PresentationFile do
polls: [Claper.Polls.Poll.t()] | nil, polls: [Claper.Polls.Poll.t()] | nil,
forms: [Claper.Forms.Form.t()] | nil, forms: [Claper.Forms.Form.t()] | nil,
embeds: [Claper.Embeds.Embed.t()] | nil, embeds: [Claper.Embeds.Embed.t()] | nil,
quizzes: [Claper.Quizzes.Quiz.t()] | nil,
presentation_state: Claper.Presentations.PresentationState.t(), presentation_state: Claper.Presentations.PresentationState.t(),
inserted_at: NaiveDateTime.t(), inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t() updated_at: NaiveDateTime.t()
@@ -25,6 +26,7 @@ defmodule Claper.Presentations.PresentationFile do
has_many :polls, Claper.Polls.Poll has_many :polls, Claper.Polls.Poll
has_many :forms, Claper.Forms.Form has_many :forms, Claper.Forms.Form
has_many :embeds, Claper.Embeds.Embed has_many :embeds, Claper.Embeds.Embed
has_many :quizzes, Claper.Quizzes.Quiz
has_one :presentation_state, Claper.Presentations.PresentationState, on_replace: :delete has_one :presentation_state, Claper.Presentations.PresentationState, on_replace: :delete
timestamps() timestamps()

563
lib/claper/quizzes.ex Normal file
View 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

View 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

View 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

View 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

View 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

View File

@@ -3,6 +3,10 @@ defmodule Claper.Repo do
otp_app: :claper, otp_app: :claper,
adapter: Ecto.Adapters.Postgres adapter: Ecto.Adapters.Postgres
import Ecto.Query
@default_page_size 12
def init(_type, config) do def init(_type, config) do
if url = System.get_env("DATABASE_URL") do if url = System.get_env("DATABASE_URL") do
{:ok, Keyword.put(config, :url, url)} {:ok, Keyword.put(config, :url, url)}
@@ -10,4 +14,29 @@ defmodule Claper.Repo do
{:ok, config} {:ok, config}
end end
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 end

View File

@@ -1,10 +1,92 @@
defmodule Claper.Stats do defmodule Claper.Stats do
@moduledoc """ @moduledoc """
The Stats context. All calculation for stats related to an event
""" """
import Ecto.Query, warn: false import Ecto.Query, warn: false
alias Claper.Repo 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 def distinct_poster_count(event_id) do
from(posts in Claper.Posts.Post, from(posts in Claper.Posts.Post,

35
lib/claper/stats/stat.ex Normal file
View 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

View 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

View 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

View File

@@ -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

View File

@@ -2,7 +2,7 @@ defmodule ClaperWeb.Lti.RegistrationController do
use ClaperWeb, :controller use ClaperWeb, :controller
def new(conn, %{"openid_configuration" => conf, "registration_token" => token}) do 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 end
def new(conn, _params) do def new(conn, _params) do
@@ -45,6 +45,7 @@ defmodule ClaperWeb.Lti.RegistrationController do
Lti13.Registrations.create_registration(%{ Lti13.Registrations.create_registration(%{
issuer: issuer, issuer: issuer,
client_id: client_id, client_id: client_id,
user_id: conn.assigns.current_user.id,
key_set_url: jwks_uri, key_set_url: jwks_uri,
auth_token_url: token_endpoint, auth_token_url: token_endpoint,
auth_login_url: auth_endpoint, auth_login_url: auth_endpoint,

View File

@@ -1,28 +1,261 @@
defmodule ClaperWeb.StatController do 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 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]) form = Forms.get_form!(form_id, [:form_submits])
headers = form.fields |> Enum.map(& &1.name) 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 conn
|> put_resp_content_type("text/csv") |> 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) |> put_root_layout(false)
|> send_resp(200, csv_data) |> send_resp(200, csv_data)
end end
defp csv_content(headers, records) do defp generate_qti_content(quiz) do
data = """
records <?xml version="1.0" encoding="UTF-8"?>
|> Enum.map(&(&1 |> Map.values())) <questestinterop xmlns="http://www.imsglobal.org/xsd/ims_qtiasiv1p2">
<assessment title="#{quiz.title}">
([headers] ++ data) <section>
|> CSV.encode() #{Enum.map_join(quiz.quiz_questions, "\n", &generate_qti_item/1)}
|> Enum.to_list() </section>
|> to_string() </assessment>
</questestinterop>
"""
end 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 end

View File

@@ -21,7 +21,7 @@ defmodule ClaperWeb.UserRegistrationController do
end end
def create(conn, %{"user" => user_params}) do 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} -> {:ok, user} ->
if Application.get_env(:claper, :email_confirmation) do if Application.get_env(:claper, :email_confirmation) do
{:ok, _} = {:ok, _} =
@@ -42,4 +42,13 @@ defmodule ClaperWeb.UserRegistrationController do
render(conn, "new.html", changeset: changeset) render(conn, "new.html", changeset: changeset)
end end
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 end

View File

@@ -13,9 +13,30 @@ defmodule ClaperWeb.EventLive.EventCardComponent do
<div class="block bg-white rounded-2xl shadow-base"> <div class="block bg-white rounded-2xl shadow-base">
<div class="px-4 py-4 sm:px-6"> <div class="px-4 py-4 sm:px-6">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<p class="text-lg font-medium text-primary-600 truncate"> <div class="flex items-center">
<%= @event.name %> <p class="text-lg font-medium text-primary-600 truncate">
</p> <%= @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"> <div class="ml-2 flex-shrink-0 flex">
<%= if Event.started?(@event) && !Event.finished?(@event) do %> <%= 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"> <div class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-500 text-white items-center gap-x-1">

View File

@@ -1,11 +1,41 @@
<div <div
id="wrapper" id="wrapper"
phx-hook="TourGuide" phx-hook="TourGuide"
data-btn-trigger="#product-tour-btn"
data-next-label={gettext("Next")} data-next-label={gettext("Next")}
data-prev-label={gettext("Back")} data-prev-label={gettext("Back")}
data-finish-label={gettext("Finish")} data-finish-label={gettext("Finish")}
data-group="create-event" 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="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"> <div class="flex-1 min-w-0">
<h1 class="text-2xl font-medium leading-6 text-gray-900 sm:truncate"> <h1 class="text-2xl font-medium leading-6 text-gray-900 sm:truncate">

View File

@@ -25,11 +25,21 @@ defmodule ClaperWeb.EventLive.Index do
Phoenix.PubSub.subscribe(Claper.PubSub, "events:#{socket.assigns.current_user.id}") Phoenix.PubSub.subscribe(Claper.PubSub, "events:#{socket.assigns.current_user.id}")
end 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 =
socket socket
|> stream(:events, list_events(socket)) |> assign(:active_tab, "not_expired")
|> assign(:managed_events, list_managed_events(socket))
|> assign(:quick_event_changeset, changeset) |> 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} {:ok, socket}
end end
@@ -43,7 +53,7 @@ defmodule ClaperWeb.EventLive.Index do
def handle_info({:presentation_file_process_done, presentation}, socket) do def handle_info({:presentation_file_process_done, presentation}, socket) do
event = Claper.Events.get_event!(presentation.event.uuid, [:presentation_file]) 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 end
@impl true @impl true
@@ -142,6 +152,27 @@ defmodule ClaperWeb.EventLive.Index do
{:noreply, assign(socket, :live_action, :quick_create)} {:noreply, assign(socket, :live_action, :quick_create)}
end 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 defp apply_action(socket, :edit, %{"id" => id}) do
event = event =
Events.get_user_event!(socket.assigns.current_user.id, id, [:presentation_file, :leaders]) 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) |> assign(:event, nil)
end end
defp list_events(socket) do defp load_events(socket) do
Events.list_events(socket.assigns.current_user.id, [:presentation_file]) params = %{"page" => socket.assigns.page, "page_size" => 5}
end
defp list_managed_events(socket) do {events, total_entries, total_pages} =
Events.list_managed_events_by(socket.assigns.current_user.email, [:presentation_file]) 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
end end

View File

@@ -1,4 +1,33 @@
<div class="mx-3 md:max-w-3xl lg:max-w-5xl md:mx-auto"> <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 %> <%= if @live_action in [:new, :edit] do %>
<.live_component <.live_component
module={ClaperWeb.EventLive.EventFormComponent} 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" 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" id="events-header"
phx-hook="TourGuide" phx-hook="TourGuide"
data-btn-trigger="#product-tour-btn"
data-group="welcome" data-group="welcome"
data-next-label={gettext("Next")} data-next-label={gettext("Next")}
data-prev-label={gettext("Back")} data-prev-label={gettext("Back")}
@@ -139,48 +169,61 @@
</div> </div>
</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"> <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() %> <% current_time = NaiveDateTime.utc_now() %>
<.live_component <%= for event <- @events do %>
:for={{id, event} <- @streams.events} <.live_component
module={ClaperWeb.EventLive.EventCardComponent} module={ClaperWeb.EventLive.EventCardComponent}
id={id} id={"event-#{event.id}"}
event={event} event={event}
current_time={current_time} current_time={current_time}
/> is_leader={@active_tab == "invited"}
/>
<% end %>
</ul> </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"> <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" /> <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> <p class="pt-12 clear-both"><%= gettext("Create your first event") %></p>
</div> </div>
<% end %> <% end %>
</div> </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 %> <% end %>
</div> </div>

View File

@@ -5,6 +5,8 @@ defmodule ClaperWeb.EventLive.Manage do
alias Claper.Polls alias Claper.Polls
alias Claper.Forms alias Claper.Forms
alias Claper.Embeds alias Claper.Embeds
# Add this line
alias Claper.Quizzes
@impl true @impl true
def mount(%{"code" => code}, session, socket) do def mount(%{"code" => code}, session, socket) do
@@ -15,6 +17,7 @@ defmodule ClaperWeb.EventLive.Manage do
event = event =
Claper.Events.get_event_with_code(code, [ Claper.Events.get_event_with_code(code, [
:user, :user,
:lti_resource,
presentation_file: [:polls, :presentation_state] presentation_file: [:polls, :presentation_state]
]) ])
@@ -27,13 +30,6 @@ defmodule ClaperWeb.EventLive.Manage do
if connected?(socket) do if connected?(socket) do
Claper.Events.Event.subscribe(event.uuid) Claper.Events.Event.subscribe(event.uuid)
Claper.Presentations.subscribe(event.presentation_file.id) Claper.Presentations.subscribe(event.presentation_file.id)
Presence.track(
self(),
"event:#{event.uuid}",
socket.assigns.current_user.id,
%{}
)
end end
posts = list_all_posts(socket, event.uuid) posts = list_all_posts(socket, event.uuid)
@@ -234,6 +230,13 @@ defmodule ClaperWeb.EventLive.Manage do
|> interactions_at_position(form.position)} |> interactions_at_position(form.position)}
end end
@impl true
def handle_info({:quiz_updated, quiz}, socket) do
{:noreply,
socket
|> interactions_at_position(quiz.position)}
end
@impl true @impl true
def handle_info({:poll_deleted, poll}, socket) do def handle_info({:poll_deleted, poll}, socket) do
{:noreply, {:noreply,
@@ -255,6 +258,13 @@ defmodule ClaperWeb.EventLive.Manage do
|> interactions_at_position(form.position)} |> interactions_at_position(form.position)}
end end
@impl true
def handle_info({:quiz_deleted, quiz}, socket) do
{:noreply,
socket
|> interactions_at_position(quiz.position)}
end
@impl true @impl true
def handle_info( def handle_info(
{:current_interaction, interaction}, {:current_interaction, interaction},
@@ -405,6 +415,39 @@ defmodule ClaperWeb.EventLive.Manage do
|> interactions_at_position(socket.assigns.state.position)} |> interactions_at_position(socket.assigns.state.position)}
end 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 @impl true
def handle_event( def handle_event(
"ban", "ban",
@@ -552,6 +595,57 @@ defmodule ClaperWeb.EventLive.Manage do
{:noreply, socket |> assign(:state, new_state)} {:noreply, socket |> assign(:state, new_state)}
end 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 @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
post = Claper.Posts.get_post!(id, [:event]) post = Claper.Posts.get_post!(id, [:event])
@@ -647,6 +741,14 @@ defmodule ClaperWeb.EventLive.Manage do
{:noreply, socket} {:noreply, socket}
end 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 @impl true
def handle_event("toggle-preview", _params, %{assigns: %{preview: preview}} = socket) do def handle_event("toggle-preview", _params, %{assigns: %{preview: preview}} = socket) do
{:noreply, socket |> assign(:preview, !preview)} {:noreply, socket |> assign(:preview, !preview)}
@@ -738,6 +840,36 @@ defmodule ClaperWeb.EventLive.Manage do
|> assign(:embed, embed) |> assign(:embed, embed)
end 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 defp pin(post, socket) do
{:ok, _updated_post} = Claper.Posts.toggle_pin_post(post) {:ok, _updated_post} = Claper.Posts.toggle_pin_post(post)

View File

@@ -6,6 +6,35 @@
data-max-page={@event.presentation_file.length} data-max-page={@event.presentation_file.length}
data-current-page={@state.position} 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 <div
:if={@preview} :if={@preview}
id="preview" id="preview"
@@ -100,6 +129,7 @@
create={@create} create={@create}
state={@state} state={@state}
show_shortcut={false} show_shortcut={false}
current_interaction={@current_interaction}
/> />
</div> </div>
</div> </div>
@@ -234,6 +264,38 @@
</div> </div>
</a> </a>
</li> </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> </ul>
<% end %> <% end %>
<%= if @create=="poll" do %> <%= if @create=="poll" do %>
@@ -298,6 +360,64 @@
/> />
</div> </div>
<% end %> <% 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> </div>
</div> </div>
@@ -309,6 +429,7 @@
data-prev-label={gettext("Back")} data-prev-label={gettext("Back")}
data-finish-label={gettext("Finish")} data-finish-label={gettext("Finish")}
data-group="manage" data-group="manage"
data-btn-trigger="#product-tour-btn"
phx-hook="TourGuide" phx-hook="TourGuide"
> >
<div class="flex items-center justify-between px-3 py-2 border-b-2 border-gray-200 w-full bg-white"> <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" d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14"
/> />
</svg> </svg>
<span><%= @event.code %></span> <span class="uppercase"><%= @event.code %></span>
</div> </div>
<div class="flex items-center text-sm text-gray-500 gap-x-1"> <div class="flex items-center text-sm text-gray-500 gap-x-1">
<svg <svg
@@ -368,6 +489,13 @@
<span id="attendees-count" phx-update="ignore" phx-hook="UpdateAttendees"> <span id="attendees-count" phx-update="ignore" phx-hook="UpdateAttendees">
<%= @attendees_nb %> <%= @attendees_nb %>
</span> </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> </div>
</div> </div>
@@ -540,7 +668,7 @@
data-type="row" data-type="row"
data-gutter=".gutter" data-gutter=".gutter"
id="base-layout" 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 <div
:if={@event.presentation_file.length > 0} :if={@event.presentation_file.length > 0}
@@ -597,7 +725,7 @@
<div class="h- overflow-y-auto @container"> <div class="h- overflow-y-auto @container">
<div <div
:if={length(@interactions) == 0} :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 <svg
xmlns="http://www.w3.org/2000/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"> <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 %> <%= for interaction <- @interactions do %>
<div class="bg-white rounded-lg p-3 shadow-base transition-all flex flex-col justify-between relative"> <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> <div>
<%= case interaction do %> <%= case interaction do %>
<% %Claper.Polls.Poll{} -> %> <% %Claper.Polls.Poll{} -> %>
@@ -806,21 +883,72 @@
class="p-2 rounded text-xs font-medium text-center text-primary-500" class="p-2 rounded text-xs font-medium text-center text-primary-500"
data-phx-link="patch" data-phx-link="patch"
data-phx-link-state="push" data-phx-link-state="push"
href={ href={~p"/e/#{@event.code}/manage/edit/embed/#{interaction.id}"}
case interaction do >
%Claper.Polls.Poll{} -> <svg
~p"/e/#{@event.code}/manage/edit/poll/#{interaction.id}" 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{} -> <span>LTI AGS</span>
~p"/e/#{@event.code}/manage/edit/form/#{interaction.id}" </p>
</div>
%Claper.Embeds.Embed{} -> <a
~p"/e/#{@event.code}/manage/edit/embed/#{interaction.id}" 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}"}
end
}
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -968,6 +1096,7 @@
%Claper.Polls.Poll{} -> "poll-set-inactive" %Claper.Polls.Poll{} -> "poll-set-inactive"
%Claper.Forms.Form{} -> "form-set-inactive" %Claper.Forms.Form{} -> "form-set-inactive"
%Claper.Embeds.Embed{} -> "embed-set-inactive" %Claper.Embeds.Embed{} -> "embed-set-inactive"
%Claper.Quizzes.Quiz{} -> "quiz-set-inactive"
_ -> "" _ -> ""
end end
} }
@@ -976,34 +1105,6 @@
> >
<%= gettext("Disable") %> <%= gettext("Disable") %>
</button> </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> </div>
<% else %> <% else %>
<button <button
@@ -1012,6 +1113,7 @@
%Claper.Polls.Poll{} -> "poll-set-active" %Claper.Polls.Poll{} -> "poll-set-active"
%Claper.Forms.Form{} -> "form-set-active" %Claper.Forms.Form{} -> "form-set-active"
%Claper.Embeds.Embed{} -> "embed-set-active" %Claper.Embeds.Embed{} -> "embed-set-active"
%Claper.Quizzes.Quiz{} -> "quiz-set-active"
_ -> "" _ -> ""
end end
} }
@@ -1051,7 +1153,7 @@
phx-hook="Split" phx-hook="Split"
data-gutter=".gutter-2" data-gutter=".gutter-2"
data-type="row" 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 <div
class="bg-gray-200 border-2 overflow-auto relative grid grid-rows-[auto_1fr] h-full w-full" class="bg-gray-200 border-2 overflow-auto relative grid grid-rows-[auto_1fr] h-full w-full"
@@ -1356,17 +1458,22 @@
</div> </div>
<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-title={"#{gettext("Settings")}"}
data-tg-order="3" 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-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" 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 <.live_component
id="settings-pane" id="settings-pane"
module={ClaperWeb.EventLive.ManagerSettingsComponent} module={ClaperWeb.EventLive.ManagerSettingsComponent}
create={@create} create={@create}
state={@state} state={@state}
current_interaction={@current_interaction}
/> />
</div> </div>
</div> </div>

View 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

View File

@@ -5,134 +5,546 @@ defmodule ClaperWeb.EventLive.ManagerSettingsComponent do
assigns = assigns |> assign_new(:show_shortcut, fn -> true end) assigns = assigns |> assign_new(:show_shortcut, fn -> true end)
~H""" ~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> <div>
<span class="font-semibold text-lg"> <div class="flex items-center space-x-2 font-semibold text-lg">
<%= gettext("Presentation settings") %> <svg
</span> 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"> <span><%= gettext("Interaction") %></span>
<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>
</div> </div>
<div class="flex space-x-2 items-center mt-3"> <%= case @current_interaction do %>
<ClaperWeb.Component.Input.check <% %Claper.Polls.Poll{} -> %>
key={:chat_visible} <div class="flex space-x-2 space-y-1.5 items-center mt-1.5">
checked={@state.chat_visible} <ClaperWeb.Component.Input.check_button
shortcut={if @create == nil, do: "W", else: nil} key={:poll_visible}
/> checked={@state.poll_visible}
<span> shortcut={if @create == nil, do: "Z", else: nil}
<%= gettext("Show messages") %> >
<code <svg
:if={@show_shortcut} :if={@state.poll_visible}
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg" xmlns="http://www.w3.org/2000/svg"
> viewBox="0 0 24 24"
w fill="none"
</code> stroke="currentColor"
</span> stroke-width="2"
</div> 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 <svg
class={"#{if !@state.chat_visible, do: "opacity-50"} flex space-x-2 items-center mt-3"} :if={!@state.poll_visible}
title={ xmlns="http://www.w3.org/2000/svg"
if !@state.chat_visible, viewBox="0 0 24 24"
do: gettext("Show messages to change this option"), fill="none"
else: nil stroke="currentColor"
} stroke-width="2"
> stroke-linecap="round"
<ClaperWeb.Component.Input.check stroke-linejoin="round"
key={:show_only_pinned} class="h-6 w-6"
checked={@state.show_only_pinned} >
disabled={!@state.chat_visible} <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" />
shortcut={if @create == nil, do: "E", else: nil} </svg>
/>
<span> <span :if={@state.poll_visible}>
<%= gettext("Show only pinned messages") %> <%= gettext("Hide results on presentation") %>
<code </span>
:if={@show_shortcut} <span :if={!@state.poll_visible}>
class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg" <%= gettext("Show results on presentation") %>
> </span>
e <code
</code> :if={@show_shortcut}
</span> class="px-2 py-1.5 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-200 rounded-lg"
</div> >
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>
<div class="grid grid-cols-1 space-y-5">
<div> <div class="grid grid-cols-1 space-y-1.5">
<span class="font-semibold text-lg"> <div class="flex items-center space-x-2 font-semibold text-lg">
<%= gettext("Attendees settings") %> <svg
</span> xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
<div class="flex space-x-2 items-center mt-3"> fill="currentColor"
<ClaperWeb.Component.Input.check class="w-5 h-5"
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"
> >
a <path
</code> fill-rule="evenodd"
</span> 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>
<div <div class="grid grid-cols-1 space-y-1.5">
class={"#{if !@state.chat_enabled, do: "opacity-50"} flex space-x-2 items-center mt-3"} <div class="flex items-center space-x-2 font-semibold text-lg">
title={ <svg
if !@state.chat_enabled, xmlns="http://www.w3.org/2000/svg"
do: gettext("Enable messages to change this option"), viewBox="0 0 20 20"
else: nil fill="currentColor"
} class="w-5 h-5"
>
<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"
> >
s <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" />
</code> <path
</span> fill-rule="evenodd"
</div> 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"> <span><%= gettext("Attendees") %></span>
<ClaperWeb.Component.Input.check </div>
key={:message_reaction_enabled}
checked={@state.message_reaction_enabled} <div class="flex space-x-2 items-center mt-3">
shortcut={if @create == nil, do: "D", else: nil} <ClaperWeb.Component.Input.check_button
/> key={:chat_enabled}
<span> checked={@state.chat_enabled}
<%= gettext("Enable reactions") %> shortcut={if @create == nil, do: "A", else: nil}
<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 <svg
</code> :if={!@state.chat_enabled}
</span> 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> </div>
</div> </div>

View File

@@ -7,7 +7,7 @@ defmodule ClaperWeb.EventLive.PollComponent do
<div> <div>
<div <div
id="collapsed-poll" 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="block w-full h-full cursor-pointer" phx-click={toggle_poll()} phx-target={@myself}>
<div class="text-white flex space-x-2 items-center"> <div class="text-white flex space-x-2 items-center">
@@ -29,7 +29,7 @@ defmodule ClaperWeb.EventLive.PollComponent do
</div> </div>
</div> </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 class="block w-full h-full cursor-pointer" phx-click={toggle_poll()} phx-target={@myself}>
<div id="poll-pane" class="float-right mt-2"> <div id="poll-pane" class="float-right mt-2">
<svg <svg
@@ -44,12 +44,12 @@ defmodule ClaperWeb.EventLive.PollComponent do
</svg> </svg>
</div> </div>
<p class="text-xs text-gray-500 my-1"><%= gettext("Current poll") %></p> <p class="text-sm text-gray-400 my-1"><%= gettext("Current poll") %></p>
<p class="text-white text-lg font-semibold mb-2"><%= @poll.title %></p> <p class="text-white text-xl font-semibold mb-2"><%= @poll.title %></p>
<%= if @poll.multiple do %> <%= 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 %> <% 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 %> <% end %>
</div> </div>
<div> <div>
@@ -57,10 +57,10 @@ defmodule ClaperWeb.EventLive.PollComponent do
<%= if (length @poll.poll_opts) > 0 do %> <%= if (length @poll.poll_opts) > 0 do %>
<%= for {opt, idx} <- Enum.with_index(@poll.poll_opts) do %> <%= for {opt, idx} <- Enum.with_index(@poll.poll_opts) do %>
<%= if (length @current_poll_vote) > 0 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 <div
style={"width: #{if @show_results, do: opt.percentage, else: 0}%;"} 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>
<div class="flex space-x-3 items-center z-10 text-left"> <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}"} id={"poll-opt-#{idx}"}
phx-click="select-poll-opt" phx-click="select-poll-opt"
phx-value-opt={idx} 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 <div
style={"width: #{if @show_results, do: opt.percentage, else: 0}%;"} 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>
<div class="flex space-x-3 items-center z-10 text-left"> <div class="flex space-x-3 items-center z-10 text-left">
@@ -123,14 +123,14 @@ defmodule ClaperWeb.EventLive.PollComponent do
</div> </div>
<%= if (length @selected_poll_opt) == 0 || (length @current_poll_vote) > 0 do %> <%= 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") %> <%= gettext("Vote") %>
</button> </button>
<% else %> <% else %>
<button <button
phx-click="vote" phx-click="vote"
phx-disable-with="..." 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") %> <%= gettext("Vote") %>
</button> </button>

View File

@@ -5,7 +5,7 @@ defmodule ClaperWeb.EventLive.Presenter do
alias Claper.Embeds.Embed alias Claper.Embeds.Embed
alias Claper.Polls.Poll alias Claper.Polls.Poll
alias Claper.Forms.Form alias Claper.Forms.Form
alias Claper.Quizzes.Quiz
@impl true @impl true
def mount(%{"code" => code} = params, session, socket) do def mount(%{"code" => code} = params, session, socket) do
with %{"locale" => locale} <- session do with %{"locale" => locale} <- session do
@@ -64,6 +64,7 @@ defmodule ClaperWeb.EventLive.Presenter do
|> poll_at_position |> poll_at_position
|> form_at_position |> form_at_position
|> embed_at_position |> embed_at_position
|> quiz_at_position
{:ok, socket, temporary_assigns: []} {:ok, socket, temporary_assigns: []}
end end
@@ -201,6 +202,20 @@ defmodule ClaperWeb.EventLive.Presenter do
|> update(:current_embed, fn _current_embed -> nil end)} |> update(:current_embed, fn _current_embed -> nil end)}
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 @impl true
def handle_info({:chat_visible, value}, socket) do def handle_info({:chat_visible, value}, socket) do
{:noreply, {:noreply,
@@ -249,7 +264,8 @@ defmodule ClaperWeb.EventLive.Presenter do
socket socket
|> assign(:current_poll, interaction) |> assign(:current_poll, interaction)
|> assign(:current_embed, nil) |> assign(:current_embed, nil)
|> assign(:current_form, nil)} |> assign(:current_form, nil)
|> assign(:current_quiz, nil)}
end end
@impl true @impl true
@@ -261,7 +277,8 @@ defmodule ClaperWeb.EventLive.Presenter do
socket socket
|> assign(:current_embed, interaction) |> assign(:current_embed, interaction)
|> assign(:current_poll, nil) |> assign(:current_poll, nil)
|> assign(:current_form, nil)} |> assign(:current_form, nil)
|> assign(:current_quiz, nil)}
end end
@impl true @impl true
@@ -273,7 +290,21 @@ defmodule ClaperWeb.EventLive.Presenter do
socket socket
|> assign(:current_form, interaction) |> assign(:current_form, interaction)
|> assign(:current_poll, nil) |> 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 end
@impl true @impl true
@@ -285,7 +316,61 @@ defmodule ClaperWeb.EventLive.Presenter do
socket socket
|> assign(:current_poll, nil) |> assign(:current_poll, nil)
|> assign(:current_embed, 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 end
@impl true @impl true
@@ -332,6 +417,16 @@ defmodule ClaperWeb.EventLive.Presenter do
end end
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 defp list_posts(_socket, event_id) do
Claper.Posts.list_posts(event_id, [:event, :reactions]) Claper.Posts.list_posts(event_id, [:event, :reactions])
end end

View File

@@ -77,6 +77,15 @@
</div> </div>
</div> </div>
<% end %> <% end %>
<!-- QUIZ -->
<%= if @current_quiz do %>
<.live_component
module={ClaperWeb.EventLive.ManageableQuizComponent}
id={"#{@current_quiz.id}-quiz"}
quiz={@current_quiz}
iframe={@iframe}
/>
<% end %>
<!-- MESSAGES --> <!-- MESSAGES -->
<div <div
id="slider-wrapper" id="slider-wrapper"

View 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

View File

@@ -2,7 +2,7 @@ defmodule ClaperWeb.EventLive.Show do
alias Claper.Interactions alias Claper.Interactions
use ClaperWeb, :live_view use ClaperWeb, :live_view
alias Claper.{Posts, Polls, Forms} alias Claper.{Posts, Polls, Forms, Quizzes, Stats}
alias ClaperWeb.Presence alias ClaperWeb.Presence
on_mount(ClaperWeb.AttendeeLiveAuth) on_mount(ClaperWeb.AttendeeLiveAuth)
@@ -63,25 +63,26 @@ defmodule ClaperWeb.EventLive.Show do
socket.assigns.attendee_identifier, socket.assigns.attendee_identifier,
%{} %{}
) )
online = Presence.list("event:#{event.uuid}") |> Enum.count()
update_stats(socket, event)
maybe_update_audience_peak(event, online)
end end
post_changeset = Posts.Post.changeset(%Posts.Post{}, %{}) 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) posts = list_posts(socket, event.uuid)
socket = socket =
socket socket
|> assign(:attendees_nb, 1) |> assign(:attendees_nb, 1)
|> assign(:post_changeset, post_changeset) |> assign(:post_changeset, post_changeset)
|> assign(:liked_posts, reacted_posts(socket, event.id, "👍")) |> assign(:like_posts, reacted_posts(socket, event.id, "👍"))
|> assign(:loved_posts, reacted_posts(socket, event.id, "❤️")) |> assign(:love_posts, reacted_posts(socket, event.id, "❤️"))
|> assign(:loled_posts, reacted_posts(socket, event.id, "😂")) |> assign(:lol_posts, reacted_posts(socket, event.id, "😂"))
|> assign(:selected_poll_opt, []) |> assign(:selected_poll_opt, [])
|> assign(:poll_opt_saved, false) |> assign(:selected_quiz_question_opts, [])
|> assign(:current_quiz_question_idx, 0)
|> assign(:event, event) |> assign(:event, event)
|> assign(:state, event.presentation_file.presentation_state) |> assign(:state, event.presentation_file.presentation_state)
|> assign(:nickname, "") |> assign(:nickname, "")
@@ -316,6 +317,20 @@ defmodule ClaperWeb.EventLive.Show do
|> update(:current_embed, fn _current_embed -> nil end)} |> update(:current_embed, fn _current_embed -> nil end)}
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 @impl true
def handle_info({:react, type}, socket) do def handle_info({:react, type}, socket) do
{:noreply, {:noreply,
@@ -431,9 +446,14 @@ defmodule ClaperWeb.EventLive.Show do
) )
when is_map(current_user) do when is_map(current_user) do
case type 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_reaction(socket, post_id, %{icon: type, user_id: current_user.id}, :like)}
"😂" -> {: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}, :love)}
"😂" ->
{:noreply, add_reaction(socket, post_id, %{icon: type, user_id: current_user.id}, :lol)}
end end
end end
@@ -446,15 +466,30 @@ defmodule ClaperWeb.EventLive.Show do
case type do case type do
"👍" -> "👍" ->
{:noreply, {: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, {: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, {: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
end end
@@ -467,13 +502,16 @@ defmodule ClaperWeb.EventLive.Show do
when is_map(current_user) do when is_map(current_user) do
case type 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
end end
@@ -486,15 +524,30 @@ defmodule ClaperWeb.EventLive.Show do
case type do case type do
"👍" -> "👍" ->
{:noreply, {: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, {: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, {: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
end end
@@ -570,6 +623,108 @@ defmodule ClaperWeb.EventLive.Show do
end end
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 def toggle_side_menu(js \\ %JS{}) do
js js
|> JS.toggle( |> JS.toggle(
@@ -594,51 +749,25 @@ defmodule ClaperWeb.EventLive.Show do
) )
end 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]), with post <- Posts.get_post!(post_id, [:event]),
{:ok, _} <- Posts.create_reaction(Map.merge(params, %{post: post})) do {:ok, _} <- Posts.create_reaction(Map.merge(params, %{post: post})) do
{:ok, _} = Posts.update_post(post, %{like_count: post.like_count + 1}) count_field = String.to_atom("#{type}_count")
update(socket, :liked_posts, fn liked_posts -> [post.id | liked_posts] end) 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
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]), with post <- Posts.get_post!(post_id, [:event]),
{:ok, _} <- Posts.delete_reaction(Map.merge(params, %{post: post})) do {:ok, _} <- Posts.delete_reaction(Map.merge(params, %{post: post})) do
{:ok, _} = Posts.update_post(post, %{like_count: post.like_count - 1}) count_field = String.to_atom("#{type}_count")
update(socket, :liked_posts, fn liked_posts -> List.delete(liked_posts, post.id) end) posts_field = String.to_atom("#{type}_posts")
end
end
defp add_post_love(socket, post_id, params) do {:ok, _} = Posts.update_post(post, %{count_field => Map.get(post, count_field) - 1})
with post <- Posts.get_post!(post_id, [:event]), update(socket, posts_field, fn posts -> List.delete(posts, post.id) end)
{: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)
end end
end end
@@ -671,6 +800,26 @@ defmodule ClaperWeb.EventLive.Show do
socket |> assign(:current_form_submit, fs) socket |> assign(:current_form_submit, fs)
end 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( defp reacted_posts(
%{assigns: %{current_user: current_user} = _assigns} = _socket, %{assigns: %{current_user: current_user} = _assigns} = _socket,
event_id, event_id,
@@ -717,6 +866,22 @@ defmodule ClaperWeb.EventLive.Show do
socket |> assign(:current_interaction, interaction) |> get_current_form_submit(interaction.id) socket |> assign(:current_interaction, interaction) |> get_current_form_submit(interaction.id)
end 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 defp load_current_interaction(socket, interaction, _same_interaction) do
socket |> assign(:current_interaction, interaction) socket |> assign(:current_interaction, interaction)
end end
@@ -728,4 +893,16 @@ defmodule ClaperWeb.EventLive.Show do
defp maybe_reset_selected_poll_opt(socket, _same_interaction) do defp maybe_reset_selected_poll_opt(socket, _same_interaction) do
socket |> assign(:selected_poll_opt, []) socket |> assign(:selected_poll_opt, [])
end 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 end

View File

@@ -111,6 +111,26 @@
/> />
</div> </div>
</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 --> <!-- Handle any other types of interactions here if needed -->
<% end %> <% end %>
@@ -134,9 +154,9 @@
attendee_identifier={@attendee_identifier} attendee_identifier={@attendee_identifier}
event={@event} event={@event}
reaction_enabled={@state.message_reaction_enabled} reaction_enabled={@state.message_reaction_enabled}
liked_posts={@liked_posts} liked_posts={@like_posts}
loved_posts={@loved_posts} loved_posts={@love_posts}
loled_posts={@loled_posts} loled_posts={@lol_posts}
/> />
</div> </div>

View 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

View 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>

View File

@@ -13,10 +13,24 @@ defmodule ClaperWeb.StatLive.Index do
event = event =
Events.get_managed_event!(socket.assigns.current_user, id, 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) distinct_poster_count = Claper.Stats.distinct_poster_count(event.id)
{:ok, {:ok,
@@ -27,15 +41,15 @@ defmodule ClaperWeb.StatLive.Index do
distinct_poster_count distinct_poster_count
) )
|> assign( |> assign(
:grouped_total_votes, :distinct_attendee_count,
grouped_total_votes distinct_attendee_count
) )
|> assign(:average_voters, average_voters(grouped_total_votes))
|> assign( |> assign(
:engagement_rate, :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 end
@impl true @impl true
@@ -45,37 +59,57 @@ defmodule ClaperWeb.StatLive.Index do
defp apply_action(socket, :index, _params) do defp apply_action(socket, :index, _params) do
socket socket
|> assign(:page_title, "Report") |> assign(:page_title, gettext("Report"))
end end
defp calculate_engagement_rate(grouped_total_votes, distinct_poster_count, event) do @impl true
total_polls = Enum.count(grouped_total_votes) def handle_event("change_tab", %{"tab" => tab}, socket) do
{:noreply, assign(socket, :current_tab, String.to_existing_atom(tab))}
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
end end
defp average_voters(grouped_total_votes) do defp calculate_engagement_rate(event, unique_attendees) do
total_polls = Enum.count(grouped_total_votes) 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 (total / 4 * 100)
0 |> Float.round()
else |> :erlang.float_to_binary(decimals: 0)
(Enum.sum(grouped_total_votes) / total_polls) |> :erlang.binary_to_integer()
|> Float.round() end
|> :erlang.float_to_binary(decimals: 0)
|> :erlang.binary_to_integer() defp average_messages(_event, 0), do: 0
end
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 end
defp list_posts(_socket, event_id) do defp list_posts(_socket, event_id) do

View File

@@ -21,16 +21,16 @@
<div class="absolute bg-primary-500 rounded-md p-3"> <div class="absolute bg-primary-500 rounded-md p-3">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-white"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2" stroke-width="2"
stroke="currentColor"
class="h-6 w-6 text-white"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="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> </svg>
</div> </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"> <div class="relative bg-white pt-5 px-4 sm:pt-6 sm:px-6 pb-4 shadow rounded-lg overflow-hidden">
<dt> <dt>
<div class="absolute bg-primary-500 rounded-md p-3"> <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 <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-white" 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"> <div class="relative bg-white pt-5 px-4 sm:pt-6 sm:px-6 pb-4 shadow rounded-lg overflow-hidden">
<dt> <dt>
<div class="absolute bg-primary-500 rounded-md p-3"> <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 <svg
class="h-6 w-6 text-white" class="h-6 w-6 text-white"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -142,12 +135,31 @@
/> />
</svg> </svg>
</div> </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") %> <%= gettext("Engagement rate") %>
</p> </p>
</dt> </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> <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> </dd>
</div> </div>
</dl> </dl>
@@ -155,137 +167,309 @@
<div class="pt-5 pb-5"> <div class="pt-5 pb-5">
<h3 class="text-lg leading-6 font-medium text-gray-900 mb-4"> <h3 class="text-lg leading-6 font-medium text-gray-900 mb-4">
<%= gettext("Interactions history") %> <%= gettext("Interactions") %>
</h3> </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 %> <div class="border-b border-gray-200">
<% total = Enum.map(poll.poll_opts, fn e -> e.vote_count end) |> Enum.sum() %> <nav class="-mb-px flex space-x-8" aria-label="Tabs">
<div class="bg-black w-full py-3 px-6 my-5 text-black shadow-lg rounded-md"> <button
<div class="block w-full h-full cursor-pointer"> phx-click="change_tab"
<p class="text-white text-lg font-semibold mb-4"><%= poll.title %></p> phx-value-tab="messages"
</div> 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'}"}
<div> >
<div class="flex flex-col space-y-3"> <%= gettext("Messages") %>
<%= if (length poll.poll_opts) > 0 do %> </button>
<%= for {opt, idx} <- Enum.with_index(poll.poll_opts) do %> <button
<% percentage = phx-click="change_tab"
if total > 0, phx-value-tab="polls"
do: 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'}"}
Float.round(opt.vote_count / total * 100) >
|> :erlang.float_to_binary(decimals: 0), <%= gettext("Polls") %>
else: 0 %> </button>
<button <button
id={"poll-opt-#{idx}"} phx-click="change_tab"
class="bg-gray-500 px-3 py-2 rounded-3xl flex justify-between items-center relative text-white" 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'}"}
<div >
style={"width: #{percentage}%;"} <%= gettext("Forms") %>
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"}"} </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> <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" />
<div class="flex space-x-3 items-center z-10 text-left"> </svg>
<span class="flex-1 pr-2"><%= opt.content %></span> <span><%= gettext("Export") %> (CSV)</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>
<% end %> <% end %>
</div> </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 %>
</div> <% else %>
<% end %> <p class="italic text-gray-500"><%= gettext("No poll has been created") %></p>
<% end %> <% 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 %> <%= if length(form.form_submits) > 0 do %>
<span class="text-xl font-semibold text-gray-900 mb-4"> <%= 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 %>
<%= gettext("Web content") %>: <%= embed.title %> <svg
</span> xmlns="http://www.w3.org/2000/svg"
<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"> viewBox="0 0 24 24"
<div class="flex space-x-3 items-center"> fill="none"
<%= raw(embed.content) %> 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>
</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) %> <div class="flex flex-col space-y-3 overflow-y-auto max-h-[500px]">
<%= for {question, _idx} <- Enum.with_index(quiz.quiz_questions) do %>
<span class="text-xl font-semibold text-gray-900 mb-4"> <div class="border-t border-gray-700 pt-4 mt-4 first:border-t-0 first:pt-0 first:mt-0">
<%= gettext("Messages") %> <p class="text-white text-lg font-medium mb-3">
</span> <%= question.content %>
</p>
<%= if length(posts) == 0 do %> <div class="space-y-2">
<p class="italic text-gray-500"><%= gettext("No messages has been sent") %></p> <%= for opt <- question.quiz_question_opts do %>
<% end %> <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="h-64 pb-5 px-2 overflow-y-auto"> <div class="flex items-center text-left space-x-3">
<.live_component <%= if opt.is_correct do %>
:for={post <- posts} <div class="h-5 w-5 mt-0.5 rounded-md point-select bg-white">
module={ClaperWeb.EventLive.ManageablePostComponent} </div>
readonly={true} <% else %>
id={post.uuid} <div class="h-5 w-5 mt-0.5 rounded-md point-select border-2 border-white">
event={@event} </div>
post={post} <% end %>
/> <span class="flex-1 pr-2"><%= opt.content %></span>
</div> </div>
</div> <span class="text-sm">
<% end %> <%= 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>
</div> </div>

View File

@@ -51,7 +51,11 @@ defmodule ClaperWeb.Router do
scope "/", ClaperWeb do scope "/", ClaperWeb do
pipe_through([:browser, :require_authenticated_user]) 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", EventLive.Index, :index)
live("/events/new", EventLive.Index, :new) 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/edit/form/:id", EventLive.Manage, :edit_form)
live("/e/:code/manage/add/embed", EventLive.Manage, :add_embed) 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/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
end end
@@ -141,7 +147,6 @@ defmodule ClaperWeb.Router do
post("/lti/login", Lti.LaunchController, :login) post("/lti/login", Lti.LaunchController, :login)
get("/lti/login", Lti.LaunchController, :login) get("/lti/login", Lti.LaunchController, :login)
post("/lti/launch", Lti.LaunchController, :launch) post("/lti/launch", Lti.LaunchController, :launch)
get("/lti/grades", Lti.GradeController, :create)
end end
scope "/", ClaperWeb do scope "/", ClaperWeb do

View File

@@ -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>

View File

@@ -33,11 +33,15 @@
<input type="hidden" name="openid_configuration" value={@conf} /> <input type="hidden" name="openid_configuration" value={@conf} />
<input type="hidden" name="registration_token" value={@token} /> <input type="hidden" name="registration_token" value={@token} />
<button <button
:if={@current_user}
type="submit" 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" 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") %> <%= gettext("Add Claper") %>
</button> </button>
<p :if={!@current_user} class="text-white italic">
<%= gettext("You must login to continue") %>
</p>
</form> </form>
</div> </div>
</div> </div>

View File

@@ -133,6 +133,30 @@ defmodule ClaperWeb.Component.Input do
""" """
end 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(is_checked, key, js \\ %JS{})
def checked(false, key, js) do def checked(false, key, js) do

View 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

View File

@@ -5,6 +5,7 @@ defmodule Lti13.Registrations.Registration do
@type t :: %__MODULE__{ @type t :: %__MODULE__{
id: integer(), id: integer(),
issuer: String.t() | nil, issuer: String.t() | nil,
user_id: integer() | nil,
client_id: String.t() | nil, client_id: String.t() | nil,
key_set_url: String.t() | nil, key_set_url: String.t() | nil,
auth_token_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 has_many :deployments, Lti13.Deployments.Deployment
belongs_to :tool_jwk, Lti13.Jwks.Jwk, foreign_key: :tool_jwk_id belongs_to :tool_jwk, Lti13.Jwks.Jwk, foreign_key: :tool_jwk_id
belongs_to :user, Claper.Accounts.User
timestamps() timestamps()
end end
@@ -34,6 +36,7 @@ defmodule Lti13.Registrations.Registration do
registration registration
|> cast(attrs, [ |> cast(attrs, [
:issuer, :issuer,
:user_id,
:client_id, :client_id,
:key_set_url, :key_set_url,
:auth_token_url, :auth_token_url,
@@ -43,6 +46,7 @@ defmodule Lti13.Registrations.Registration do
]) ])
|> validate_required([ |> validate_required([
:issuer, :issuer,
:user_id,
:client_id, :client_id,
:key_set_url, :key_set_url,
:auth_token_url, :auth_token_url,

View File

@@ -24,14 +24,17 @@ defmodule Lti13.Resources do
Creates a resource and event with the given title and resource_id Creates a resource and event with the given title and resource_id
## Examples ## 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{}} {:ok, %Claper.Events.Event{}, %Lti13.Resources.Resource{}}
iex> create_resource_with_event(%{}) iex> create_resource_with_event(%{})
{:error, %{reason: :invalid_resource, msg: "Failed to create resource"}} {:error, %{reason: :invalid_resource, msg: "Failed to create resource"}}
""" """
@spec create_resource_with_event(map()) :: def create_resource_with_event(%{
{:ok, Resource.t()} | {:error, map()} title: title,
def create_resource_with_event(%{title: title, resource_id: resource_id, lti_user: lti_user}) do resource_id: resource_id,
line_items_url: line_items_url,
lti_user: lti_user
}) do
with {:ok, event} <- with {:ok, event} <-
Claper.Events.create_event(%{ Claper.Events.create_event(%{
name: title, name: title,
@@ -49,6 +52,7 @@ defmodule Lti13.Resources do
create_resource(%{ create_resource(%{
title: title, title: title,
resource_id: resource_id, resource_id: resource_id,
line_items_url: line_items_url,
event_id: event.id, event_id: event.id,
registration_id: lti_user.registration_id registration_id: lti_user.registration_id
}) do }) do

View File

@@ -8,6 +8,7 @@ defmodule Lti13.Resources.Resource do
resource_id: integer() | nil, resource_id: integer() | nil,
event_id: integer(), event_id: integer(),
registration_id: integer(), registration_id: integer(),
line_items_url: String.t() | nil,
inserted_at: NaiveDateTime.t(), inserted_at: NaiveDateTime.t(),
updated_at: NaiveDateTime.t() updated_at: NaiveDateTime.t()
} }
@@ -15,6 +16,7 @@ defmodule Lti13.Resources.Resource do
schema "lti_13_resources" do schema "lti_13_resources" do
field :title, :string field :title, :string
field :resource_id, :integer field :resource_id, :integer
field :line_items_url, :string
belongs_to :event, Claper.Events.Event belongs_to :event, Claper.Events.Event
belongs_to :registration, Lti13.Registrations.Registration belongs_to :registration, Lti13.Registrations.Registration
@@ -25,7 +27,7 @@ defmodule Lti13.Resources.Resource do
@doc false @doc false
def changeset(registration, attrs \\ %{}) do def changeset(registration, attrs \\ %{}) do
registration 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]) |> validate_required([:title, :resource_id, :event_id, :registration_id])
end end
end end

View File

@@ -81,17 +81,14 @@ defmodule Lti13.Tool.LaunchValidation do
end end
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( defp validate_resource(
%{ %{
"https://purl.imsglobal.org/spec/lti/claim/custom" => %{ "https://purl.imsglobal.org/spec/lti/claim/custom" => %{
"resource_title" => title, "resource_title" => title,
"resource_id" => resource_id "resource_id" => resource_id
},
"https://purl.imsglobal.org/spec/lti-ags/claim/endpoint" => %{
"lineitems" => line_items_url
} }
}, },
lti_user, lti_user,
@@ -99,15 +96,16 @@ defmodule Lti13.Tool.LaunchValidation do
is_instructor is_instructor
) do ) do
case Lti13.Resources.get_resource_by_id_and_registration(resource_id, registration.id) 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) resource -> handle_existing_resource(resource, lti_user, is_instructor)
end end
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(%{ case Lti13.Resources.create_resource_with_event(%{
title: title, title: title,
resource_id: resource_id, resource_id: resource_id,
line_items_url: line_items_url,
lti_user: lti_user lti_user: lti_user
}) do }) do
{:ok, resource} -> {:ok, resource} {:ok, resource} -> {:ok, resource}
@@ -115,9 +113,6 @@ defmodule Lti13.Tool.LaunchValidation do
end end
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 defp handle_existing_resource(resource, lti_user, true) do
maybe_create_activity_leader(resource, lti_user) maybe_create_activity_leader(resource, lti_user)
{:ok, resource} {:ok, resource}

View File

@@ -13,8 +13,6 @@ defmodule Lti13.Tool.Services.AccessToken do
scope: String.t() scope: String.t()
} }
require Logger
@doc """ @doc """
Requests an OAuth2 access token. Returns {:ok, %AccessToken{}} on success, {:error, error} Requests an OAuth2 access token. Returns {:ok, %AccessToken{}} on success, {:error, error}
otherwise. otherwise.
@@ -39,7 +37,6 @@ defmodule Lti13.Tool.Services.AccessToken do
iex> fetch_access_token(bad_tool) iex> fetch_access_token(bad_tool)
{:error, "invalid_scope"} {:error, "invalid_scope"}
""" """
def fetch_access_token( def fetch_access_token(
%{auth_token_url: auth_token_url, client_id: client_id, auth_server: auth_audience}, %{auth_token_url: auth_token_url, client_id: client_id, auth_server: auth_audience},
scopes, scopes,
@@ -55,6 +52,21 @@ defmodule Lti13.Tool.Services.AccessToken do
request_token(auth_token_url, client_assertion, scopes) request_token(auth_token_url, client_assertion, scopes)
end 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 defp request_token(url, client_assertion, scopes) do
body = body =
[ [
@@ -67,24 +79,22 @@ defmodule Lti13.Tool.Services.AccessToken do
headers = %{"Content-Type" => "application/x-www-form-urlencoded"} 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}} <- with {:ok, %Req.Response{status: 200, body: body}} <-
Req.post(url, body: body, headers: headers), Req.post(url, body: body, headers: headers),
{:ok, result} <- body do {:ok, parsed_body} <- Jason.decode(body) do
{:ok, {:ok,
%__MODULE__{ %__MODULE__{
access_token: Map.get(result, "access_token"), access_token: Map.get(parsed_body, "access_token"),
token_type: Map.get(result, "token_type"), token_type: Map.get(parsed_body, "token_type"),
expires_in: Map.get(result, "expires_in"), expires_in: Map.get(parsed_body, "expires_in"),
scope: Map.get(result, "scope") scope: Map.get(parsed_body, "scope")
}} }}
else else
{:error, %Jason.DecodeError{}} ->
{:error, "Invalid JSON response"}
e -> e ->
Logger.error("Error encountered fetching access token #{inspect(e)}") {:error, "Error fetching access token: #{inspect(e)}"}
{:error, "Error fetching access token"}
end end
end end

View File

@@ -14,14 +14,10 @@ defmodule Lti13.Tool.Services.AGS do
alias Lti13.Tool.Services.AGS.LineItem alias Lti13.Tool.Services.AGS.LineItem
alias Lti13.Tool.Services.AccessToken alias Lti13.Tool.Services.AccessToken
require Logger
@doc """ @doc """
Post a score to an existing line item, using an already acquired access token. 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 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!() body = score |> Jason.encode!()
case Req.post( 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, %Req.Response{status: code, body: body}} when code in [200, 201] ->
{:ok, body} {:ok, body}
e -> _e ->
Logger.error(
"Error encountered posting score for user #{score.userId} for line item '#{line_item.label}' #{inspect(e)}"
)
{:error, "Error posting score"} {:error, "Error posting score"}
end end
end end
@@ -53,58 +45,53 @@ defmodule Lti13.Tool.Services.AGS do
label, label,
%AccessToken{} = access_token %AccessToken{} = access_token
) do ) 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] <- with {:ok, %Req.Response{status: code, body: body}} when code in [200, 201] <-
Req.get(request_url, headers: headers(access_token)), Req.get(line_items_service_url, headers: headers(access_token)),
{:ok, result} <- Jason.decode(body) do result <- if(is_binary(body), do: Jason.decode!(body), else: body) do
case result do process_line_items(
[] -> result,
Logger.info("fetch_or_create_line_item #{resource_id} #{label}") line_items_service_url,
resource_id,
create_line_item( maximum_score_provider,
line_items_service_url, label,
resource_id, access_token
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
else else
e -> _error -> {:error, "Error retrieving existing line items"}
Logger.error( end
"Error encountered fetching line item for #{resource_id} #{label}: #{inspect(e)}" 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
end end
@@ -118,21 +105,18 @@ defmodule Lti13.Tool.Services.AGS do
end end
def fetch_line_items(line_items_service_url, %AccessToken{} = access_token) do 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 # 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 # 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 # bypasses this default limit, of course, and works in all cases until a course more than
# a thousand grade book entries. # a thousand grade book entries.
url = build_url_with_params(line_items_service_url, "limit=1000") url = build_url_with_params(line_items_service_url, "limit=1000")
with {:ok, %Req.Response{status: 200, body: body}} <- case Req.get(url, headers: headers(access_token)) do
Req.get(url, headers: headers(access_token)), {:ok, %Req.Response{status: 200, body: body}} ->
{:ok, results} <- Jason.decode(body) do results = if is_binary(body), do: Jason.decode!(body), else: body
{:ok, Enum.map(results, fn r -> to_line_item(r) end)} {:ok, Enum.map(results, fn r -> to_line_item(r) end)}
else
e -> _e ->
Logger.error("Error encountered fetching line items from #{url} #{inspect(e)}")
{:error, "Error retrieving all line items"} {:error, "Error retrieving all line items"}
end end
end end
@@ -148,8 +132,6 @@ defmodule Lti13.Tool.Services.AGS do
label, label,
%AccessToken{} = access_token %AccessToken{} = access_token
) do ) do
Logger.info("Create line item for #{resource_id} #{label}")
line_item = %LineItem{ line_item = %LineItem{
scoreMaximum: score_maximum, scoreMaximum: score_maximum,
resourceId: LineItem.to_resource_id(resource_id), resourceId: LineItem.to_resource_id(resource_id),
@@ -158,16 +140,12 @@ defmodule Lti13.Tool.Services.AGS do
body = line_item |> Jason.encode!() body = line_item |> Jason.encode!()
with {:ok, %Req.Response{status: code, body: body}} when code in [200, 201] <- case Req.post(line_items_service_url, body: body, headers: headers(access_token)) do
Req.post(line_items_service_url, body: body, headers: headers(access_token)), {:ok, %Req.Response{status: code, body: body}} when code in [200, 201] ->
{:ok, result} <- Jason.decode(body) do result = if is_binary(body), do: Jason.decode!(body), else: body
{:ok, to_line_item(result)} {:ok, to_line_item(result)}
else
e ->
Logger.error(
"Error encountered creating line item for #{resource_id} #{label}: #{inspect(e)}"
)
_ ->
{:error, "Error creating new line item"} {:error, "Error creating new line item"}
end end
end end
@@ -177,8 +155,6 @@ defmodule Lti13.Tool.Services.AGS do
a {:ok, line_item} tuple. On error, returns a {:error, error} tuple. a {:ok, line_item} tuple. On error, returns a {:error, error} tuple.
""" """
def update_line_item(%LineItem{} = line_item, changes, %AccessToken{} = access_token) do 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{ updated_line_item = %LineItem{
id: line_item.id, id: line_item.id,
scoreMaximum: Map.get(changes, :scoreMaximum, line_item.scoreMaximum), 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 to use is the id of the line item
url = line_item.id url = line_item.id
with {:ok, %Req.Response{status: 200, body: body}} <- case Req.put(url, body: body, headers: headers(access_token)) do
Req.put(url, body: body, headers: headers(access_token)), {:ok, %Req.Response{status: 200, body: body}} ->
{:ok, result} <- body do result = if is_binary(body), do: Jason.decode!(body), else: body
{:ok, to_line_item(result)} {:ok, to_line_item(result)}
else
e ->
Logger.error(
"Error encountered updating line item #{line_item.id} for changes #{inspect(changes)}: #{inspect(e)}"
)
_e ->
{:error, "Error updating existing line item"} {:error, "Error updating existing line item"}
end end
end end

View File

@@ -1,7 +1,7 @@
defmodule Claper.MixProject do defmodule Claper.MixProject do
use Mix.Project use Mix.Project
@version "2.2.0" @version "2.3.0"
def project do def project do
[ [
@@ -114,7 +114,8 @@ defmodule Claper.MixProject do
{:jose, "~> 1.11"}, {:jose, "~> 1.11"},
{:req, "~> 0.5.0"}, {:req, "~> 0.5.0"},
{:uuid, "~> 1.1"}, {:uuid, "~> 1.1"},
{:oidcc, "~> 3.2"} {:oidcc, "~> 3.2"},
{:oban, "~> 2.17"}
] ]
end end

View File

@@ -52,6 +52,7 @@
"nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"}, "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_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "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"}, "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"}, "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"}, "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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View File

@@ -39,10 +39,7 @@ defmodule Claper.AccountsTest do
end end
test "sends magic link through notification", %{user: user} do test "sends magic link through notification", %{user: user} do
token = {:ok, token} = Accounts.deliver_magic_link(user.email, &"/users/magic/#{&1}")
extract_magic_token(fn url ->
Accounts.deliver_magic_link(user.email, url)
end)
{:ok, token} = Base.url_decode64(token, padding: false) {:ok, token} = Base.url_decode64(token, padding: false)
@@ -142,10 +139,12 @@ defmodule Claper.AccountsTest do
end end
test "sends token through notification", %{user: user} do test "sends token through notification", %{user: user} do
token = {:ok, token} =
extract_user_token(fn url -> Accounts.deliver_update_email_instructions(
Accounts.deliver_update_email_instructions(user, "current@example.com", url) user,
end) "current@example.com",
&"/users/settings/confirm_email/#{&1}"
)
{:ok, token} = Base.url_decode64(token, padding: false) {:ok, token} = Base.url_decode64(token, padding: false)
assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
@@ -160,10 +159,12 @@ defmodule Claper.AccountsTest do
user = user_fixture() user = user_fixture()
email = unique_user_email() email = unique_user_email()
token = {:ok, token} =
extract_user_token(fn url -> Accounts.deliver_update_email_instructions(
Accounts.deliver_update_email_instructions(%{user | email: email}, user.email, url) %{user | email: email},
end) user.email,
&"/users/settings/confirm_email/#{&1}"
)
%{user: user, token: token, email: email} %{user: user, token: token, email: email}
end end
@@ -266,10 +267,8 @@ defmodule Claper.AccountsTest do
end end
test "sends token through notification", %{user: user} do test "sends token through notification", %{user: user} do
token = {:ok, token} =
extract_user_token(fn url -> Accounts.deliver_user_confirmation_instructions(user, &"/users/confirm/#{&1}")
Accounts.deliver_user_confirmation_instructions(user, url)
end)
{:ok, token} = Base.url_decode64(token, padding: false) {:ok, token} = Base.url_decode64(token, padding: false)
assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token)) assert user_token = Repo.get_by(UserToken, token: :crypto.hash(:sha256, token))
@@ -283,10 +282,8 @@ defmodule Claper.AccountsTest do
setup do setup do
user = user_fixture() user = user_fixture()
token = {:ok, token} =
extract_user_token(fn url -> Accounts.deliver_user_confirmation_instructions(user, &"/users/confirm/#{&1}")
Accounts.deliver_user_confirmation_instructions(user, url)
end)
%{user: user, token: token} %{user: user, token: token}
end end

View 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

View File

@@ -53,10 +53,8 @@ defmodule ClaperWeb.UserConfirmationControllerTest do
describe "POST /users/confirm/:token" do describe "POST /users/confirm/:token" do
test "confirms the given token once", %{conn: conn, user: user} do test "confirms the given token once", %{conn: conn, user: user} do
token = {:ok, token} =
extract_user_token(fn url -> Accounts.deliver_user_confirmation_instructions(user, &"/users/confirm/#{&1}")
Accounts.deliver_user_confirmation_instructions(user, url)
end)
conn = get(conn, ~p"/users/confirm/#{token}") conn = get(conn, ~p"/users/confirm/#{token}")
assert redirected_to(conn) == ~p"/users/log_in" assert redirected_to(conn) == ~p"/users/log_in"

View File

@@ -4,10 +4,12 @@ defmodule Lti13.RegistrationsTest do
alias Lti13.Registrations alias Lti13.Registrations
import Lti13.JwksFixtures import Lti13.JwksFixtures
import Claper.AccountsFixtures
describe "registrations" do describe "registrations" do
test "create and get registration by issuer client id" do test "create and get registration by issuer client id" do
jwk = jwk_fixture() jwk = jwk_fixture()
user = confirmed_user_fixture()
registration = %{ registration = %{
issuer: "some issuer", issuer: "some issuer",
@@ -16,6 +18,7 @@ defmodule Lti13.RegistrationsTest do
auth_token_url: "some auth_token_url", auth_token_url: "some auth_token_url",
auth_login_url: "some auth_login_url", auth_login_url: "some auth_login_url",
auth_server: "some auth_server", auth_server: "some auth_server",
user_id: user.id,
tool_jwk_id: jwk.id tool_jwk_id: jwk.id
} }

View File

@@ -80,6 +80,7 @@ defmodule Lti13.ResourcesTest do
attrs = %{ attrs = %{
title: "Resource 1", title: "Resource 1",
resource_id: 1, resource_id: 1,
line_items_url: "https://example.com/line_items",
lti_user: lti_user lti_user: lti_user
} }
@@ -104,6 +105,7 @@ defmodule Lti13.ResourcesTest do
attrs = %{ attrs = %{
title: nil, title: nil,
resource_id: nil, resource_id: nil,
line_items_url: nil,
lti_user: lti_user lti_user: lti_user
} }

View File

@@ -18,6 +18,7 @@ defmodule Claper.DataCase do
using do using do
quote do quote do
use Oban.Testing, repo: Claper.Repo
alias Claper.Repo alias Claper.Repo
import Ecto import Ecto

View File

@@ -39,16 +39,4 @@ defmodule Claper.AccountsFixtures do
user user
end 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 end

View File

@@ -5,12 +5,14 @@ defmodule Lti13.RegistrationsFixtures do
""" """
import Lti13.JwksFixtures import Lti13.JwksFixtures
import Claper.AccountsFixtures
@doc """ @doc """
Generate a registration. Generate a registration.
""" """
def registration_fixture(attrs \\ %{}) do def registration_fixture(attrs \\ %{}) do
jwk = jwk_fixture() jwk = jwk_fixture()
user = confirmed_user_fixture()
{:ok, registration} = {:ok, registration} =
attrs attrs
@@ -21,7 +23,8 @@ defmodule Lti13.RegistrationsFixtures do
auth_login_url: "https://example.com/auth_login_url", auth_login_url: "https://example.com/auth_login_url",
key_set_url: "https://example.com/key_set_url", key_set_url: "https://example.com/key_set_url",
auth_server: "https://example.com/", 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() |> Lti13.Registrations.create_registration()

View 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