Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: improve enrolment checking UX #529

Merged
merged 6 commits into from
Feb 13, 2025
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat: create enrolment
ruioliveira02 committed Feb 12, 2025
commit 1a1292e63045ee14340d86509d2346d01b39994e
26 changes: 22 additions & 4 deletions lib/safira/activities.ex
Original file line number Diff line number Diff line change
@@ -5,6 +5,7 @@ defmodule Safira.Activities do

use Safira.Context

alias Safira.Accounts.{Attendee, User}
alias Safira.Activities.{Activity, ActivityCategory, Enrolment, Speaker}

@doc """
@@ -42,6 +43,17 @@ defmodule Safira.Activities do
|> Flop.validate_and_run(params, for: Activity)
end

def list_enrolled_attendees(activity_id, params \\ %{}, opts \\ []) do
User
|> join(:inner, [u], at in Attendee, on: u.id == at.user_id)
|> join(:inner, [u, at], e in Enrolment, on: e.attendee_id == at.id)
|> where([u, at, e], e.activity_id == ^activity_id)
|> select([u, at, e], u)
|> preload(:attendee)
|> apply_filters(opts)
|> Flop.validate_and_run(params, for: User)
end

@doc """
Returns the count of activities.

@@ -500,15 +512,21 @@ defmodule Safira.Activities do
iex> unenrol(attendee_id, activity_id)
{:error, :struct, %Ecto.Changeset{}, %{}}
"""
def unenrol(enrolment) do
def unenrol(activity_id, attendee_id) do
Ecto.Multi.new()
# We need to read the activity before updating the enrolment count to avoid
# a race condition where the enrolment count changes after the activity was last
# read from the database, and before this transaction began
|> Ecto.Multi.one(:activity, Activity |> where([a], a.id == ^enrolment.activity_id))
|> Ecto.Multi.delete(
|> Ecto.Multi.one(
:enrolment,
enrolment
Enrolment |> where([e], e.activity_id == ^activity_id and e.attendee_id == ^attendee_id)
)
|> Ecto.Multi.one(:activity, Activity |> where([a], a.id == ^activity_id))
|> Ecto.Multi.delete(
:deleted_enrolment,
fn %{enrolment: enrolment} ->
enrolment
end
)
|> Ecto.Multi.update(:new_activity, fn %{activity: act} ->
Activity.changeset(act, %{enrolment_count: act.enrolment_count - 1})
Original file line number Diff line number Diff line change
@@ -5,69 +5,40 @@ defmodule SafiraWeb.Backoffice.ScheduleLive.EnrolmentLive.FormComponent do
alias Safira.Accounts.User
alias Safira.Activities
alias Safira.Activities.Enrolment

import SafiraWeb.Components.Forms

@impl true
def render(assigns) do
~H"""
<div>
<.page title={@activity.title}>
<div class="py-8">
<div class="flex flex-row justify-between items-center">
<h2 class="font-semibold"><%= gettext("Enrolments") %></h2>
<.button phx-click={JS.push("add-enrolment", target: @myself)}>
<.icon name="hero-plus" class="w-5 h-5" />
</.button>
</div>

<ul class="h-[45vh] overflow-y-scroll scrollbar-hide mt-4 border-b-[1px] border-lightShade dark:border-darkShade">
<%= for {id, new, enrolment, form} <- @enrolments do %>
<li class="border-b-[1px] last:border-b-0 border-lightShade dark:border-darkShade">
<%= if new do %>
<.simple_form id={id} for={form} phx-change="validate" phx-target={@myself} class="">
<.field type="hidden" name="identifier" value={id} />
<.field type="hidden" field={form[:activity_id]} value={@activity.id} />
<div class="grid space-x-2 grid-cols-9 pl-1">
<.field_multiselect
id={"attendees-#{id}"}
field={form[:attendee_id]}
target={@myself}
value_mapper={&attendee_options/1}
wrapper_class="w-full col-span-8"
placeholder={gettext("Search for attendees")}
/>

<.link
phx-click={JS.push("delete-enrolment", value: %{id: id})}
data-confirm="Are you sure?"
phx-target={@myself}
class="content-center px-3"
>
<.icon name="hero-trash" class="w-5 h-5" />
</.link>
</div>
</.simple_form>
<% else %>
<div class="grid space-x-2 grid-cols-9 pl-1 my-3">
<p class="col-span-8"><%= enrolment.attendee.user.name %></p>
<.link
phx-click={JS.push("delete-enrolment", value: %{id: id})}
data-confirm="Are you sure?"
phx-target={@myself}
class="content-center px-3"
>
<.icon name="hero-trash" class="w-5 h-5" />
</.link>
</div>
<% end %>
</li>
<% end %>
</ul>
</div>
<div class="w-full flex flex-row-reverse">
<.button phx-click="save" phx-target={@myself} phx-disable-with="Saving...">
<%= gettext("Save Configuration") %>
</.button>
<.page title={@title} subtitle={@activity.title}>
<div class="pt-4 flex flex-col gap-2 h-[30.5rem]">
<.simple_form
id="enrolments-form"
for={@form}
phx-target={@myself}
phx-validate="validate"
phx-submit="save"
>
<.field
field={@form[:activity_id]}
type="string"
value={@activity.id}
wrapper_class="hidden"
required
/>
<.field_multiselect
mode={:single}
id="attendee"
field={@form[:attendee_id]}
target={@myself}
value_mapper={&value_mapper/1}
wrapper_class="w-full"
placeholder={gettext("Search for attendees")}
/>
<.button phx-disable-with="Saving...">Save</.button>
</.simple_form>
</div>
</.page>
</div>
@@ -80,54 +51,15 @@ defmodule SafiraWeb.Backoffice.ScheduleLive.EnrolmentLive.FormComponent do
end

@impl true
def update(assigns, socket) do
enrolments =
assigns.activity.enrolments
|> Enum.map(fn enrolment ->
{Ecto.UUID.generate(), false, enrolment, to_form(Activities.change_enrolment(enrolment))}
end)
def update(%{enrolment: enrolment} = assigns, socket) do
changeset = Activities.change_enrolment(enrolment)

{:ok,
socket
|> assign(assigns)
|> assign(:enrolments, enrolments)
|> assign(:attendees, Accounts.list_attendees())}
end

@impl true
def handle_event("validate", enrolment_params, socket) do
enrolments = socket.assigns.enrolments
enrolment = get_enrolment_data_by_id(enrolments, enrolment_params["identifier"])
changeset = Activities.change_enrolment(enrolment, enrolment_params["enrolment"])

# Update the form with the new changeset and the enrolment type if it changed
enrolments =
socket.assigns.enrolments
|> update_enrolment_form(
enrolment_params["identifier"],
to_form(changeset, action: :validate)
)

{:noreply,
socket
|> assign(enrolments: enrolments)}
end

@impl true
def handle_event("add-enrolment", _, socket) do
enrolments = socket.assigns.enrolments

# Add a new enrolment to the list
{:noreply,
socket
|> assign(
:enrolments,
enrolments ++
[
{Ecto.UUID.generate(), true, %Enrolment{},
to_form(Activities.change_enrolment(%Enrolment{}))}
]
)}
|> assign_new(:form, fn ->
to_form(changeset)
end)}
end

@impl true
@@ -149,64 +81,30 @@ defmodule SafiraWeb.Backoffice.ScheduleLive.EnrolmentLive.FormComponent do
end

@impl true
def handle_event("delete-enrolment", %{"id" => id}, socket) do
enrolments = socket.assigns.enrolments
# Find the enrolment to delete in the enrolments list
enrolment =
Enum.find(enrolments, fn {enrolment_id, _, _, _} -> enrolment_id == id end) |> elem(2)

# If the enrolment has an id, delete it from the database
if enrolment.id != nil do
Activities.unenrol(enrolment)
def handle_event(
"save",
%{"enrolment" => %{"activity_id" => activity_id, "attendee_id" => attendee_id}},
socket
) do
case Activities.enrol(attendee_id, activity_id) do
{:ok, _} ->
{:noreply,
socket
|> put_flash(:info, "Enrolled successfully")
|> push_patch(to: socket.assigns.patch)}

{:error, _, _, _} ->
{:noreply, socket |> put_flash(:error, "Unable to enrol")}
end

# Remove the enrolment from the list
{:noreply,
socket
|> assign(
enrolments: Enum.reject(enrolments, fn {enrolment_id, _, _, _} -> enrolment_id == id end)
)}
end

@impl true
def handle_event("save", _params, socket) do
enrolments = socket.assigns.enrolments

# Find if all the changesets are valid
valid_enrolments =
Enum.all?(enrolments, fn {_, _, _, form} -> form.source.valid? end) and
not (enrolments
|> Enum.map(fn {id, _, _, _} -> id end)
|> has_duplicates?())

if valid_enrolments do
# For each enrolment, update or create it
Enum.each(enrolments, fn {_, new, _enrolment, form} ->
if new, do: Activities.enrol(form.params["attendee_id"], form.params["activity_id"])
end)

{:noreply,
socket
|> put_flash(:info, "Enrolments changed successfully")
|> push_patch(to: socket.assigns.patch)}
else
{:noreply, socket}
end
end

def get_enrolment_data_by_id(enrolments, id) do
Enum.find(enrolments, &(elem(&1, 0) == id)) |> elem(2)
end

defp update_enrolment_form(enrolments, id, new_form) do
Enum.map(enrolments, fn
{^id, new, enrolment, _} -> {id, new, enrolment, new_form}
other -> other
end)
def handle_event("validate", %{"enrolment" => enrolment_params}, socket) do
changeset = Activities.change_enrolment(socket.assigns.enrolment, enrolment_params)
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
end

defp attendee_options(%User{} = user), do: {user.name, user.attendee.id}
defp attendee_options(id), do: id
defp value_mapper(%User{} = user), do: {user.name, user.attendee.id}

defp has_duplicates?(list), do: Enum.uniq(list) != list
defp value_mapper(id), do: id
end
139 changes: 139 additions & 0 deletions lib/safira_web/live/backoffice/schedule_live/enrolment_live/index.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
defmodule SafiraWeb.Backoffice.ScheduleLive.EnrolmentLive.Index do
use SafiraWeb, :live_component

alias Safira.Accounts
alias Safira.Accounts.User
alias Safira.Activities
alias Safira.Activities.Enrolment

import SafiraWeb.Components.{EnsurePermissions, Table, TableSearch}

@impl true
def render(assigns) do
~H"""
<div>
<.page title={@title}>
<:actions>
<.ensure_permissions user={@current_user} permissions={%{"enrolments" => ["edit"]}}>
<.link navigate={~p"/dashboard/schedule/activities/#{@activity_id}/enrolments/new"}>
<.button>New Enrolment</.button>
</.link>
</.ensure_permissions>
</:actions>
<div class="pt-4 flex flex-col gap-2 h-[30.5rem]">
<.table_search
id="enrolment-table-name-search"
params={@params}
field={:name}
path={~p"/dashboard/schedule/activities/enrolments"}
placeholder={gettext("Search for enrolments")}
class="w-full"
/>
<.table
id="enrolment-table"
items={@streams.enrolled_attendees}
meta={@meta}
params={@params}
>
<:col :let={{_id, attendee}} sortable field={:name} label="Name">
<div class="flex gap-4 flex-center items-center">
<.avatar
src={
Uploaders.UserPicture.url({attendee.picture, attendee}, :original, signed: true)
}
handle={attendee.name}
/>
<div class="self-center">
<p class="text-base font-semibold"><%= attendee.name %></p>
</div>
</div>
</:col>
<:action :let={{id, user}}>
<.ensure_permissions user={@current_user} permissions={%{"enrolments" => ["edit"]}}>
<div class="flex flex-row gap-2">
<.link
phx-click={
JS.push("delete-enrolment",
value: %{activity_id: @activity_id, attendee_id: user.attendee.id},
target: @myself
)
|> hide("##{id}")
}
data-confirm="Are you sure?"
>
<.icon name="hero-trash" class="w-5 h-5" />
</.link>
</div>
</.ensure_permissions>
</:action>
</.table>
</div>
</.page>
</div>
"""
end

@impl true
def mount(socket) do
{:ok, socket}
end

@impl true
def handle_event(
"delete-enrolment",
%{"activity_id" => activity_id, "attendee_id" => attendee_id},
socket
) do
Activities.unenrol(activity_id, attendee_id)

# Remove the enrolment from the list
{:noreply,
socket
|> stream_delete(
:enrolled_attendees,
Accounts.get_attendee!(attendee_id)
)}
end

@impl true
def handle_event("save", _params, socket) do
enrolments = socket.assigns.enrolments

# Find if all the changesets are valid
valid_enrolments =
Enum.all?(enrolments, fn {_, _, _, form} -> form.source.valid? end) and
not (enrolments
|> Enum.map(fn {id, _, _, _} -> id end)
|> has_duplicates?())

if valid_enrolments do
# For each enrolment, update or create it
Enum.each(enrolments, fn {_, new, _enrolment, form} ->
if new, do: Activities.enrol(form.params["attendee_id"], form.params["activity_id"])
end)

{:noreply,
socket
|> put_flash(:info, "Enrolments changed successfully")
|> push_patch(to: socket.assigns.patch)}
else
{:noreply, socket}
end
end

def get_enrolment_data_by_id(enrolments, id) do
Enum.find(enrolments, &(elem(&1, 0) == id)) |> elem(2)
end

defp update_enrolment_form(enrolments, id, new_form) do
Enum.map(enrolments, fn
{^id, new, enrolment, _} -> {id, new, enrolment, new_form}
other -> other
end)
end

defp attendee_options(%User{} = user), do: {user.name, user.attendee.id}
defp attendee_options(id), do: id

defp has_duplicates?(list), do: Enum.uniq(list) != list
end
21 changes: 18 additions & 3 deletions lib/safira_web/live/backoffice/schedule_live/index.ex
Original file line number Diff line number Diff line change
@@ -5,7 +5,7 @@ defmodule SafiraWeb.Backoffice.ScheduleLive.Index do
import SafiraWeb.Components.{Table, TableSearch}

alias Safira.Activities
alias Safira.Activities.{Activity, ActivityCategory, Speaker}
alias Safira.Activities.{Activity, ActivityCategory, Enrolment, Speaker}

on_mount {SafiraWeb.StaffRoles, index: %{"schedule" => ["show"]}}

@@ -92,8 +92,23 @@ defmodule SafiraWeb.Backoffice.ScheduleLive.Index do
end

defp apply_action(socket, :enrolments, params) do
case Activities.list_enrolled_attendees(params["id"], params) do
{:ok, {attendees, meta}} ->
socket
|> assign(:page_title, "Enrolments")
|> assign(:params, params)
|> assign(:enrolments_meta, meta)
|> assign(:activity_id, params["id"])
|> stream(:enrolled_attendees, attendees, reset: true)

{:error, _} ->
socket
end
end

defp apply_action(socket, :enrolments_new, params) do
socket
|> assign(:page_title, "Enrolments")
|> assign(:page_title, "New Enrolment")
|> assign(:activity, Activities.get_activity!(params["id"]))
end

@@ -107,7 +122,7 @@ defmodule SafiraWeb.Backoffice.ScheduleLive.Index do
|> stream(:speakers, speakers, reset: true)

{:error, _} ->
{:ok, socket}
socket
end
end

25 changes: 23 additions & 2 deletions lib/safira_web/live/backoffice/schedule_live/index.html.heex
Original file line number Diff line number Diff line change
@@ -108,15 +108,36 @@
id="enrolments-modal"
show
on_cancel={JS.navigate(~p"/dashboard/schedule/activities")}
>
<.live_component
module={SafiraWeb.Backoffice.ScheduleLive.EnrolmentLive.Index}
id="enrolments-component"
title={@page_title}
current_user={@current_user}
action={@live_action}
params={@params}
meta={@enrolments_meta}
streams={@streams}
activity_id={@activity_id}
patch={~p"/dashboard/schedule/activities"}
/>
</.modal>

<.modal
:if={@live_action in [:enrolments_new]}
id="enrolments-modal"
show
on_cancel={JS.navigate(~p"/dashboard/schedule/activities/#{@activity.id}/enrolments")}
>
<.live_component
module={SafiraWeb.Backoffice.ScheduleLive.EnrolmentLive.FormComponent}
id={"enrolments-#{@activity.id}"}
id="enrolments-new"
title={@page_title}
current_user={@current_user}
action={@live_action}
activity={@activity}
patch={~p"/dashboard/schedule/activities"}
enrolment={%Enrolment{}}
patch={~p"/dashboard/schedule/activities/#{@activity.id}/enrolments"}
/>
</.modal>

1 change: 1 addition & 0 deletions lib/safira_web/router.ex
Original file line number Diff line number Diff line change
@@ -234,6 +234,7 @@ defmodule SafiraWeb.Router do
live "/new", Index, :new
live "/:id/edit", Index, :edit
live "/:id/enrolments", Index, :enrolments
live "/:id/enrolments/new", Index, :enrolments_new

scope "/speakers" do
live "/", Index, :speakers