From 086d795cda51ffe43afcdda5ef6bd150030cc57f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lobo?= Date: Fri, 1 Nov 2024 18:56:49 +0000 Subject: [PATCH 01/15] feat: add activities --- lib/safira/accounts/roles/permissions.ex | 1 + lib/safira/activities.ex | 216 ++++++++++++++++++ lib/safira/activities/activity.ex | 35 +++ lib/safira/activities/activity_category.ex | 21 ++ lib/safira_web/components/table.ex | 2 +- lib/safira_web/config.ex | 7 + .../category_live/form_component.ex | 81 +++++++ .../schedule_live/category_live/index.ex | 68 ++++++ .../schedule_live/form_component.ex | 128 +++++++++++ .../live/backoffice/schedule_live/index.ex | 83 +++++++ .../backoffice/schedule_live/index.html.heex | 113 +++++++++ lib/safira_web/router.ex | 17 ++ ...41028131617_create_activity_categories.exs | 14 ++ .../20241028131718_create_activities.exs | 19 ++ priv/repo/seeds.exs | 3 +- priv/repo/seeds/activities.exs | 56 +++++ test/safira/activities_test.exs | 160 +++++++++++++ test/support/fixtures/activities_fixtures.ex | 40 ++++ 18 files changed, 1062 insertions(+), 2 deletions(-) create mode 100644 lib/safira/activities.ex create mode 100644 lib/safira/activities/activity.ex create mode 100644 lib/safira/activities/activity_category.ex create mode 100644 lib/safira_web/live/backoffice/schedule_live/category_live/form_component.ex create mode 100644 lib/safira_web/live/backoffice/schedule_live/category_live/index.ex create mode 100644 lib/safira_web/live/backoffice/schedule_live/form_component.ex create mode 100644 lib/safira_web/live/backoffice/schedule_live/index.ex create mode 100644 lib/safira_web/live/backoffice/schedule_live/index.html.heex create mode 100644 priv/repo/migrations/20241028131617_create_activity_categories.exs create mode 100644 priv/repo/migrations/20241028131718_create_activities.exs create mode 100644 priv/repo/seeds/activities.exs create mode 100644 test/safira/activities_test.exs create mode 100644 test/support/fixtures/activities_fixtures.ex diff --git a/lib/safira/accounts/roles/permissions.ex b/lib/safira/accounts/roles/permissions.ex index 4d7e7676..69da4280 100644 --- a/lib/safira/accounts/roles/permissions.ex +++ b/lib/safira/accounts/roles/permissions.ex @@ -14,6 +14,7 @@ defmodule Safira.Accounts.Roles.Permissions do "badges" => ["show", "edit", "delete", "give", "revoke", "give_without_restrictions"], "minigames" => ["show", "edit", "simulate"], "spotlights" => ["edit"], + "schedule" => ["edit"], "statistics" => ["show"], "mailer" => ["send"] } diff --git a/lib/safira/activities.ex b/lib/safira/activities.ex new file mode 100644 index 00000000..3d25ae95 --- /dev/null +++ b/lib/safira/activities.ex @@ -0,0 +1,216 @@ +defmodule Safira.Activities do + @moduledoc """ + The Activities context. + """ + + use Safira.Context + + alias Safira.Activities.Activity + + @doc """ + Returns the list of activities. + + ## Examples + + iex> list_activities() + [%Activity{}, ...] + + """ + def list_activities do + Repo.all(Activity) + end + + def list_activities(opts) when is_list(opts) do + Activity + |> apply_filters(opts) + |> Repo.all() + end + + def list_activities(params) do + Activity + |> Flop.validate_and_run(params, for: Activity) + end + + def list_activities(%{} = params, opts) when is_list(opts) do + Activity + |> apply_filters(opts) + |> Flop.validate_and_run(params, for: Activity) + end + + @doc """ + Gets a single activity. + + Raises `Ecto.NoResultsError` if the Activity does not exist. + + ## Examples + + iex> get_activity!(123) + %Activity{} + + iex> get_activity!(456) + ** (Ecto.NoResultsError) + + """ + def get_activity!(id), do: Repo.get!(Activity, id) + + @doc """ + Creates a activity. + + ## Examples + + iex> create_activity(%{field: value}) + {:ok, %Activity{}} + + iex> create_activity(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_activity(attrs \\ %{}) do + %Activity{} + |> Activity.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a activity. + + ## Examples + + iex> update_activity(activity, %{field: new_value}) + {:ok, %Activity{}} + + iex> update_activity(activity, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_activity(%Activity{} = activity, attrs) do + activity + |> Activity.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a activity. + + ## Examples + + iex> delete_activity(activity) + {:ok, %Activity{}} + + iex> delete_activity(activity) + {:error, %Ecto.Changeset{}} + + """ + def delete_activity(%Activity{} = activity) do + Repo.delete(activity) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking activity changes. + + ## Examples + + iex> change_activity(activity) + %Ecto.Changeset{data: %Activity{}} + + """ + def change_activity(%Activity{} = activity, attrs \\ %{}) do + Activity.changeset(activity, attrs) + end + + alias Safira.Activities.ActivityCategory + + @doc """ + Returns the list of activity_categories. + + ## Examples + + iex> list_activity_categories() + [%ActivityCategory{}, ...] + + """ + def list_activity_categories do + Repo.all(ActivityCategory) + end + + @doc """ + Gets a single activity_category. + + Raises `Ecto.NoResultsError` if the Activity category does not exist. + + ## Examples + + iex> get_activity_category!(123) + %ActivityCategory{} + + iex> get_activity_category!(456) + ** (Ecto.NoResultsError) + + """ + def get_activity_category!(id), do: Repo.get!(ActivityCategory, id) + + @doc """ + Creates a activity_category. + + ## Examples + + iex> create_activity_category(%{field: value}) + {:ok, %ActivityCategory{}} + + iex> create_activity_category(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_activity_category(attrs \\ %{}) do + %ActivityCategory{} + |> ActivityCategory.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a activity_category. + + ## Examples + + iex> update_activity_category(activity_category, %{field: new_value}) + {:ok, %ActivityCategory{}} + + iex> update_activity_category(activity_category, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_activity_category(%ActivityCategory{} = activity_category, attrs) do + activity_category + |> ActivityCategory.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a activity_category. + + ## Examples + + iex> delete_activity_category(activity_category) + {:ok, %ActivityCategory{}} + + iex> delete_activity_category(activity_category) + {:error, %Ecto.Changeset{}} + + """ + def delete_activity_category(%ActivityCategory{} = activity_category) do + Repo.delete(activity_category) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking activity_category changes. + + ## Examples + + iex> change_activity_category(activity_category) + %Ecto.Changeset{data: %ActivityCategory{}} + + """ + def change_activity_category(%ActivityCategory{} = activity_category, attrs \\ %{}) do + ActivityCategory.changeset(activity_category, attrs) + end +end diff --git a/lib/safira/activities/activity.ex b/lib/safira/activities/activity.ex new file mode 100644 index 00000000..c3749e15 --- /dev/null +++ b/lib/safira/activities/activity.ex @@ -0,0 +1,35 @@ +defmodule Safira.Activities.Activity do + @moduledoc """ + Activities scheduled for the event. + """ + use Safira.Schema + + @required_fields ~w(title date time_start time_end)a + @optional_fields ~w(description category_id location has_enrolments)a + + @derive { + Flop.Schema, + filterable: [:title], sortable: [:title, :date], default_limit: 11 + } + + schema "activities" do + field :title, :string + field :description, :string + field :location, :string + field :date, :date + field :time_start, :time + field :time_end, :time + field :has_enrolments, :boolean, default: false + + belongs_to :category, Safira.Activities.ActivityCategory + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(activity, attrs) do + activity + |> cast(attrs, @required_fields ++ @optional_fields) + |> validate_required(@required_fields) + end +end diff --git a/lib/safira/activities/activity_category.ex b/lib/safira/activities/activity_category.ex new file mode 100644 index 00000000..2f092526 --- /dev/null +++ b/lib/safira/activities/activity_category.ex @@ -0,0 +1,21 @@ +defmodule Safira.Activities.ActivityCategory do + @moduledoc """ + Categories for activities. + """ + use Safira.Schema + + @required_fields ~w(name)a + + schema "activity_categories" do + field :name, :string + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(activity_category, attrs) do + activity_category + |> cast(attrs, @required_fields) + |> validate_required(@required_fields) + end +end diff --git a/lib/safira_web/components/table.ex b/lib/safira_web/components/table.ex index 12514824..978e9323 100644 --- a/lib/safira_web/components/table.ex +++ b/lib/safira_web/components/table.ex @@ -86,7 +86,7 @@ defmodule SafiraWeb.Components.Table do - <.pagination meta={@meta} params={@params} /> + <.pagination :if={@meta.total_pages > 1} meta={@meta} params={@params} /> """ end diff --git a/lib/safira_web/config.ex b/lib/safira_web/config.ex index c9fab043..0acf081a 100644 --- a/lib/safira_web/config.ex +++ b/lib/safira_web/config.ex @@ -104,6 +104,13 @@ defmodule SafiraWeb.Config do url: "/dashboard/spotlights", scope: %{"spotlights" => ["edit"]} }, + %{ + key: :schedule, + title: "Schedule", + icon: "hero-calendar-days", + url: "/dashboard/schedule/activities", + scope: %{"schedule" => ["edit"]} + }, %{ key: :statistics, title: "Statistics", diff --git a/lib/safira_web/live/backoffice/schedule_live/category_live/form_component.ex b/lib/safira_web/live/backoffice/schedule_live/category_live/form_component.ex new file mode 100644 index 00000000..a4d774d4 --- /dev/null +++ b/lib/safira_web/live/backoffice/schedule_live/category_live/form_component.ex @@ -0,0 +1,81 @@ +defmodule SafiraWeb.Backoffice.ScheduleLive.CategoryLive.FormComponent do + use SafiraWeb, :live_component + + alias Safira.Activities + import SafiraWeb.Components.Forms + + @impl true + def render(assigns) do + ~H""" +
+ <.page title={@title} subtitle={gettext("Categories group scheduled activities.")}> + <.simple_form + for={@form} + id="category-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save" + > +
+ <.field field={@form[:name]} type="text" label="Name" required /> +
+ <:actions> + <.button phx-disable-with="Saving...">Save Category + + + +
+ """ + end + + @impl true + def mount(socket) do + {:ok, socket} + end + + @impl true + def update(%{category: category} = assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> assign_new(:form, fn -> + to_form(Activities.change_activity_category(category)) + end)} + end + + @impl true + def handle_event("validate", %{"activity_category" => category_params}, socket) do + changeset = Activities.change_activity_category(socket.assigns.category, category_params) + {:noreply, assign(socket, form: to_form(changeset, action: :validate))} + end + + def handle_event("save", %{"activity_category" => category_params}, socket) do + save_category(socket, socket.assigns.action, category_params) + end + + defp save_category(socket, :categories_edit, category_params) do + case Activities.update_activity_category(socket.assigns.category, category_params) do + {:ok, _category} -> + {:noreply, + socket + |> put_flash(:info, "Activity category updated successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp save_category(socket, :categories_new, category_params) do + case Activities.create_activity_category(category_params) do + {:ok, _category} -> + {:noreply, + socket + |> put_flash(:info, "Activity category created successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end +end diff --git a/lib/safira_web/live/backoffice/schedule_live/category_live/index.ex b/lib/safira_web/live/backoffice/schedule_live/category_live/index.ex new file mode 100644 index 00000000..956f35ce --- /dev/null +++ b/lib/safira_web/live/backoffice/schedule_live/category_live/index.ex @@ -0,0 +1,68 @@ +defmodule SafiraWeb.Backoffice.ScheduleLive.CategoryLive.Index do + use SafiraWeb, :live_component + + alias Safira.Activities + + import SafiraWeb.Components.EnsurePermissions + + @impl true + def render(assigns) do + ~H""" +
+ <.page title={@title}> + <:actions> + <.ensure_permissions user={@current_user} permissions={%{"schedule" => ["edit"]}}> + <.link navigate={~p"/dashboard/schedule/activities/categories/new"}> + <.button>New Category + + + + + +
+ """ + end + + @impl true + def mount(socket) do + {:ok, + socket + |> stream(:categories, Activities.list_activity_categories())} + end +end diff --git a/lib/safira_web/live/backoffice/schedule_live/form_component.ex b/lib/safira_web/live/backoffice/schedule_live/form_component.ex new file mode 100644 index 00000000..180a0057 --- /dev/null +++ b/lib/safira_web/live/backoffice/schedule_live/form_component.ex @@ -0,0 +1,128 @@ +defmodule SafiraWeb.Backoffice.ScheduleLive.FormComponent do + use SafiraWeb, :live_component + + alias Safira.Activities + import SafiraWeb.Components.Forms + + @impl true + def render(assigns) do + ~H""" +
+ <.page title={@title} subtitle={gettext("Activities that happen troughout the event.")}> + <.simple_form + for={@form} + id="activity-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save" + > +
+
+ <.field field={@form[:title]} type="text" label="Title" required wrapper_class="w-full" /> + <.field field={@form[:location]} type="text" label="Location" wrapper_class="w-full" /> +
+ <.field field={@form[:description]} type="textarea" label="Description" /> +
+ <.field + field={@form[:date]} + type="date" + label="Date" + required + wrapper_class="col-span-2" + /> + <.field + field={@form[:time_start]} + type="time" + label="Start" + required + wrapper_class="col-span-1" + /> + <.field + field={@form[:time_end]} + type="time" + label="End" + required + wrapper_class="col-span-1" + /> +
+
+ <.field + field={@form[:category_id]} + type="select" + label="Category" + options={categories_options(@categories)} + wrapper_class="w-full" + /> + <.field + field={@form[:has_enrolments]} + type="checkbox" + label="Requires enrolment" + wrapper_class="w-full" + /> +
+
+ <:actions> + <.button phx-disable-with="Saving...">Save Activity + + + +
+ """ + end + + @impl true + def mount(socket) do + {:ok, socket} + end + + @impl true + def update(%{activity: activity} = assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> assign_new(:form, fn -> + to_form(Activities.change_activity(activity)) + end)} + end + + @impl true + def handle_event("validate", %{"activity" => activity_params}, socket) do + changeset = Activities.change_activity(socket.assigns.activity, activity_params) + {:noreply, assign(socket, form: to_form(changeset, action: :validate))} + end + + def handle_event("save", %{"activity" => activity_params}, socket) do + save_activity(socket, socket.assigns.action, activity_params) + end + + defp save_activity(socket, :edit, activity_params) do + case Activities.update_activity(socket.assigns.activity, activity_params) do + {:ok, _activity} -> + {:noreply, + socket + |> put_flash(:info, "Activity updated successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp save_activity(socket, :new, activity_params) do + case Activities.create_activity(activity_params) do + {:ok, _activity} -> + {:noreply, + socket + |> put_flash(:info, "Activity created successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp categories_options(categories) do + [{"None", nil}] ++ + Enum.map(categories, &{&1.name, &1.id}) + end +end diff --git a/lib/safira_web/live/backoffice/schedule_live/index.ex b/lib/safira_web/live/backoffice/schedule_live/index.ex new file mode 100644 index 00000000..cc47a806 --- /dev/null +++ b/lib/safira_web/live/backoffice/schedule_live/index.ex @@ -0,0 +1,83 @@ +defmodule SafiraWeb.Backoffice.ScheduleLive.Index do + use SafiraWeb, :backoffice_view + + import SafiraWeb.Components.{Table, TableSearch} + + alias Safira.Activities + alias Safira.Activities.{Activity, ActivityCategory} + + on_mount {SafiraWeb.StaffRoles, index: %{"schedule" => ["edit"]}} + + @impl true + def mount(_params, _session, socket) do + {:ok, socket} + end + + @impl true + def handle_params(params, _url, socket) do + case Activities.list_activities(params, preloads: [:category]) do + {:ok, {activities, meta}} -> + {:noreply, + socket + |> assign(:current_page, :schedule) + |> assign(:meta, meta) + |> assign(:params, params) + |> stream(:activities, activities, reset: true) + |> apply_action(socket.assigns.live_action, params)} + + {:error, _} -> + {:noreply, socket} + end + end + + defp apply_action(socket, :edit, %{"id" => id}) do + socket + |> assign(:page_title, "Edit Activity") + |> assign(:activity, Activities.get_activity!(id)) + |> assign(:categories, Activities.list_activity_categories()) + end + + defp apply_action(socket, :new, _params) do + socket + |> assign(:page_title, "New Activity") + |> assign(:activity, %Activity{}) + |> assign(:categories, Activities.list_activity_categories()) + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, "Listing Activities") + end + + defp apply_action(socket, :categories_edit, %{"id" => id}) do + socket + |> assign(:page_title, "Edit Category") + |> assign(:category, Activities.get_activity_category!(id)) + end + + defp apply_action(socket, :categories_new, _params) do + socket + |> assign(:page_title, "New Category") + |> assign(:category, %ActivityCategory{}) + end + + defp apply_action(socket, :categories, _params) do + socket + |> assign(:page_title, "Listing Categories") + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + activity = Activities.get_activity!(id) + + {:ok, _} = Activities.delete_activity(activity) + + {:noreply, stream_delete(socket, :activities, activity)} + end + + defp formatted_activity_times(activity) do + format = "{h24}:{m}" + + "#{activity.time_start |> Timex.format!(format)} - #{activity.time_end |> Timex.format!(format)}" + end +end diff --git a/lib/safira_web/live/backoffice/schedule_live/index.html.heex b/lib/safira_web/live/backoffice/schedule_live/index.html.heex new file mode 100644 index 00000000..bef2ac0d --- /dev/null +++ b/lib/safira_web/live/backoffice/schedule_live/index.html.heex @@ -0,0 +1,113 @@ +<.page title="Schedule"> + <:actions> +
+ <.table_search + id="schedule-table-name-search" + params={@params} + field={:title} + path={~p"/dashboard/schedule/activities"} + placeholder={gettext("Search for activities")} + /> + <.ensure_permissions user={@current_user} permissions={%{"schedule" => ["edit"]}}> + <.link patch={~p"/dashboard/schedule/activities/new"}> + <.button>New Activity + + + + <.ensure_permissions user={@current_user} permissions={%{"schedule" => ["edit"]}}> + <.link patch={~p"/dashboard/schedule/activities/categories"}> + <.button> + <.icon name="hero-tag" class="w-5 h-5" /> + + + +
+ + +
+ <.table id="activities-table" items={@streams.activities} meta={@meta} params={@params}> + <:col :let={{_id, activity}} sortable field={:title} label="Title"> + <%= activity.title %> + + <:col :let={{_id, activity}} sortable field={:date} label="Date"> + <%= Timex.format!(activity.date, "{D}/{M}/{YYYY}") %> + + <:col :let={{_id, activity}} sortable field={:time} label="Time"> + <%= formatted_activity_times(activity) %> + + <:col :let={{_id, activity}} field={:category} label="Category"> + <%= if activity.category do %> + <%= activity.category.name %> + <% else %> +

-

+ <% end %> + + <:action :let={{id, activity}}> + <.ensure_permissions user={@current_user} permissions={%{"schedule" => ["edit"]}}> +
+ <.link patch={~p"/dashboard/schedule/activities/#{activity.id}/edit"}> + <.icon name="hero-pencil" class="w-5 h-5" /> + + <.link + phx-click={JS.push("delete", value: %{id: activity.id}) |> hide("##{id}")} + data-confirm="Are you sure?" + > + <.icon name="hero-trash" class="w-5 h-5" /> + +
+ + + +
+ + +<.modal + :if={@live_action in [:edit, :new]} + id="categories-modal" + show + on_cancel={JS.navigate(~p"/dashboard/schedule/activities")} +> + <.live_component + module={SafiraWeb.Backoffice.ScheduleLive.FormComponent} + id={@activity.id || :new} + title={@page_title} + current_user={@current_user} + action={@live_action} + activity={@activity} + categories={@categories} + patch={~p"/dashboard/schedule/activities"} + /> + + +<.modal + :if={@live_action in [:categories]} + id="categories-modal" + show + on_cancel={JS.patch(~p"/dashboard/schedule/activities")} +> + <.live_component + module={SafiraWeb.Backoffice.ScheduleLive.CategoryLive.Index} + id="categories-tiers" + title={@page_title} + current_user={@current_user} + action={@live_action} + patch={~p"/dashboard/schedule/activities"} + /> + + +<.modal + :if={@live_action in [:categories_edit, :categories_new]} + id="categories-modal" + show + on_cancel={JS.navigate(~p"/dashboard/schedule/activities/categories")} +> + <.live_component + module={SafiraWeb.Backoffice.ScheduleLive.CategoryLive.FormComponent} + id={@category.id || :new} + title={@page_title} + current_user={@current_user} + action={@live_action} + category={@category} + patch={~p"/dashboard/schedule/activities/categories"} + /> + diff --git a/lib/safira_web/router.ex b/lib/safira_web/router.ex index cf3a31ce..78938672 100644 --- a/lib/safira_web/router.ex +++ b/lib/safira_web/router.ex @@ -124,6 +124,23 @@ defmodule SafiraWeb.Router do end end + scope "/schedule", ScheduleLive do + scope "/activities" do + live "/", Index, :index + live "/new", Index, :new + live "/:id/edit", Index, :edit + + scope "/categories" do + live "/", Index, :categories + live "/new", Index, :categories_new + + scope "/:id" do + live "/edit", Index, :categories_edit + end + end + end + end + scope "/store/products", ProductLive do live "/", Index, :index live "/new", Index, :new diff --git a/priv/repo/migrations/20241028131617_create_activity_categories.exs b/priv/repo/migrations/20241028131617_create_activity_categories.exs new file mode 100644 index 00000000..b1fb112e --- /dev/null +++ b/priv/repo/migrations/20241028131617_create_activity_categories.exs @@ -0,0 +1,14 @@ +defmodule Safira.Repo.Migrations.CreateActivityCategories do + use Ecto.Migration + + def change do + create table(:activity_categories, primary_key: false) do + add :id, :binary_id, primary_key: true + add :name, :string, null: false + + timestamps(type: :utc_datetime) + end + + create unique_index(:activity_categories, [:name]) + end +end diff --git a/priv/repo/migrations/20241028131718_create_activities.exs b/priv/repo/migrations/20241028131718_create_activities.exs new file mode 100644 index 00000000..19b37b5f --- /dev/null +++ b/priv/repo/migrations/20241028131718_create_activities.exs @@ -0,0 +1,19 @@ +defmodule Safira.Repo.Migrations.CreateActivities do + use Ecto.Migration + + def change do + create table(:activities, primary_key: false) do + add :id, :binary_id, primary_key: true + add :title, :string, null: false + add :description, :string + add :location, :string + add :date, :date, null: false + add :time_start, :time, null: false + add :time_end, :time, null: false + add :has_enrolments, :boolean, default: false, null: false + add :category_id, references(:activity_categories, on_delete: :nothing, type: :binary_id) + + timestamps(type: :utc_datetime) + end + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 02712b4b..a74aa58a 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -15,7 +15,8 @@ defmodule Safira.Repo.Seeds do "store.exs", "vault.exs", "prizes.exs", - "companies.exs" + "companies.exs", + "activities.exs" ] |> Enum.each(fn file -> Code.require_file("#{@seeds_dir}/#{file}") diff --git a/priv/repo/seeds/activities.exs b/priv/repo/seeds/activities.exs new file mode 100644 index 00000000..560ecd6d --- /dev/null +++ b/priv/repo/seeds/activities.exs @@ -0,0 +1,56 @@ +defmodule Safira.Repo.Seeds.Activities do + alias Safira.Repo + + alias Safira.Activities + alias Safira.Activities.ActivityCategory + + def run do + case Activities.list_activity_categories() do + [] -> + seed_categories() + _ -> + Mix.shell().error("Found categories, aborting seeding categories.") + end + + case Activities.list_activities() do + [] -> + seed_activities() + _ -> + Mix.shell().error("Found activities, aborting seeding activities.") + end + end + + def seed_categories do + categories = [ + %{ + name: "Talk" + }, + %{ + name: "Pitch" + }, + %{ + name: "Workshop" + }, + %{ + name: "Break" + } + ] + + for category <- categories do + changeset = ActivityCategory.changeset(%ActivityCategory{}, category) + + case Repo.insert(changeset) do + {:ok, _} -> :ok + {:error, changeset} -> + Mix.shell().error("Failed to insert category: #{category.name}") + Mix.shell().error(Kernel.inspect(changeset.errors)) + end + end + end + + def seed_activities do + # TODO: Add activity seeds + end +end + +Safira.Repo.Seeds.Activities.run() diff --git a/test/safira/activities_test.exs b/test/safira/activities_test.exs new file mode 100644 index 00000000..83af7fa8 --- /dev/null +++ b/test/safira/activities_test.exs @@ -0,0 +1,160 @@ +defmodule Safira.ActivitiesTest do + use Safira.DataCase + + alias Safira.Activities + + describe "activities" do + alias Safira.Activities.Activity + + import Safira.ActivitiesFixtures + + @invalid_attrs %{ + date: nil, + description: nil, + title: nil, + location: nil, + time_start: nil, + time_end: nil, + has_enrolments: nil + } + + test "list_activities/0 returns all activities" do + activity = activity_fixture() + assert Activities.list_activities() == [activity] + end + + test "get_activity!/1 returns the activity with given id" do + activity = activity_fixture() + assert Activities.get_activity!(activity.id) == activity + end + + test "create_activity/1 with valid data creates a activity" do + valid_attrs = %{ + date: ~D[2024-10-27], + description: "some description", + title: "some title", + location: "some location", + time_start: ~T[14:00:00], + time_end: ~T[14:00:00], + has_enrolments: true + } + + assert {:ok, %Activity{} = activity} = Activities.create_activity(valid_attrs) + assert activity.date == ~D[2024-10-27] + assert activity.description == "some description" + assert activity.title == "some title" + assert activity.location == "some location" + assert activity.time_start == ~T[14:00:00] + assert activity.time_end == ~T[14:00:00] + assert activity.has_enrolments == true + end + + test "create_activity/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Activities.create_activity(@invalid_attrs) + end + + test "update_activity/2 with valid data updates the activity" do + activity = activity_fixture() + + update_attrs = %{ + date: ~D[2024-10-28], + description: "some updated description", + title: "some updated title", + location: "some updated location", + time_start: ~T[15:01:01], + time_end: ~T[15:01:01], + has_enrolments: false + } + + assert {:ok, %Activity{} = activity} = Activities.update_activity(activity, update_attrs) + assert activity.date == ~D[2024-10-28] + assert activity.description == "some updated description" + assert activity.title == "some updated title" + assert activity.location == "some updated location" + assert activity.time_start == ~T[15:01:01] + assert activity.time_end == ~T[15:01:01] + assert activity.has_enrolments == false + end + + test "update_activity/2 with invalid data returns error changeset" do + activity = activity_fixture() + assert {:error, %Ecto.Changeset{}} = Activities.update_activity(activity, @invalid_attrs) + assert activity == Activities.get_activity!(activity.id) + end + + test "delete_activity/1 deletes the activity" do + activity = activity_fixture() + assert {:ok, %Activity{}} = Activities.delete_activity(activity) + assert_raise Ecto.NoResultsError, fn -> Activities.get_activity!(activity.id) end + end + + test "change_activity/1 returns a activity changeset" do + activity = activity_fixture() + assert %Ecto.Changeset{} = Activities.change_activity(activity) + end + end + + describe "activity_categories" do + alias Safira.Activities.ActivityCategory + + import Safira.ActivitiesFixtures + + @invalid_attrs %{name: nil} + + test "list_activity_categories/0 returns all activity_categories" do + activity_category = activity_category_fixture() + assert Activities.list_activity_categories() == [activity_category] + end + + test "get_activity_category!/1 returns the activity_category with given id" do + activity_category = activity_category_fixture() + assert Activities.get_activity_category!(activity_category.id) == activity_category + end + + test "create_activity_category/1 with valid data creates a activity_category" do + valid_attrs = %{name: "some name"} + + assert {:ok, %ActivityCategory{} = activity_category} = + Activities.create_activity_category(valid_attrs) + + assert activity_category.name == "some name" + end + + test "create_activity_category/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Activities.create_activity_category(@invalid_attrs) + end + + test "update_activity_category/2 with valid data updates the activity_category" do + activity_category = activity_category_fixture() + update_attrs = %{name: "some updated name"} + + assert {:ok, %ActivityCategory{} = activity_category} = + Activities.update_activity_category(activity_category, update_attrs) + + assert activity_category.name == "some updated name" + end + + test "update_activity_category/2 with invalid data returns error changeset" do + activity_category = activity_category_fixture() + + assert {:error, %Ecto.Changeset{}} = + Activities.update_activity_category(activity_category, @invalid_attrs) + + assert activity_category == Activities.get_activity_category!(activity_category.id) + end + + test "delete_activity_category/1 deletes the activity_category" do + activity_category = activity_category_fixture() + assert {:ok, %ActivityCategory{}} = Activities.delete_activity_category(activity_category) + + assert_raise Ecto.NoResultsError, fn -> + Activities.get_activity_category!(activity_category.id) + end + end + + test "change_activity_category/1 returns a activity_category changeset" do + activity_category = activity_category_fixture() + assert %Ecto.Changeset{} = Activities.change_activity_category(activity_category) + end + end +end diff --git a/test/support/fixtures/activities_fixtures.ex b/test/support/fixtures/activities_fixtures.ex new file mode 100644 index 00000000..acf27479 --- /dev/null +++ b/test/support/fixtures/activities_fixtures.ex @@ -0,0 +1,40 @@ +defmodule Safira.ActivitiesFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Safira.Activities` context. + """ + + @doc """ + Generate a activity. + """ + def activity_fixture(attrs \\ %{}) do + {:ok, activity} = + attrs + |> Enum.into(%{ + date: ~D[2024-10-27], + description: "some description", + has_enrolments: true, + location: "some location", + time_end: ~T[14:00:00], + time_start: ~T[14:00:00], + title: "some title" + }) + |> Safira.Activities.create_activity() + + activity + end + + @doc """ + Generate a activity_category. + """ + def activity_category_fixture(attrs \\ %{}) do + {:ok, activity_category} = + attrs + |> Enum.into(%{ + name: "some name" + }) + |> Safira.Activities.create_activity_category() + + activity_category + end +end From 6ed55f12f19936f876668d79a4e4f32f2498aabd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lobo?= Date: Sat, 2 Nov 2024 19:03:22 +0000 Subject: [PATCH 02/15] chore: clean up form --- assets/css/components/field.css | 2 +- lib/safira/activities/activity.ex | 16 +++++- .../schedule_live/form_component.ex | 53 ++++++++++++++----- .../backoffice/schedule_live/index.html.heex | 4 +- .../20241028131718_create_activities.exs | 3 +- 5 files changed, 58 insertions(+), 20 deletions(-) diff --git a/assets/css/components/field.css b/assets/css/components/field.css index 6a2f958f..48f1291e 100644 --- a/assets/css/components/field.css +++ b/assets/css/components/field.css @@ -129,7 +129,7 @@ /* Text */ .safira-text-input { - @apply block w-full rounded-md text-dark border border-lightShade placeholder:text-darkMuted focus:outline-2 focus:border-lightShade ring-0 focus:outline-dark focus:outline-offset-2 dark:outline-darkShade dark:bg-dark dark:text-light dark:placeholder-lightMuted dark:focus:border-darkShade dark:border-darkShade focus:ring-0 dark:focus:outline-light; + @apply block w-full rounded-md text-dark border border-lightShade placeholder:text-darkMuted focus:outline-2 focus:border-lightShade ring-0 focus:outline-dark focus:outline-offset-2 dark:outline-darkShade dark:bg-dark dark:text-light dark:placeholder-lightMuted dark:focus:border-darkShade dark:border-darkShade focus:ring-0 dark:focus:outline-light dark:[color-scheme:dark]; } /* Code */ diff --git a/lib/safira/activities/activity.ex b/lib/safira/activities/activity.ex index c3749e15..de18cf45 100644 --- a/lib/safira/activities/activity.ex +++ b/lib/safira/activities/activity.ex @@ -5,11 +5,22 @@ defmodule Safira.Activities.Activity do use Safira.Schema @required_fields ~w(title date time_start time_end)a - @optional_fields ~w(description category_id location has_enrolments)a + @optional_fields ~w(description category_id location has_enrolments max_enrolments)a @derive { Flop.Schema, - filterable: [:title], sortable: [:title, :date], default_limit: 11 + filterable: [:title], + sortable: [:timestamp], + default_limit: 11, + adapter_opts: [ + compound_fields: [ + timestamp: [:date, :time_start] + ] + ], + default_order: %{ + order_by: [:timestamp], + order_directions: [:asc] + } } schema "activities" do @@ -20,6 +31,7 @@ defmodule Safira.Activities.Activity do field :time_start, :time field :time_end, :time field :has_enrolments, :boolean, default: false + field :max_enrolments, :integer, default: 0 belongs_to :category, Safira.Activities.ActivityCategory diff --git a/lib/safira_web/live/backoffice/schedule_live/form_component.ex b/lib/safira_web/live/backoffice/schedule_live/form_component.ex index 180a0057..2eeacdc4 100644 --- a/lib/safira_web/live/backoffice/schedule_live/form_component.ex +++ b/lib/safira_web/live/backoffice/schedule_live/form_component.ex @@ -45,20 +45,39 @@ defmodule SafiraWeb.Backoffice.ScheduleLive.FormComponent do wrapper_class="col-span-1" /> + <.field + field={@form[:category_id]} + type="select" + label="Category" + options={categories_options(@categories)} + wrapper_class="w-full" + />
- <.field - field={@form[:category_id]} - type="select" - label="Category" - options={categories_options(@categories)} - wrapper_class="w-full" - /> - <.field - field={@form[:has_enrolments]} - type="checkbox" - label="Requires enrolment" - wrapper_class="w-full" - /> +
+
+ <.label> + <%= gettext("Enrolments") %> + +

+ <%= gettext( + "Enable enrolments to allow participants to sign up for this activity." + ) %> +

+ <.field + field={@form[:has_enrolments]} + type="switch" + label="" + wrapper_class="w-full pt-3" + /> +
+ <.field + :if={@enrolments_active} + field={@form[:max_enrolments]} + type="number" + label="Max enrolments" + wrapper_class="w-full" + /> +
<:actions> @@ -80,6 +99,7 @@ defmodule SafiraWeb.Backoffice.ScheduleLive.FormComponent do {:ok, socket |> assign(assigns) + |> assign(:enrolments_active, activity.has_enrolments) |> assign_new(:form, fn -> to_form(Activities.change_activity(activity)) end)} @@ -88,7 +108,12 @@ defmodule SafiraWeb.Backoffice.ScheduleLive.FormComponent do @impl true def handle_event("validate", %{"activity" => activity_params}, socket) do changeset = Activities.change_activity(socket.assigns.activity, activity_params) - {:noreply, assign(socket, form: to_form(changeset, action: :validate))} + + {:noreply, + assign(socket, + form: to_form(changeset, action: :validate), + enrolments_active: activity_params["has_enrolments"] != "false" + )} end def handle_event("save", %{"activity" => activity_params}, socket) do diff --git a/lib/safira_web/live/backoffice/schedule_live/index.html.heex b/lib/safira_web/live/backoffice/schedule_live/index.html.heex index bef2ac0d..ad87abf8 100644 --- a/lib/safira_web/live/backoffice/schedule_live/index.html.heex +++ b/lib/safira_web/live/backoffice/schedule_live/index.html.heex @@ -29,10 +29,10 @@ <:col :let={{_id, activity}} sortable field={:title} label="Title"> <%= activity.title %> - <:col :let={{_id, activity}} sortable field={:date} label="Date"> + <:col :let={{_id, activity}} sortable field={:timestamp} label="Date"> <%= Timex.format!(activity.date, "{D}/{M}/{YYYY}") %> - <:col :let={{_id, activity}} sortable field={:time} label="Time"> + <:col :let={{_id, activity}} field={:time} label="Time"> <%= formatted_activity_times(activity) %> <:col :let={{_id, activity}} field={:category} label="Category"> diff --git a/priv/repo/migrations/20241028131718_create_activities.exs b/priv/repo/migrations/20241028131718_create_activities.exs index 19b37b5f..0542d29e 100644 --- a/priv/repo/migrations/20241028131718_create_activities.exs +++ b/priv/repo/migrations/20241028131718_create_activities.exs @@ -5,12 +5,13 @@ defmodule Safira.Repo.Migrations.CreateActivities do create table(:activities, primary_key: false) do add :id, :binary_id, primary_key: true add :title, :string, null: false - add :description, :string + add :description, :text add :location, :string add :date, :date, null: false add :time_start, :time, null: false add :time_end, :time, null: false add :has_enrolments, :boolean, default: false, null: false + add :max_enrolments, :integer, default: 0, null: false add :category_id, references(:activity_categories, on_delete: :nothing, type: :binary_id) timestamps(type: :utc_datetime) From 487ec899ec131311e7d8bd6daae318f418b4ae2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lobo?= Date: Sun, 3 Nov 2024 19:34:32 +0000 Subject: [PATCH 03/15] feat: speakers --- lib/safira/activities.ex | 131 +++++++++++++- lib/safira/activities/speaker.ex | 94 ++++++++++ lib/safira/companies.ex | 4 +- lib/safira/companies/company.ex | 4 +- lib/safira/schema.ex | 2 +- lib/safira/uploaders/speaker.ex | 28 +++ lib/safira_web/components/image_uploader.ex | 2 +- lib/safira_web/components/table_search.ex | 4 +- .../backoffice/product_live/form_component.ex | 3 +- .../live/backoffice/schedule_live/index.ex | 38 +++- .../backoffice/schedule_live/index.html.heex | 46 ++++- .../speaker_live/form_component.ex | 168 ++++++++++++++++++ .../schedule_live/speaker_live/index.ex | 89 ++++++++++ lib/safira_web/router.ex | 11 +- .../20241102191718_create_speakers.exs | 18 ++ test/safira/activities_test.exs | 75 ++++++++ test/support/fixtures/activities_fixtures.ex | 18 ++ 17 files changed, 717 insertions(+), 18 deletions(-) create mode 100644 lib/safira/activities/speaker.ex create mode 100644 lib/safira/uploaders/speaker.ex create mode 100644 lib/safira_web/live/backoffice/schedule_live/speaker_live/form_component.ex create mode 100644 lib/safira_web/live/backoffice/schedule_live/speaker_live/index.ex create mode 100644 priv/repo/migrations/20241102191718_create_speakers.exs diff --git a/lib/safira/activities.ex b/lib/safira/activities.ex index 3d25ae95..37f2bbfb 100644 --- a/lib/safira/activities.ex +++ b/lib/safira/activities.ex @@ -5,7 +5,7 @@ defmodule Safira.Activities do use Safira.Context - alias Safira.Activities.Activity + alias Safira.Activities.{Activity, Speaker} @doc """ Returns the list of activities. @@ -213,4 +213,133 @@ defmodule Safira.Activities do def change_activity_category(%ActivityCategory{} = activity_category, attrs \\ %{}) do ActivityCategory.changeset(activity_category, attrs) end + + @doc """ + Returns the list of speakers. + + ## Examples + + iex> list_speakers() + [%Speaker{}, ...] + + """ + def list_speakers do + Repo.all(Speaker) + end + + def list_speakers(opts) when is_list(opts) do + Speaker + |> apply_filters(opts) + |> Repo.all() + end + + def list_speakers(params) do + Speaker + |> Flop.validate_and_run(params, for: Speaker) + end + + def list_speakers(%{} = params, opts) when is_list(opts) do + Speaker + |> apply_filters(opts) + |> Flop.validate_and_run(params, for: Speaker) + end + + @doc """ + Gets a single speaker. + + Raises `Ecto.NoResultsError` if the Speaker does not exist. + + ## Examples + + iex> get_speaker!(123) + %Speaker{} + + iex> get_speaker!(456) + ** (Ecto.NoResultsError) + + """ + def get_speaker!(id), do: Repo.get!(Speaker, id) + + @doc """ + Creates a speaker. + + ## Examples + + iex> create_speaker(%{field: value}) + {:ok, %Speaker{}} + + iex> create_speaker(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_speaker(attrs \\ %{}) do + %Speaker{} + |> Speaker.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a speaker. + + ## Examples + + iex> update_speaker(speaker, %{field: new_value}) + {:ok, %Speaker{}} + + iex> update_speaker(speaker, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_speaker(%Speaker{} = speaker, attrs) do + speaker + |> Speaker.changeset(attrs) + |> Repo.update() + end + + @doc """ + Updates a speaker picture. + + ## Examples + + iex> update_speaker_picture(speaker, %{picture: image}) + {:ok, %Speaker{}} + + iex> update_speaker_picture(speaker, %{picture: bad_image}) + {:error, %Ecto.Changeset{}} + + """ + def update_speaker_picture(%Speaker{} = speaker, attrs) do + speaker + |> Speaker.picture_changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a speaker. + + ## Examples + + iex> delete_speaker(speaker) + {:ok, %Speaker{}} + + iex> delete_speaker(speaker) + {:error, %Ecto.Changeset{}} + + """ + def delete_speaker(%Speaker{} = speaker) do + Repo.delete(speaker) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking speaker changes. + + ## Examples + + iex> change_speaker(speaker) + %Ecto.Changeset{data: %Speaker{}} + + """ + def change_speaker(%Speaker{} = speaker, attrs \\ %{}) do + Speaker.changeset(speaker, attrs) + end end diff --git a/lib/safira/activities/speaker.ex b/lib/safira/activities/speaker.ex new file mode 100644 index 00000000..67d48b8d --- /dev/null +++ b/lib/safira/activities/speaker.ex @@ -0,0 +1,94 @@ +defmodule Safira.Activities.Speaker do + @moduledoc """ + Speakers participate in the event's activities. + """ + use Safira.Schema + + alias Safira.Activities + + @required_fields ~w(name company title)a + @optional_fields ~w(biography highlighted)a + + @derive { + Flop.Schema, + filterable: [:name], sortable: [:name, :company], default_limit: 4 + } + + schema "speakers" do + field :name, :string + field :title, :string + field :picture, Uploaders.Speaker.Type + field :company, :string + field :biography, :string + field :highlighted, :boolean, default: false + + embeds_one :socials, Activities.Speaker.Socials + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(speaker, attrs) do + speaker + |> cast(attrs, @required_fields ++ @optional_fields) + |> cast_embed(:socials) + |> validate_required(@required_fields) + end + + @doc false + def picture_changeset(speaker, attrs) do + speaker + |> cast_attachments(attrs, [:picture]) + end +end + +defmodule Safira.Activities.Speaker.Socials do + @moduledoc """ + Social media handles for speakers. + """ + use Safira.Schema + + embedded_schema do + field :github, :string + field :linkedin, :string + field :website, :string + field :x, :string + end + + @doc false + def changeset(socials, attrs) do + socials + |> cast(attrs, [:github, :linkedin, :website, :x]) + |> validate_url(:website) + |> validate_github() + |> validate_linkedin() + |> validate_x() + end + + def validate_github(changeset) do + changeset + |> validate_format( + :github, + ~r/^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i, + message: "not a valid github handle" + ) + end + + def validate_linkedin(changeset) do + changeset + |> validate_format( + :linkedin, + ~r/^[a-zA-Z0-9]{3,100}$/, + message: "not a valid linkedin handle" + ) + end + + def validate_x(changeset) do + changeset + |> validate_format( + :x, + ~r/^[A-Za-z0-9_]{4,15}$/, + message: "not a valid x handle" + ) + end +end diff --git a/lib/safira/companies.ex b/lib/safira/companies.ex index 071ebd3a..ea3b21e2 100644 --- a/lib/safira/companies.ex +++ b/lib/safira/companies.ex @@ -5,7 +5,7 @@ defmodule Safira.Companies do use Safira.Context - alias Safira.Companies.Company + alias Safira.Companies.{Company, Tier} @doc """ Returns the list of companies. @@ -141,8 +141,6 @@ defmodule Safira.Companies do Company.changeset(company, attrs) end - alias Safira.Companies.Tier - @doc """ Returns the list of tiers. diff --git a/lib/safira/companies/company.ex b/lib/safira/companies/company.ex index d1e57d4d..33af89d7 100644 --- a/lib/safira/companies/company.ex +++ b/lib/safira/companies/company.ex @@ -45,8 +45,8 @@ defmodule Safira.Companies.Company do end @doc false - def image_changeset(badge, attrs) do - badge + def image_changeset(company, attrs) do + company |> cast_attachments(attrs, [:logo]) end end diff --git a/lib/safira/schema.ex b/lib/safira/schema.ex index d3b81373..1063992e 100644 --- a/lib/safira/schema.ex +++ b/lib/safira/schema.ex @@ -17,7 +17,7 @@ defmodule Safira.Schema do def validate_url(changeset, field) do changeset |> validate_format( - :url, + field, ~r/^https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&\/=]*)$/, message: "must start with http:// or https:// and have a valid domain" ) diff --git a/lib/safira/uploaders/speaker.ex b/lib/safira/uploaders/speaker.ex new file mode 100644 index 00000000..e753cb14 --- /dev/null +++ b/lib/safira/uploaders/speaker.ex @@ -0,0 +1,28 @@ +defmodule Safira.Uploaders.Speaker do + @moduledoc """ + Speaker image uploader. + """ + use Safira.Uploader + + alias Safira.Activities.Speaker + + @versions [:original] + @extension_whitelist ~w(.jpg .jpeg .png) + + def validate({file, _}) do + file_extension = file.file_name |> Path.extname() |> String.downcase() + Enum.member?(extension_whitelist(), file_extension) + end + + def storage_dir(_, {_file, %Speaker{} = speaker}) do + "uploads/activities/speakers/#{speaker.id}" + end + + def filename(version, _) do + version + end + + def extension_whitelist do + @extension_whitelist + end +end diff --git a/lib/safira_web/components/image_uploader.ex b/lib/safira_web/components/image_uploader.ex index 2f8dabcd..606c71f3 100644 --- a/lib/safira_web/components/image_uploader.ex +++ b/lib/safira_web/components/image_uploader.ex @@ -26,7 +26,7 @@ defmodule SafiraWeb.Components.ImageUploader do <% else %>
<.icon name={@icon} class="w-12 h-12" /> -

<%= gettext("Upload a file or drag and drop") %>

+

<%= gettext("Upload a file or drag and drop.") %>

<% end %> diff --git a/lib/safira_web/components/table_search.ex b/lib/safira_web/components/table_search.ex index 31ccbe4e..5da9c5b9 100644 --- a/lib/safira_web/components/table_search.ex +++ b/lib/safira_web/components/table_search.ex @@ -10,6 +10,7 @@ defmodule SafiraWeb.Components.TableSearch do attr :field, :atom, required: true attr :path, :string, required: true attr :placeholder, :string, default: gettext("Search") + attr :class, :string, default: "" def table_search(assigns) do ~H""" @@ -20,6 +21,7 @@ defmodule SafiraWeb.Components.TableSearch do field={@field} path={@path} placeholder={@placeholder} + class={@class} /> """ end @@ -47,7 +49,7 @@ defmodule SafiraWeb.Components.TableSearchLiveComponent do name="search[query]" spellcheck="false" placeholder={@placeholder} - class="block w-80 p-2 ps-10 text-sm text-dark border border-lightShade rounded-md placeholder:text-darkMuted focus:outline-2 focus:border-lightShade ring-0 focus:outline-dark focus:outline-offset-2 dark:outline-darkShade dark:bg-dark dark:text-light dark:placeholder-lightMuted dark:focus:border-darkShade dark:focus:border-darkShade dark:border-darkShade focus:ring-0 dark:focus:outline-light" + class={"block w-80 p-2 ps-10 text-sm text-dark border border-lightShade rounded-md placeholder:text-darkMuted focus:outline-2 focus:border-lightShade ring-0 focus:outline-dark focus:outline-offset-2 dark:outline-darkShade dark:bg-dark dark:text-light dark:placeholder-lightMuted dark:focus:border-darkShade dark:focus:border-darkShade dark:border-darkShade focus:ring-0 dark:focus:outline-light #{@class}"} /> diff --git a/lib/safira_web/live/backoffice/product_live/form_component.ex b/lib/safira_web/live/backoffice/product_live/form_component.ex index bd6f5a68..a76cc75c 100644 --- a/lib/safira_web/live/backoffice/product_live/form_component.ex +++ b/lib/safira_web/live/backoffice/product_live/form_component.ex @@ -4,8 +4,7 @@ defmodule SafiraWeb.Backoffice.ProductLive.FormComponent do alias Safira.Store alias Safira.Uploaders.Product - import SafiraWeb.Components.ImageUploader - import SafiraWeb.Components.Forms + import SafiraWeb.Components.{Forms, ImageUploader} @impl true def render(assigns) do diff --git a/lib/safira_web/live/backoffice/schedule_live/index.ex b/lib/safira_web/live/backoffice/schedule_live/index.ex index cc47a806..ea8310f1 100644 --- a/lib/safira_web/live/backoffice/schedule_live/index.ex +++ b/lib/safira_web/live/backoffice/schedule_live/index.ex @@ -1,10 +1,11 @@ defmodule SafiraWeb.Backoffice.ScheduleLive.Index do + alias Safira.Activities.Speaker use SafiraWeb, :backoffice_view import SafiraWeb.Components.{Table, TableSearch} alias Safira.Activities - alias Safira.Activities.{Activity, ActivityCategory} + alias Safira.Activities.{Activity, ActivityCategory, Speaker} on_mount {SafiraWeb.StaffRoles, index: %{"schedule" => ["edit"]}} @@ -15,7 +16,14 @@ defmodule SafiraWeb.Backoffice.ScheduleLive.Index do @impl true def handle_params(params, _url, socket) do - case Activities.list_activities(params, preloads: [:category]) do + case Activities.list_activities( + if socket.assigns.live_action != :speakers do + params + else + %{} + end, + preloads: [:category] + ) do {:ok, {activities, meta}} -> {:noreply, socket @@ -66,6 +74,32 @@ defmodule SafiraWeb.Backoffice.ScheduleLive.Index do |> assign(:page_title, "Listing Categories") end + defp apply_action(socket, :speakers_edit, %{"id" => id}) do + socket + |> assign(:page_title, "Edit Speaker") + |> assign(:speaker, Activities.get_speaker!(id)) + end + + defp apply_action(socket, :speakers_new, _params) do + socket + |> assign(:page_title, "New Speaker") + |> assign(:speaker, %Speaker{}) + end + + defp apply_action(socket, :speakers, params) do + case Activities.list_speakers(params) do + {:ok, {speakers, meta}} -> + socket + |> assign(:page_title, "Listing Speakers") + |> assign(:speakers_meta, meta) + |> assign(:params, params) + |> stream(:speakers, speakers, reset: true) + + {:error, _} -> + {:ok, socket} + end + end + @impl true def handle_event("delete", %{"id" => id}, socket) do activity = Activities.get_activity!(id) diff --git a/lib/safira_web/live/backoffice/schedule_live/index.html.heex b/lib/safira_web/live/backoffice/schedule_live/index.html.heex index ad87abf8..cdd02e7c 100644 --- a/lib/safira_web/live/backoffice/schedule_live/index.html.heex +++ b/lib/safira_web/live/backoffice/schedule_live/index.html.heex @@ -14,6 +14,14 @@ + <.ensure_permissions user={@current_user} permissions={%{"schedule" => ["edit"]}}> + <.link patch={~p"/dashboard/schedule/activities/speakers"}> + <.button> + <.icon name="hero-user" class="w-5 h-5" /> + + + + <.ensure_permissions user={@current_user} permissions={%{"schedule" => ["edit"]}}> <.link patch={~p"/dashboard/schedule/activities/categories"}> <.button> @@ -79,6 +87,42 @@ /> +<.modal + :if={@live_action in [:speakers]} + id="speakers-modal" + show + on_cancel={JS.patch(~p"/dashboard/schedule/activities")} +> + <.live_component + module={SafiraWeb.Backoffice.ScheduleLive.SpeakerLive.Index} + id="speakers" + title={@page_title} + current_user={@current_user} + action={@live_action} + params={@params} + streams={@streams} + meta={@speakers_meta} + patch={~p"/dashboard/schedule/activities"} + /> + + +<.modal + :if={@live_action in [:speakers_edit, :speakers_new]} + id="speakers-modal" + show + on_cancel={JS.navigate(~p"/dashboard/schedule/activities/speakers")} +> + <.live_component + module={SafiraWeb.Backoffice.ScheduleLive.SpeakerLive.FormComponent} + id={@speaker.id || :new} + title={@page_title} + current_user={@current_user} + action={@live_action} + speaker={@speaker} + patch={~p"/dashboard/schedule/activities/speakers"} + /> + + <.modal :if={@live_action in [:categories]} id="categories-modal" @@ -87,7 +131,7 @@ > <.live_component module={SafiraWeb.Backoffice.ScheduleLive.CategoryLive.Index} - id="categories-tiers" + id="categories" title={@page_title} current_user={@current_user} action={@live_action} diff --git a/lib/safira_web/live/backoffice/schedule_live/speaker_live/form_component.ex b/lib/safira_web/live/backoffice/schedule_live/speaker_live/form_component.ex new file mode 100644 index 00000000..3c412e7e --- /dev/null +++ b/lib/safira_web/live/backoffice/schedule_live/speaker_live/form_component.ex @@ -0,0 +1,168 @@ +defmodule SafiraWeb.Backoffice.ScheduleLive.SpeakerLive.FormComponent do + alias Safira.Activities + use SafiraWeb, :live_component + + alias Safira.Uploaders.Speaker + + import SafiraWeb.Components.{Forms, ImageUploader} + + @impl true + def render(assigns) do + ~H""" +
+ <.page title={@title} subtitle={gettext("Speakers participate in the event's activities.")}> + <.simple_form + for={@form} + id="speaker-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save" + > +
+ <.field field={@form[:name]} type="text" label="Name" required /> +
+ <.field + field={@form[:company]} + type="text" + label="Company" + wrapper_class="w-full" + required + /> + <.field field={@form[:title]} type="text" label="Title" wrapper_class="w-full" required /> +
+ <.field field={@form[:biography]} type="textarea" label="Biography" /> +
+
+ <.inputs_for :let={socials_form} field={@form[:socials]}> + <.field + field={socials_form[:github]} + type="text" + label="GitHub" + wrapper_class="w-full" + /> + <.field + field={socials_form[:linkedin]} + type="text" + label="LinkedIn" + wrapper_class="w-full" + /> + <.field + field={socials_form[:website]} + type="text" + label="Website" + wrapper_class="w-full" + /> + <.field field={socials_form[:x]} type="text" label="X" wrapper_class="w-full" /> + + <.field + field={@form[:highlighted]} + type="switch" + label="Highlighted" + wrapper_class="w-full" + /> +
+
+ <.label> + <%= gettext("Picture") %> + + <.image_uploader + class="w-full aspect-square" + upload={@uploads.picture} + icon="hero-user" + image={Uploaders.Speaker.url({@speaker.picture, @speaker}, :original)} + /> +
+
+
+ <:actions> + <.button phx-disable-with="Saving...">Save Speaker + + + +
+ """ + end + + @impl true + def mount(socket) do + {:ok, + socket + |> assign(:uploaded_files, []) + |> allow_upload(:picture, + accept: Speaker.extension_whitelist(), + max_entries: 1 + )} + end + + @impl true + def update(%{speaker: speaker} = assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> assign_new(:form, fn -> + to_form(Activities.change_speaker(speaker)) + end)} + end + + @impl true + def handle_event("validate", %{"speaker" => speaker_params}, socket) do + changeset = Activities.change_speaker(socket.assigns.speaker, speaker_params) + + {:noreply, assign(socket, form: to_form(changeset, action: :validate))} + end + + def handle_event("save", %{"speaker" => speaker_params}, socket) do + save_speaker(socket, socket.assigns.action, speaker_params) + end + + defp save_speaker(socket, :speakers_edit, speaker_params) do + case Activities.update_speaker(socket.assigns.speaker, speaker_params) do + {:ok, speaker} -> + case consume_picture_data(speaker, socket) do + {:ok, _speaker} -> + {:noreply, + socket + |> put_flash(:info, "Speaker updated successfully") + |> push_patch(to: socket.assigns.patch)} + end + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp save_speaker(socket, :speakers_new, speaker_params) do + case Activities.create_speaker(speaker_params) do + {:ok, speaker} -> + case consume_picture_data(speaker, socket) do + {:ok, _speaker} -> + {:noreply, + socket + |> put_flash(:info, "Speaker created successfully") + |> push_patch(to: socket.assigns.patch)} + end + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp consume_picture_data(speaker, socket) do + consume_uploaded_entries(socket, :picture, fn %{path: path}, entry -> + Activities.update_speaker_picture(speaker, %{ + "picture" => %Plug.Upload{ + content_type: entry.client_type, + filename: entry.client_name, + path: path + } + }) + end) + |> case do + [{:ok, speaker}] -> + {:ok, speaker} + + _errors -> + {:ok, speaker} + end + end +end diff --git a/lib/safira_web/live/backoffice/schedule_live/speaker_live/index.ex b/lib/safira_web/live/backoffice/schedule_live/speaker_live/index.ex new file mode 100644 index 00000000..a4668d55 --- /dev/null +++ b/lib/safira_web/live/backoffice/schedule_live/speaker_live/index.ex @@ -0,0 +1,89 @@ +defmodule SafiraWeb.Backoffice.ScheduleLive.SpeakerLive.Index do + use SafiraWeb, :live_component + + alias Safira.Activities + alias Safira.Uploaders + + import SafiraWeb.Components.{EnsurePermissions, Table, TableSearch} + + @impl true + def render(assigns) do + ~H""" +
+ <.page title={@title}> + <:actions> + <.ensure_permissions user={@current_user} permissions={%{"schedule" => ["edit"]}}> + <.link navigate={~p"/dashboard/schedule/activities/speakers/new"}> + <.button>New Speaker + + + +
+ <.table_search + id="speaker-table-name-search" + params={@params} + field={:name} + path={~p"/dashboard/schedule/activities/speakers"} + placeholder={gettext("Search for speakers")} + class="w-full" + /> + <.table id="speakers-table" items={@streams.speakers} meta={@meta} params={@params}> + <:col :let={{_id, speaker}} sortable field={:name} label="Name"> +
+ <%= if speaker.picture do %> + + <% else %> + String.slice(0..2)}.png"} + /> + <% end %> +
+

<%= speaker.name %>

+

<%= speaker.title %>

+
+
+ + <:col :let={{_id, speaker}} sortable field={:company} label="Company"> + <%= speaker.company %> + + <:action :let={{id, speaker}}> + <.ensure_permissions user={@current_user} permissions={%{"schedule" => ["edit"]}}> +
+ <.link patch={~p"/dashboard/schedule/activities/speakers/#{speaker.id}/edit"}> + <.icon name="hero-pencil" class="w-5 h-5" /> + + <.link + phx-click={ + JS.push("delete", value: %{id: speaker.id}, target: @myself) |> hide("##{id}") + } + data-confirm="Are you sure?" + > + <.icon name="hero-trash" class="w-5 h-5" /> + +
+ + + +
+ +
+ """ + end + + @impl true + def mount(socket) do + {:ok, socket} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + speaker = Activities.get_speaker!(id) + {:ok, _} = Activities.delete_speaker(speaker) + + {:noreply, stream_delete(socket, :speakers, speaker)} + end +end diff --git a/lib/safira_web/router.ex b/lib/safira_web/router.ex index 78938672..008ad000 100644 --- a/lib/safira_web/router.ex +++ b/lib/safira_web/router.ex @@ -130,13 +130,16 @@ defmodule SafiraWeb.Router do live "/new", Index, :new live "/:id/edit", Index, :edit + scope "/speakers" do + live "/", Index, :speakers + live "/new", Index, :speakers_new + live "/:id/edit", Index, :speakers_edit + end + scope "/categories" do live "/", Index, :categories live "/new", Index, :categories_new - - scope "/:id" do - live "/edit", Index, :categories_edit - end + live "/:id/edit", Index, :categories_edit end end end diff --git a/priv/repo/migrations/20241102191718_create_speakers.exs b/priv/repo/migrations/20241102191718_create_speakers.exs new file mode 100644 index 00000000..55d5dbbb --- /dev/null +++ b/priv/repo/migrations/20241102191718_create_speakers.exs @@ -0,0 +1,18 @@ +defmodule Safira.Repo.Migrations.CreateSpeakers do + use Ecto.Migration + + def change do + create table(:speakers, primary_key: false) do + add :id, :binary_id, primary_key: true + add :name, :string + add :picture, :string + add :company, :string + add :title, :string + add :biography, :text + add :highlighted, :boolean, default: false, null: false + add :socials, :map + + timestamps(type: :utc_datetime) + end + end +end diff --git a/test/safira/activities_test.exs b/test/safira/activities_test.exs index 83af7fa8..d166e49d 100644 --- a/test/safira/activities_test.exs +++ b/test/safira/activities_test.exs @@ -157,4 +157,79 @@ defmodule Safira.ActivitiesTest do assert %Ecto.Changeset{} = Activities.change_activity_category(activity_category) end end + + describe "speakers" do + alias Safira.Activities.Speaker + + import Safira.ActivitiesFixtures + + @invalid_attrs %{name: nil, title: nil, company: nil, biography: nil, highlighted: nil} + + test "list_speakers/0 returns all speakers" do + speaker = speaker_fixture() + assert Activities.list_speakers() == [speaker] + end + + test "get_speaker!/1 returns the speaker with given id" do + speaker = speaker_fixture() + assert Activities.get_speaker!(speaker.id) == speaker + end + + test "create_speaker/1 with valid data creates a speaker" do + valid_attrs = %{ + name: "some name", + title: "some title", + company: "some company", + biography: "some biography", + highlighted: true + } + + assert {:ok, %Speaker{} = speaker} = Activities.create_speaker(valid_attrs) + assert speaker.name == "some name" + assert speaker.title == "some title" + assert speaker.company == "some company" + assert speaker.biography == "some biography" + assert speaker.highlighted == true + end + + test "create_speaker/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Activities.create_speaker(@invalid_attrs) + end + + test "update_speaker/2 with valid data updates the speaker" do + speaker = speaker_fixture() + + update_attrs = %{ + name: "some updated name", + title: "some updated title", + company: "some updated company", + biography: "some updated biography", + highlighted: false + } + + assert {:ok, %Speaker{} = speaker} = Activities.update_speaker(speaker, update_attrs) + assert speaker.name == "some updated name" + assert speaker.title == "some updated title" + assert speaker.company == "some updated company" + assert speaker.biography == "some updated biography" + assert speaker.highlighted == false + end + + test "update_speaker/2 with invalid data returns error changeset" do + speaker = speaker_fixture() + assert {:error, %Ecto.Changeset{}} = Activities.update_speaker(speaker, @invalid_attrs) + assert speaker == Activities.get_speaker!(speaker.id) + end + + test "delete_speaker/1 deletes the speaker" do + speaker = speaker_fixture() + assert {:ok, %Speaker{}} = Activities.delete_speaker(speaker) + assert_raise Ecto.NoResultsError, fn -> Activities.get_speaker!(speaker.id) end + end + + test "change_speaker/1 returns a speaker changeset" do + speaker = speaker_fixture() + assert %Ecto.Changeset{} = Activities.change_speaker(speaker) + end + end end diff --git a/test/support/fixtures/activities_fixtures.ex b/test/support/fixtures/activities_fixtures.ex index acf27479..c7cebf8d 100644 --- a/test/support/fixtures/activities_fixtures.ex +++ b/test/support/fixtures/activities_fixtures.ex @@ -37,4 +37,22 @@ defmodule Safira.ActivitiesFixtures do activity_category end + + @doc """ + Generate a speaker. + """ + def speaker_fixture(attrs \\ %{}) do + {:ok, speaker} = + attrs + |> Enum.into(%{ + biography: "some biography", + company: "some company", + highlighted: true, + name: "some name", + title: "some title" + }) + |> Safira.Activities.create_speaker() + + speaker + end end From a5e54176a1ab04ddfd01193f3f83394f8b9bb8de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lobo?= Date: Mon, 4 Nov 2024 00:27:53 +0000 Subject: [PATCH 04/15] feat: multiselect component & activity speakers --- assets/css/components/field.css | 26 ++++++ assets/js/app.js | 4 +- lib/safira/activities.ex | 40 ++++++++-- lib/safira/activities/activity.ex | 11 +++ lib/safira/activities/speaker.ex | 4 + lib/safira_web/components/forms.ex | 61 ++++++++++++++ .../schedule_live/form_component.ex | 79 +++++++++++++++---- .../live/backoffice/schedule_live/index.ex | 7 +- mix.exs | 1 + mix.lock | 2 + ...41103215838_create_activities_speakers.exs | 16 ++++ test/support/fixtures/activities_fixtures.ex | 2 +- 12 files changed, 229 insertions(+), 24 deletions(-) create mode 100644 priv/repo/migrations/20241103215838_create_activities_speakers.exs diff --git a/assets/css/components/field.css b/assets/css/components/field.css index 48f1291e..2b49bb09 100644 --- a/assets/css/components/field.css +++ b/assets/css/components/field.css @@ -196,3 +196,29 @@ .safira-form-help-text { @apply mt-2 text-sm text-gray-500 dark:text-gray-400; } + +/* Multiselect */ + +.safira-multiselect-dropdown { + @apply absolute z-50 border border-t-0 border-lightShade dark:border-darkShade mt-1 rounded-b-md pb-1 bg-light dark:bg-dark; +} + +.safira-multiselect-dropdown-option { + @apply mx-1 my-1 px-3 py-1 hover:bg-lightShade/40 dark:hover:bg-darkShade cursor-pointer rounded-md text-sm; +} + +.safira-multiselect-dropdown-option-selected { + @apply opacity-60; +} + +.safira-multiselect-dropdown-tags-container { + @apply absolute flex mt-12 gap-1 flex-wrap; +} + +.safira-multiselect-dropdown-tag { + @apply text-xs flex items-center border border-lightShade dark:border-darkShade pl-2 rounded-md z-30; +} + +.safira-multiselect-dropdown-tag-remove { + @apply cursor-pointer dark:opacity-80 scale-75; +} \ No newline at end of file diff --git a/assets/js/app.js b/assets/js/app.js index 8209d75f..7ebc74dc 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -21,13 +21,15 @@ import "phoenix_html" import {Socket} from "phoenix" import {LiveSocket} from "phoenix_live_view" import topbar from "../vendor/topbar" +import live_select from "live_select" import { QrScanner, Wheel, Confetti, Sorting } from "./hooks"; let Hooks = { QrScanner: QrScanner, Wheel: Wheel, Confetti: Confetti, - Sorting: Sorting + Sorting: Sorting, + ...live_select }; let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") diff --git a/lib/safira/activities.ex b/lib/safira/activities.ex index 37f2bbfb..8efb72da 100644 --- a/lib/safira/activities.ex +++ b/lib/safira/activities.ex @@ -5,7 +5,7 @@ defmodule Safira.Activities do use Safira.Context - alias Safira.Activities.{Activity, Speaker} + alias Safira.Activities.{Activity, ActivityCategory, Speaker} @doc """ Returns the list of activities. @@ -17,22 +17,27 @@ defmodule Safira.Activities do """ def list_activities do - Repo.all(Activity) + Activity + |> preload(:speakers) + |> Repo.all() end def list_activities(opts) when is_list(opts) do Activity |> apply_filters(opts) + |> preload(:speakers) |> Repo.all() end def list_activities(params) do Activity + |> preload(:speakers) |> Flop.validate_and_run(params, for: Activity) end def list_activities(%{} = params, opts) when is_list(opts) do Activity + |> preload(:speakers) |> apply_filters(opts) |> Flop.validate_and_run(params, for: Activity) end @@ -51,7 +56,11 @@ defmodule Safira.Activities do ** (Ecto.NoResultsError) """ - def get_activity!(id), do: Repo.get!(Activity, id) + def get_activity!(id) do + Activity + |> preload(:speakers) + |> Repo.get!(id) + end @doc """ Creates a activity. @@ -89,6 +98,29 @@ defmodule Safira.Activities do |> Repo.update() end + @doc """ + Updates an activity's speakers. + + ## Examples + + iex> upsert_activity_speakers(activity, [1, 2, 3]) + {:ok, %Activity{}} + + iex> upsert_activity_speakers(activity, [1, 2, 3]) + {:error, %Ecto.Changeset{}} + + """ + def upsert_activity_speakers(%Activity{} = activity, speaker_ids) do + speakers = + Speaker + |> where([s], s.id in ^speaker_ids) + |> Repo.all() + + activity + |> Activity.changeset_update_speakers(speakers) + |> Repo.update() + end + @doc """ Deletes a activity. @@ -118,8 +150,6 @@ defmodule Safira.Activities do Activity.changeset(activity, attrs) end - alias Safira.Activities.ActivityCategory - @doc """ Returns the list of activity_categories. diff --git a/lib/safira/activities/activity.ex b/lib/safira/activities/activity.ex index de18cf45..dec9a5f0 100644 --- a/lib/safira/activities/activity.ex +++ b/lib/safira/activities/activity.ex @@ -35,6 +35,10 @@ defmodule Safira.Activities.Activity do belongs_to :category, Safira.Activities.ActivityCategory + many_to_many :speakers, Safira.Activities.Speaker, + join_through: "activities_speakers", + on_replace: :delete + timestamps(type: :utc_datetime) end @@ -44,4 +48,11 @@ defmodule Safira.Activities.Activity do |> cast(attrs, @required_fields ++ @optional_fields) |> validate_required(@required_fields) end + + @doc false + def changeset_update_speakers(activity, speakers) do + activity + |> cast(%{}, @required_fields ++ @optional_fields) + |> put_assoc(:speakers, speakers) + end end diff --git a/lib/safira/activities/speaker.ex b/lib/safira/activities/speaker.ex index 67d48b8d..069681f9 100644 --- a/lib/safira/activities/speaker.ex +++ b/lib/safira/activities/speaker.ex @@ -24,6 +24,10 @@ defmodule Safira.Activities.Speaker do embeds_one :socials, Activities.Speaker.Socials + many_to_many :activities, Activities.Activity, + join_through: "activities_speakers", + on_replace: :delete + timestamps(type: :utc_datetime) end diff --git a/lib/safira_web/components/forms.ex b/lib/safira_web/components/forms.ex index 4e2c2431..788735f5 100644 --- a/lib/safira_web/components/forms.ex +++ b/lib/safira_web/components/forms.ex @@ -4,6 +4,8 @@ defmodule SafiraWeb.Components.Forms do """ use Phoenix.Component + import LiveSelect + alias Phoenix.HTML @input_types ~w( @@ -483,4 +485,63 @@ defmodule SafiraWeb.Components.Forms do bin |> String.replace("_", " ") |> :string.titlecase() end + + attr :id, :any, default: nil, doc: "The id of the input. If not provided, it will be generated." + attr :name, :any, doc: "The name of the input. If not provided, it will be generated." + attr :class, :string, default: nil, doc: "The class to be added to the input." + + attr :errors, :list, + default: [], + doc: "A list of erros to be displayed. If not provided, it will be generated." + + attr :wrapper_class, :string, default: nil, doc: "The wrapper div class." + attr :label_class, :string, default: nil, doc: "Extra class for the label." + attr :help_text, :string, default: nil, doc: "Context/help for the input." + + attr :required, :boolean, + default: false, + doc: + "If the input is required. In positive cases, it will add the `required` attribute to the input and a `*` to the label." + + attr :target, :any, default: nil, doc: "The target for the live select component." + + attr :field, HTML.FormField, + doc: "A form field struct retrieved from the form, for example: `@form[:email]`." + + attr :rest, :global, + include: ~w(value_mapper placeholder), + doc: "Any other attribute to be added to the input." + + def field_multiselect(assigns) do + ~H""" + <.field_wrapper + errors={@errors} + name={Map.get(assigns, :name, @field.name)} + class={@wrapper_class} + > + <.field_label required={@required} for={@id} class={@label_class}> + <%= humanize(@field.field) %> + + + <.live_select + id={assigns.id || @field.id} + mode={:tags} + field={@field} + phx-target={@target} + container_class={"#{@wrapper_class}"} + text_input_class="safira-text-input" + dropdown_class="safira-multiselect-dropdown" + option_class="safira-multiselect-dropdown-option" + selected_option_class="safira-multiselect-dropdown-option-selected" + tags_container_class="safira-multiselect-dropdown-tags-container" + tag_class="safira-multiselect-dropdown-tag" + clear_tag_button_class="safira-multiselect-dropdown-tag-remove" + {@rest} + /> + + <.field_error :for={msg <- @errors}><%= msg %> + <.field_help_text help_text={@help_text} /> + + """ + end end diff --git a/lib/safira_web/live/backoffice/schedule_live/form_component.ex b/lib/safira_web/live/backoffice/schedule_live/form_component.ex index 2eeacdc4..9342e074 100644 --- a/lib/safira_web/live/backoffice/schedule_live/form_component.ex +++ b/lib/safira_web/live/backoffice/schedule_live/form_component.ex @@ -2,6 +2,7 @@ defmodule SafiraWeb.Backoffice.ScheduleLive.FormComponent do use SafiraWeb, :live_component alias Safira.Activities + alias Safira.Activities.Speaker import SafiraWeb.Components.Forms @impl true @@ -45,13 +46,22 @@ defmodule SafiraWeb.Backoffice.ScheduleLive.FormComponent do wrapper_class="col-span-1" /> - <.field - field={@form[:category_id]} - type="select" - label="Category" - options={categories_options(@categories)} - wrapper_class="w-full" - /> +
+ <.field + field={@form[:category_id]} + type="select" + label="Category" + options={categories_options(@categories)} + wrapper_class="w-full" + /> + <.field_multiselect + field={@form[:speakers]} + target={@myself} + value_mapper={&value_mapper/1} + wrapper_class="w-full" + placeholder={gettext("Search for speakers")} + /> +
@@ -120,13 +130,40 @@ defmodule SafiraWeb.Backoffice.ScheduleLive.FormComponent do save_activity(socket, socket.assigns.action, activity_params) end + @impl true + def handle_event("live_select_change", %{"text" => text, "id" => live_select_id}, socket) do + case Activities.list_speakers(%{ + "filters" => %{"1" => %{"field" => "name", "op" => "ilike_or", "value" => text}} + }) do + {:ok, {speakers, _meta}} -> + send_update(LiveSelect.Component, + id: live_select_id, + options: speakers |> Enum.map(&{&1.name, &1.id}) + ) + + {:noreply, socket} + + {:error, _} -> + {:noreply, socket} + end + end + defp save_activity(socket, :edit, activity_params) do case Activities.update_activity(socket.assigns.activity, activity_params) do {:ok, _activity} -> - {:noreply, - socket - |> put_flash(:info, "Activity updated successfully") - |> push_patch(to: socket.assigns.patch)} + case Activities.upsert_activity_speakers( + socket.assigns.activity, + activity_params["speakers"] + ) do + {:ok, _activity} -> + {:noreply, + socket + |> put_flash(:info, "Activity updated successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end {:error, %Ecto.Changeset{} = changeset} -> {:noreply, assign(socket, form: to_form(changeset))} @@ -135,11 +172,17 @@ defmodule SafiraWeb.Backoffice.ScheduleLive.FormComponent do defp save_activity(socket, :new, activity_params) do case Activities.create_activity(activity_params) do - {:ok, _activity} -> - {:noreply, - socket - |> put_flash(:info, "Activity created successfully") - |> push_patch(to: socket.assigns.patch)} + {:ok, activity} -> + case Activities.upsert_activity_speakers(activity, activity_params["speakers"]) do + {:ok, _activity} -> + {:noreply, + socket + |> put_flash(:info, "Activity created successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end {:error, %Ecto.Changeset{} = changeset} -> {:noreply, assign(socket, form: to_form(changeset))} @@ -150,4 +193,8 @@ defmodule SafiraWeb.Backoffice.ScheduleLive.FormComponent do [{"None", nil}] ++ Enum.map(categories, &{&1.name, &1.id}) end + + defp value_mapper(%Speaker{} = speaker), do: {speaker.name, speaker.id} + + defp value_mapper(id), do: id end diff --git a/lib/safira_web/live/backoffice/schedule_live/index.ex b/lib/safira_web/live/backoffice/schedule_live/index.ex index ea8310f1..f0c2040b 100644 --- a/lib/safira_web/live/backoffice/schedule_live/index.ex +++ b/lib/safira_web/live/backoffice/schedule_live/index.ex @@ -77,7 +77,12 @@ defmodule SafiraWeb.Backoffice.ScheduleLive.Index do defp apply_action(socket, :speakers_edit, %{"id" => id}) do socket |> assign(:page_title, "Edit Speaker") - |> assign(:speaker, Activities.get_speaker!(id)) + |> assign( + :speaker, + Map.update!(Activities.get_speaker!(id), :speakers, fn speakers -> + speakers |> Enum.map(&{&1.name, &1.id}) + end) + ) end defp apply_action(socket, :speakers_new, _params) do diff --git a/mix.exs b/mix.exs index 97eef8e4..62431884 100644 --- a/mix.exs +++ b/mix.exs @@ -70,6 +70,7 @@ defmodule Safira.MixProject do compile: false, depth: 1}, {:esbuild, "~> 0.8", runtime: Mix.env() == :dev}, + {:live_select, "~> 1.4"}, # monitoring {:telemetry_metrics, "~> 1.0"}, diff --git a/mix.lock b/mix.lock index 681eb027..17e7a7bc 100644 --- a/mix.lock +++ b/mix.lock @@ -28,6 +28,7 @@ "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.3", "d3f984eeb96fe53b85d20e0b049f03e57d075b5acda3ac8d465c969a2536c17b", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "9a90e868927f7c777689baa16d86f4d0e086d968db5c05d917ccff6d443e58a3"}, "jumper": {:hex, :jumper, "1.0.2", "68cdcd84472a00ac596b4e6459a41b3062d4427cbd4f1e8c8793c5b54f1406a7", [:mix], [], "hexpm", "9b7782409021e01ab3c08270e26f36eb62976a38c1aa64b2eaf6348422f165e1"}, + "live_select": {:hex, :live_select, "1.4.3", "ec9706952f589d8e2e6f98a0e1633c5b51ab5b807d503bd0d9622a26c999fb9a", [:mix], [{:ecto, "~> 3.8", [hex: :ecto, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, ">= 1.6.0", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "58f7d702b0f786c73d31e60a342c0a49afaf56ca5a6a078b51babf3490465220"}, "lua": {:hex, :lua, "0.0.14", "0f9f2b44271debdf855efe87583f73e874c4daec1e920c45a73d1fa8e3c2f9a8", [:mix], [{:luerl, "~> 1.2", [hex: :luerl, repo: "hexpm", optional: false]}], "hexpm", "9bd39736c349dd47541a5619925f00d7cf3f2a6d3d33248b80f9eac81f5850d3"}, "luerl": {:hex, :luerl, "1.2.0", "60f05f4240f0e7c148ddb79b67b8ff972734aad237aa74c83d0748b8214c8ef0", [:rebar3], [], "hexpm", "9cafd4f6094ff0f5a9d278fd81d60d3e026c820bdfb6cacd4b1bd909f21b525d"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, @@ -40,6 +41,7 @@ "phoenix": {:hex, :phoenix, "1.7.14", "a7d0b3f1bc95987044ddada111e77bd7f75646a08518942c72a8440278ae7825", [: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", "c7859bc56cc5dfef19ecfc240775dae358cbaa530231118a9e014df392ace61a"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.2", "3b83b24ab5a2eb071a20372f740d7118767c272db386831b2e77638c4dcc606d", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "3f94d025f59de86be00f5f8c5dd7b5965a3298458d21ab1c328488be3b5fcd59"}, "phoenix_html": {:hex, :phoenix_html, "4.1.1", "4c064fd3873d12ebb1388425a8f2a19348cef56e7289e1998e2d2fa758aa982e", [:mix], [], "hexpm", "f2f2df5a72bc9a2f510b21497fd7d2b86d932ec0598f0210fed4114adc546c6f"}, + "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, "phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.4", "4508e481f791ce62ec6a096e13b061387158cbeefacca68c6c1928e1305e23ed", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "2984aae96994fbc5c61795a73b8fb58153b41ff934019cfb522343d2d3817d59"}, "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.5.3", "f2161c207fda0e4fb55165f650f7f8db23f02b29e3bff00ff7ef161d6ac1f09d", [:mix], [{:file_system, "~> 0.3 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "b4ec9cd73cb01ff1bd1cac92e045d13e7030330b74164297d1aee3907b54803c"}, "phoenix_live_view": {:hex, :phoenix_live_view, "0.20.17", "f396bbdaf4ba227b82251eb75ac0afa6b3da5e509bc0d030206374237dfc9450", [:mix], [{:floki, "~> 0.36", [hex: :floki, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, 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.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61d741ffb78c85fdbca0de084da6a48f8ceb5261a79165b5a0b59e5f65ce98b"}, diff --git a/priv/repo/migrations/20241103215838_create_activities_speakers.exs b/priv/repo/migrations/20241103215838_create_activities_speakers.exs new file mode 100644 index 00000000..7b3b4570 --- /dev/null +++ b/priv/repo/migrations/20241103215838_create_activities_speakers.exs @@ -0,0 +1,16 @@ +defmodule Safira.Repo.Migrations.CreateActivitiesSpeakers do + use Ecto.Migration + + def change do + create table(:activities_speakers, primary_key: false) do + add :activity_id, references(:activities, type: :binary_id, on_delete: :delete_all), + primary_key: true + + add :speaker_id, references(:speakers, type: :binary_id, on_delete: :delete_all), + primary_key: true + end + + create index(:activities_speakers, [:activity_id]) + create index(:activities_speakers, [:speaker_id]) + end +end diff --git a/test/support/fixtures/activities_fixtures.ex b/test/support/fixtures/activities_fixtures.ex index c7cebf8d..ab99e2dd 100644 --- a/test/support/fixtures/activities_fixtures.ex +++ b/test/support/fixtures/activities_fixtures.ex @@ -21,7 +21,7 @@ defmodule Safira.ActivitiesFixtures do }) |> Safira.Activities.create_activity() - activity + Map.put(activity, :speakers, []) end @doc """ From 6bce59dba97887275186f64bff466bc34ba8057b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lobo?= Date: Mon, 4 Nov 2024 00:59:40 +0000 Subject: [PATCH 05/15] feat: speaker seeds --- .../schedule_live/form_component.ex | 5 ++- .../live/backoffice/schedule_live/index.ex | 9 +---- mix.exs | 1 + mix.lock | 1 + priv/repo/seeds/activities.exs | 40 ++++++++++++++++++- 5 files changed, 47 insertions(+), 9 deletions(-) diff --git a/lib/safira_web/live/backoffice/schedule_live/form_component.ex b/lib/safira_web/live/backoffice/schedule_live/form_component.ex index 9342e074..0a86d0be 100644 --- a/lib/safira_web/live/backoffice/schedule_live/form_component.ex +++ b/lib/safira_web/live/backoffice/schedule_live/form_component.ex @@ -173,7 +173,10 @@ defmodule SafiraWeb.Backoffice.ScheduleLive.FormComponent do defp save_activity(socket, :new, activity_params) do case Activities.create_activity(activity_params) do {:ok, activity} -> - case Activities.upsert_activity_speakers(activity, activity_params["speakers"]) do + case Activities.upsert_activity_speakers( + Map.put(activity, :speakers, []), + activity_params["speakers"] + ) do {:ok, _activity} -> {:noreply, socket diff --git a/lib/safira_web/live/backoffice/schedule_live/index.ex b/lib/safira_web/live/backoffice/schedule_live/index.ex index f0c2040b..a2ca9d90 100644 --- a/lib/safira_web/live/backoffice/schedule_live/index.ex +++ b/lib/safira_web/live/backoffice/schedule_live/index.ex @@ -48,7 +48,7 @@ defmodule SafiraWeb.Backoffice.ScheduleLive.Index do defp apply_action(socket, :new, _params) do socket |> assign(:page_title, "New Activity") - |> assign(:activity, %Activity{}) + |> assign(:activity, %Activity{speakers: []}) |> assign(:categories, Activities.list_activity_categories()) end @@ -77,12 +77,7 @@ defmodule SafiraWeb.Backoffice.ScheduleLive.Index do defp apply_action(socket, :speakers_edit, %{"id" => id}) do socket |> assign(:page_title, "Edit Speaker") - |> assign( - :speaker, - Map.update!(Activities.get_speaker!(id), :speakers, fn speakers -> - speakers |> Enum.map(&{&1.name, &1.id}) - end) - ) + |> assign(:speaker, Activities.get_speaker!(id)) end defp apply_action(socket, :speakers_new, _params) do diff --git a/mix.exs b/mix.exs index 62431884..4a5c8a67 100644 --- a/mix.exs +++ b/mix.exs @@ -59,6 +59,7 @@ defmodule Safira.MixProject do {:qrcode_ex, "~> 0.1.1"}, {:cachex, "~> 3.6"}, {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, + {:faker, "~> 0.18.0"}, # frontend {:tailwind, "~> 0.2", runtime: Mix.env() == :dev}, diff --git a/mix.lock b/mix.lock index 17e7a7bc..9229f2c6 100644 --- a/mix.lock +++ b/mix.lock @@ -17,6 +17,7 @@ "esbuild": {:hex, :esbuild, "0.8.1", "0cbf919f0eccb136d2eeef0df49c4acf55336de864e63594adcea3814f3edf41", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "25fc876a67c13cb0a776e7b5d7974851556baeda2085296c14ab48555ea7560f"}, "eternal": {:hex, :eternal, "1.2.2", "d1641c86368de99375b98d183042dd6c2b234262b8d08dfd72b9eeaafc2a1abd", [:mix], [], "hexpm", "2c9fe32b9c3726703ba5e1d43a1d255a4f3f2d8f8f9bc19f094c7cb1a7a9e782"}, "expo": {:hex, :expo, "0.5.2", "beba786aab8e3c5431813d7a44b828e7b922bfa431d6bfbada0904535342efe2", [:mix], [], "hexpm", "8c9bfa06ca017c9cb4020fabe980bc7fdb1aaec059fd004c2ab3bff03b1c599c"}, + "faker": {:hex, :faker, "0.18.0", "943e479319a22ea4e8e39e8e076b81c02827d9302f3d32726c5bf82f430e6e14", [:mix], [], "hexpm", "bfbdd83958d78e2788e99ec9317c4816e651ad05e24cfd1196ce5db5b3e81797"}, "file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"}, "finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"}, "floki": {:hex, :floki, "0.36.2", "a7da0193538c93f937714a6704369711998a51a6164a222d710ebd54020aa7a3", [:mix], [], "hexpm", "a8766c0bc92f074e5cb36c4f9961982eda84c5d2b8e979ca67f5c268ec8ed580"}, diff --git a/priv/repo/seeds/activities.exs b/priv/repo/seeds/activities.exs index 560ecd6d..49d8de7e 100644 --- a/priv/repo/seeds/activities.exs +++ b/priv/repo/seeds/activities.exs @@ -2,7 +2,7 @@ defmodule Safira.Repo.Seeds.Activities do alias Safira.Repo alias Safira.Activities - alias Safira.Activities.ActivityCategory + alias Safira.Activities.{ActivityCategory, Speaker} def run do case Activities.list_activity_categories() do @@ -12,6 +12,13 @@ defmodule Safira.Repo.Seeds.Activities do Mix.shell().error("Found categories, aborting seeding categories.") end + case Activities.list_speakers() do + [] -> + seed_speakers() + _ -> + Mix.shell().error("Found speakers, aborting seeding speakers.") + end + case Activities.list_activities() do [] -> seed_activities() @@ -48,6 +55,37 @@ defmodule Safira.Repo.Seeds.Activities do end end + def seed_speakers do + for i <- 1..30 do + first_name = Faker.Person.first_name() + last_name = Faker.Person.last_name() + handle = "#{first_name}#{last_name}" |> String.downcase() + + speaker = %{ + name: "#{first_name} #{last_name}", + title: Faker.Person.title(), + company: Faker.Company.name(), + biography: Faker.Lorem.paragraph(3), + highlighted: i > 24, + socials: %{ + x: handle |> String.slice(0..14), + linkedin: handle, + github: handle, + website: Faker.Internet.url() + } + } + + changeset = Speaker.changeset(%Speaker{}, speaker) + + case Repo.insert(changeset) do + {:ok, _} -> :ok + {:error, changeset} -> + Mix.shell().error("Failed to insert speaker: #{speaker.name}") + Mix.shell().error(Kernel.inspect(changeset.errors)) + end + end + end + def seed_activities do # TODO: Add activity seeds end From 216f07de370ed8b371ac7472bce5d8af406ce709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lobo?= Date: Mon, 4 Nov 2024 01:42:06 +0000 Subject: [PATCH 06/15] feat: add activity seeds --- priv/repo/seeds/activities.exs | 55 ++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/priv/repo/seeds/activities.exs b/priv/repo/seeds/activities.exs index 49d8de7e..f6236b9b 100644 --- a/priv/repo/seeds/activities.exs +++ b/priv/repo/seeds/activities.exs @@ -2,7 +2,7 @@ defmodule Safira.Repo.Seeds.Activities do alias Safira.Repo alias Safira.Activities - alias Safira.Activities.{ActivityCategory, Speaker} + alias Safira.Activities.{Activity, ActivityCategory, Speaker} def run do case Activities.list_activity_categories() do @@ -87,7 +87,58 @@ defmodule Safira.Repo.Seeds.Activities do end def seed_activities do - # TODO: Add activity seeds + categories = (Activities.list_activity_categories() |> Enum.map(&(&1.id))) ++ [nil] + speakers = Activities.list_speakers() |> Enum.map(&(&1.id)) + + for day <- 0..3 do + for i <- 0..5 do + time_start = ~T[09:00:00] |> Time.add(i * 2, :hour) + time_end = time_start |> Time.add(1, :hour) + + activity = %{ + title: Faker.Company.bs() |> String.capitalize(), + location: "CP#{:rand.uniform(4)} - #{Enum.random(["A", "B"])}#{:rand.uniform(2)}", + date: next_first_tuesday_of_february() |> Date.shift(day: day), + time_start: time_start, + time_end: time_end, + description: Faker.Lorem.paragraph(3), + category_id: Enum.random(categories), + } + + changeset = Activities.change_activity(%Activity{}, activity) + + case Repo.insert(changeset) do + {:ok, activity} -> + speaker_ids = Enum.take_random(speakers, :rand.uniform(3)) + Activities.upsert_activity_speakers(Map.put(activity, :speakers, []), speaker_ids) + {:error, changeset} -> + Mix.shell().error("Failed to insert activity: #{activity.title}") + Mix.shell().error(Kernel.inspect(changeset.errors)) + end + end + end + end + + def next_first_tuesday_of_february do + today = Date.utc_today() + {year, _, _} = Date.to_erl(today) + + # Determine if we need to check this year or next year + target_year = + if today > Date.from_iso8601!("#{year}-02-01") do + year + 1 + else + year + end + + # Find the first day of February for the target year + february_first = Date.from_iso8601!("#{target_year}-02-01") + + # Calculate how many days to add to reach the first Tuesday + days_to_add = rem(9 - Date.day_of_week(february_first), 7) + + # Add the days to February 1st to get the first Tuesday + Date.add(february_first, days_to_add) end end From 85337c817bcd56885cb74eb93163026947912294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lobo?= Date: Mon, 4 Nov 2024 02:44:46 +0000 Subject: [PATCH 07/15] feat: event dates config & fix: no speakers bug --- lib/safira/activities.ex | 4 +- lib/safira/activities/activity.ex | 31 +++ lib/safira/event.ex | 74 ++++++ .../activity_live/form_component.ex | 211 ++++++++++++++++ .../schedule_live/form_component.ex | 229 +++++------------- .../live/backoffice/schedule_live/index.ex | 5 + .../backoffice/schedule_live/index.html.heex | 28 ++- lib/safira_web/router.ex | 2 + priv/repo/seeds/activities.exs | 9 + 9 files changed, 426 insertions(+), 167 deletions(-) create mode 100644 lib/safira/event.ex create mode 100644 lib/safira_web/live/backoffice/schedule_live/activity_live/form_component.ex diff --git a/lib/safira/activities.ex b/lib/safira/activities.ex index 8efb72da..4828f52a 100644 --- a/lib/safira/activities.ex +++ b/lib/safira/activities.ex @@ -111,9 +111,11 @@ defmodule Safira.Activities do """ def upsert_activity_speakers(%Activity{} = activity, speaker_ids) do + ids = speaker_ids || [] + speakers = Speaker - |> where([s], s.id in ^speaker_ids) + |> where([s], s.id in ^ids) |> Repo.all() activity diff --git a/lib/safira/activities/activity.ex b/lib/safira/activities/activity.ex index dec9a5f0..fbe01c60 100644 --- a/lib/safira/activities/activity.ex +++ b/lib/safira/activities/activity.ex @@ -4,6 +4,8 @@ defmodule Safira.Activities.Activity do """ use Safira.Schema + alias Safira.Event + @required_fields ~w(title date time_start time_end)a @optional_fields ~w(description category_id location has_enrolments max_enrolments)a @@ -47,6 +49,7 @@ defmodule Safira.Activities.Activity do activity |> cast(attrs, @required_fields ++ @optional_fields) |> validate_required(@required_fields) + |> validate_activity_date() end @doc false @@ -55,4 +58,32 @@ defmodule Safira.Activities.Activity do |> cast(%{}, @required_fields ++ @optional_fields) |> put_assoc(:speakers, speakers) end + + def validate_activity_date(activity) do + event_start = Event.get_event_start_date() + event_end = Event.get_event_end_date() + date = get_field(activity, :date) + + if date != nil do + if Date.compare(date, event_start) in [:lt] do + activity + |> Ecto.Changeset.add_error( + :date, + "must be after or in the event's start date (#{Date.to_string(event_start)})" + ) + else + if Date.compare(date, event_end) in [:gt] do + activity + |> Ecto.Changeset.add_error( + :date, + "must be before or in the event's end date (#{Date.to_string(event_end)})" + ) + else + activity + end + end + else + activity + end + end end diff --git a/lib/safira/event.ex b/lib/safira/event.ex new file mode 100644 index 00000000..18345c81 --- /dev/null +++ b/lib/safira/event.ex @@ -0,0 +1,74 @@ +defmodule Safira.Event do + @moduledoc """ + The event context. + """ + alias Safira.Constants + + @doc """ + Returns the event's start date. + If the date is not set, it will be set to today's date by default. + + ## Examples + + iex> get_event_start_date() + ~D[2025-02-11] + """ + def get_event_start_date do + case Constants.get("event_start_date") do + {:ok, date} -> + date |> Date.from_iso8601!() + + {:error, _} -> + # If the date is not set, set it to today's date by default + today = Timex.today() + change_event_start_date(today) + today + end + end + + @doc """ + Returns the event's end date. + If the date is not set, it will be set to today's date by default. + + ## Examples + + iex> get_event_end_date() + ~D[2025-02-14] + """ + def get_event_end_date do + case Constants.get("event_end_date") do + {:ok, date} -> + date |> Date.from_iso8601!() + + {:error, _} -> + # If the date is not set, set it to today's date by default + today = Timex.today() + change_event_end_date(today) + today + end + end + + @doc """ + Changes the event's start date. + + ## Examples + + iex> change_event_start_date(~D[2025-02-11]) + :ok + """ + def change_event_start_date(date) do + Constants.set("event_start_date", date) + end + + @doc """ + Changes the event's end date. + + ## Examples + + iex> change_event_end_date(~D[2025-02-14]) + :ok + """ + def change_event_end_date(date) do + Constants.set("event_end_date", date) + end +end diff --git a/lib/safira_web/live/backoffice/schedule_live/activity_live/form_component.ex b/lib/safira_web/live/backoffice/schedule_live/activity_live/form_component.ex new file mode 100644 index 00000000..b232b445 --- /dev/null +++ b/lib/safira_web/live/backoffice/schedule_live/activity_live/form_component.ex @@ -0,0 +1,211 @@ +defmodule SafiraWeb.Backoffice.ScheduleLive.ActivityLive.FormComponent do + use SafiraWeb, :live_component + + alias Safira.Activities + alias Safira.Activities.Speaker + import SafiraWeb.Components.Forms + + @impl true + def render(assigns) do + ~H""" +
+ <.page title={@title} subtitle={gettext("Activities that happen troughout the event.")}> + <.simple_form + for={@form} + id="activity-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save" + > +
+
+ <.field field={@form[:title]} type="text" label="Title" required wrapper_class="w-full" /> + <.field field={@form[:location]} type="text" label="Location" wrapper_class="w-full" /> +
+ <.field field={@form[:description]} type="textarea" label="Description" /> +
+ <.field + field={@form[:date]} + type="date" + label="Date" + required + wrapper_class="col-span-2" + /> + <.field + field={@form[:time_start]} + type="time" + label="Start" + required + wrapper_class="col-span-1" + /> + <.field + field={@form[:time_end]} + type="time" + label="End" + required + wrapper_class="col-span-1" + /> +
+
+ <.field + field={@form[:category_id]} + type="select" + label="Category" + options={categories_options(@categories)} + wrapper_class="w-full" + /> + <.field_multiselect + id="speakers" + field={@form[:speakers]} + target={@myself} + value_mapper={&value_mapper/1} + wrapper_class="w-full" + placeholder={gettext("Search for speakers")} + /> +
+
+
+
+ <.label> + <%= gettext("Enrolments") %> + +

+ <%= gettext( + "Enable enrolments to allow participants to sign up for this activity." + ) %> +

+ <.field + field={@form[:has_enrolments]} + type="switch" + label="" + wrapper_class="w-full pt-3" + /> +
+ <.field + :if={@enrolments_active} + field={@form[:max_enrolments]} + type="number" + label="Max enrolments" + wrapper_class="w-full mt-12" + /> +
+
+
+ <:actions> + <.button phx-disable-with="Saving...">Save Activity + + + +
+ """ + end + + @impl true + def mount(socket) do + {:ok, socket} + end + + @impl true + def update(%{activity: activity} = assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> assign(:enrolments_active, activity.has_enrolments) + |> assign_new(:form, fn -> + to_form(Activities.change_activity(activity)) + end)} + end + + @impl true + def handle_event("validate", %{"activity" => activity_params}, socket) do + activity = + if Map.has_key?(activity_params, "speakers") do + socket.assigns.activity + else + Map.put(socket.assigns.activity, :speakers, []) + end + + changeset = Activities.change_activity(activity, activity_params) + + {:noreply, + assign(socket, + form: to_form(changeset, action: :validate), + enrolments_active: activity_params["has_enrolments"] != "false" + )} + end + + def handle_event("save", %{"activity" => activity_params}, socket) do + save_activity(socket, socket.assigns.action, activity_params) + end + + @impl true + def handle_event("live_select_change", %{"text" => text, "id" => live_select_id}, socket) do + case Activities.list_speakers(%{ + "filters" => %{"1" => %{"field" => "name", "op" => "ilike_or", "value" => text}} + }) do + {:ok, {speakers, _meta}} -> + send_update(LiveSelect.Component, + id: live_select_id, + options: speakers |> Enum.map(&{&1.name, &1.id}) + ) + + {:noreply, socket} + + {:error, _} -> + {:noreply, socket} + end + end + + defp save_activity(socket, :edit, activity_params) do + case Activities.update_activity(socket.assigns.activity, activity_params) do + {:ok, _activity} -> + case Activities.upsert_activity_speakers( + socket.assigns.activity, + activity_params["speakers"] + ) do + {:ok, _activity} -> + {:noreply, + socket + |> put_flash(:info, "Activity updated successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp save_activity(socket, :new, activity_params) do + case Activities.create_activity(activity_params) do + {:ok, activity} -> + case Activities.upsert_activity_speakers( + Map.put(activity, :speakers, []), + activity_params["speakers"] + ) do + {:ok, _activity} -> + {:noreply, + socket + |> put_flash(:info, "Activity created successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp categories_options(categories) do + [{"None", nil}] ++ + Enum.map(categories, &{&1.name, &1.id}) + end + + defp value_mapper(%Speaker{} = speaker), do: {speaker.name, speaker.id} + + defp value_mapper(id), do: id +end diff --git a/lib/safira_web/live/backoffice/schedule_live/form_component.ex b/lib/safira_web/live/backoffice/schedule_live/form_component.ex index 0a86d0be..0af69aa6 100644 --- a/lib/safira_web/live/backoffice/schedule_live/form_component.ex +++ b/lib/safira_web/live/backoffice/schedule_live/form_component.ex @@ -1,203 +1,104 @@ defmodule SafiraWeb.Backoffice.ScheduleLive.FormComponent do + @moduledoc false use SafiraWeb, :live_component - alias Safira.Activities - alias Safira.Activities.Speaker import SafiraWeb.Components.Forms - @impl true + alias Ecto.Changeset + alias Safira.Event + def render(assigns) do ~H"""
- <.page title={@title} subtitle={gettext("Activities that happen troughout the event.")}> - <.simple_form - for={@form} - id="activity-form" - phx-target={@myself} - phx-change="validate" - phx-submit="save" - > -
-
- <.field field={@form[:title]} type="text" label="Title" required wrapper_class="w-full" /> - <.field field={@form[:location]} type="text" label="Location" wrapper_class="w-full" /> -
- <.field field={@form[:description]} type="textarea" label="Description" /> -
+ <.page title={@title} subtitle={gettext("Configures the event's dates.")}> +
+ <.form + id="event-config-form" + for={@form} + phx-submit="save" + phx-change="validate" + phx-target={@myself} + > +
<.field - field={@form[:date]} + field={@form[:event_start_date]} + name="event_start_date" + label="Start date" type="date" - label="Date" - required - wrapper_class="col-span-2" - /> - <.field - field={@form[:time_start]} - type="time" - label="Start" - required - wrapper_class="col-span-1" /> <.field - field={@form[:time_end]} - type="time" - label="End" - required - wrapper_class="col-span-1" - /> -
-
- <.field - field={@form[:category_id]} - type="select" - label="Category" - options={categories_options(@categories)} - wrapper_class="w-full" - /> - <.field_multiselect - field={@form[:speakers]} - target={@myself} - value_mapper={&value_mapper/1} - wrapper_class="w-full" - placeholder={gettext("Search for speakers")} + field={@form[:event_end_date]} + name="event_end_date" + label="End date" + type="date" />
-
-
-
- <.label> - <%= gettext("Enrolments") %> - -

- <%= gettext( - "Enable enrolments to allow participants to sign up for this activity." - ) %> -

- <.field - field={@form[:has_enrolments]} - type="switch" - label="" - wrapper_class="w-full pt-3" - /> -
- <.field - :if={@enrolments_active} - field={@form[:max_enrolments]} - type="number" - label="Max enrolments" - wrapper_class="w-full" - /> -
+
+ <.button phx-disable-with="Saving...">Save Configuration
-
- <:actions> - <.button phx-disable-with="Saving...">Save Activity - - + +
""" end - @impl true def mount(socket) do - {:ok, socket} - end - - @impl true - def update(%{activity: activity} = assigns, socket) do {:ok, socket - |> assign(assigns) - |> assign(:enrolments_active, activity.has_enrolments) - |> assign_new(:form, fn -> - to_form(Activities.change_activity(activity)) - end)} + |> assign( + form: + to_form( + %{ + "event_start_date" => Event.get_event_start_date(), + "event_end_date" => Event.get_event_end_date() + }, + as: :wheel_configuration + ) + )} end - @impl true - def handle_event("validate", %{"activity" => activity_params}, socket) do - changeset = Activities.change_activity(socket.assigns.activity, activity_params) + def handle_event("validate", params, socket) do + changeset = validate_configuration(params["event_start_date"], params["event_end_date"]) {:noreply, - assign(socket, - form: to_form(changeset, action: :validate), - enrolments_active: activity_params["has_enrolments"] != "false" - )} + assign(socket, form: to_form(changeset, action: :validate, as: :event_configuration))} end - def handle_event("save", %{"activity" => activity_params}, socket) do - save_activity(socket, socket.assigns.action, activity_params) - end - - @impl true - def handle_event("live_select_change", %{"text" => text, "id" => live_select_id}, socket) do - case Activities.list_speakers(%{ - "filters" => %{"1" => %{"field" => "name", "op" => "ilike_or", "value" => text}} - }) do - {:ok, {speakers, _meta}} -> - send_update(LiveSelect.Component, - id: live_select_id, - options: speakers |> Enum.map(&{&1.name, &1.id}) - ) - - {:noreply, socket} - - {:error, _} -> - {:noreply, socket} + def handle_event("save", params, socket) do + if valid_config?(params) do + Event.change_event_start_date(params["event_start_date"] |> Date.from_iso8601!()) + Event.change_event_end_date(params["event_end_date"] |> Date.from_iso8601!()) + {:noreply, socket |> push_patch(to: ~p"/dashboard/schedule/activities/")} + else + {:noreply, socket} end end - defp save_activity(socket, :edit, activity_params) do - case Activities.update_activity(socket.assigns.activity, activity_params) do - {:ok, _activity} -> - case Activities.upsert_activity_speakers( - socket.assigns.activity, - activity_params["speakers"] - ) do - {:ok, _activity} -> - {:noreply, - socket - |> put_flash(:info, "Activity updated successfully") - |> push_patch(to: socket.assigns.patch)} - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, form: to_form(changeset))} - end - - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, form: to_form(changeset))} - end + defp validate_configuration(event_start_date, event_end_date) do + {%{}, %{event_start_date: :date, event_end_date: :date}} + |> Changeset.cast(%{event_start_date: event_start_date, event_end_date: event_end_date}, [ + :event_start_date, + :event_end_date + ]) + |> Changeset.validate_required([:event_start_date]) + |> Changeset.validate_required([:event_end_date]) + |> validate_date_is_after() end - defp save_activity(socket, :new, activity_params) do - case Activities.create_activity(activity_params) do - {:ok, activity} -> - case Activities.upsert_activity_speakers( - Map.put(activity, :speakers, []), - activity_params["speakers"] - ) do - {:ok, _activity} -> - {:noreply, - socket - |> put_flash(:info, "Activity created successfully") - |> push_patch(to: socket.assigns.patch)} + defp valid_config?(params) do + validation = validate_configuration(params["event_start_date"], params["event_end_date"]) + validation.errors == [] + end - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, form: to_form(changeset))} - end + defp validate_date_is_after(changeset) do + start_date = Changeset.get_field(changeset, :event_start_date) + end_date = Changeset.get_field(changeset, :event_end_date) - {:error, %Ecto.Changeset{} = changeset} -> - {:noreply, assign(socket, form: to_form(changeset))} + if Date.compare(start_date, end_date) == :gt do + Changeset.add_error(changeset, :event_start_date, "cannot be later than the end date") + else + changeset end end - - defp categories_options(categories) do - [{"None", nil}] ++ - Enum.map(categories, &{&1.name, &1.id}) - end - - defp value_mapper(%Speaker{} = speaker), do: {speaker.name, speaker.id} - - defp value_mapper(id), do: id end diff --git a/lib/safira_web/live/backoffice/schedule_live/index.ex b/lib/safira_web/live/backoffice/schedule_live/index.ex index a2ca9d90..c6ced199 100644 --- a/lib/safira_web/live/backoffice/schedule_live/index.ex +++ b/lib/safira_web/live/backoffice/schedule_live/index.ex @@ -86,6 +86,11 @@ defmodule SafiraWeb.Backoffice.ScheduleLive.Index do |> assign(:speaker, %Speaker{}) end + defp apply_action(socket, :edit_schedule, _params) do + socket + |> assign(:page_title, "Event Calendar Configuration") + end + defp apply_action(socket, :speakers, params) do case Activities.list_speakers(params) do {:ok, {speakers, meta}} -> diff --git a/lib/safira_web/live/backoffice/schedule_live/index.html.heex b/lib/safira_web/live/backoffice/schedule_live/index.html.heex index cdd02e7c..8670da7b 100644 --- a/lib/safira_web/live/backoffice/schedule_live/index.html.heex +++ b/lib/safira_web/live/backoffice/schedule_live/index.html.heex @@ -14,6 +14,14 @@ + <.ensure_permissions user={@current_user} permissions={%{"schedule" => ["edit"]}}> + <.link patch={~p"/dashboard/schedule/edit"}> + <.button> + <.icon name="hero-calendar-days" class="w-5 h-5" /> + + + + <.ensure_permissions user={@current_user} permissions={%{"schedule" => ["edit"]}}> <.link patch={~p"/dashboard/schedule/activities/speakers"}> <.button> @@ -71,12 +79,12 @@ <.modal :if={@live_action in [:edit, :new]} - id="categories-modal" + id="activites-modal" show on_cancel={JS.navigate(~p"/dashboard/schedule/activities")} > <.live_component - module={SafiraWeb.Backoffice.ScheduleLive.FormComponent} + module={SafiraWeb.Backoffice.ScheduleLive.ActivityLive.FormComponent} id={@activity.id || :new} title={@page_title} current_user={@current_user} @@ -155,3 +163,19 @@ patch={~p"/dashboard/schedule/activities/categories"} /> + +<.modal + :if={@live_action in [:edit_schedule]} + id="schedule-config-modal" + show + on_cancel={JS.patch(~p"/dashboard/schedule/activities")} +> + <.live_component + module={SafiraWeb.Backoffice.ScheduleLive.FormComponent} + id="schedule-config" + title={@page_title} + current_user={@current_user} + action={@live_action} + patch={~p"/dashboard/schedule/activities"} + /> + diff --git a/lib/safira_web/router.ex b/lib/safira_web/router.ex index 008ad000..3da0b0de 100644 --- a/lib/safira_web/router.ex +++ b/lib/safira_web/router.ex @@ -125,6 +125,8 @@ defmodule SafiraWeb.Router do end scope "/schedule", ScheduleLive do + live "/edit", Index, :edit_schedule + scope "/activities" do live "/", Index, :index live "/new", Index, :new diff --git a/priv/repo/seeds/activities.exs b/priv/repo/seeds/activities.exs index f6236b9b..6cacdb69 100644 --- a/priv/repo/seeds/activities.exs +++ b/priv/repo/seeds/activities.exs @@ -3,8 +3,11 @@ defmodule Safira.Repo.Seeds.Activities do alias Safira.Activities alias Safira.Activities.{Activity, ActivityCategory, Speaker} + alias Safira.Event def run do + seed_event_schedule_config() + case Activities.list_activity_categories() do [] -> seed_categories() @@ -27,6 +30,12 @@ defmodule Safira.Repo.Seeds.Activities do end end + def seed_event_schedule_config do + event_start_date = next_first_tuesday_of_february() + Event.change_event_start_date(event_start_date) + Event.change_event_end_date(Date.add(event_start_date, 3)) + end + def seed_categories do categories = [ %{ From faa9c896d06c0b0866b5576e7770ded7a0d4cae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lobo?= Date: Mon, 4 Nov 2024 02:54:13 +0000 Subject: [PATCH 08/15] fix: tests --- lib/safira/event.ex | 8 ++++++-- test/safira/activities_test.exs | 4 ++++ test/support/fixtures/activities_fixtures.ex | 4 ++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/safira/event.ex b/lib/safira/event.ex index 18345c81..40ccb12a 100644 --- a/lib/safira/event.ex +++ b/lib/safira/event.ex @@ -16,7 +16,7 @@ defmodule Safira.Event do def get_event_start_date do case Constants.get("event_start_date") do {:ok, date} -> - date |> Date.from_iso8601!() + ensure_date(date) {:error, _} -> # If the date is not set, set it to today's date by default @@ -38,7 +38,7 @@ defmodule Safira.Event do def get_event_end_date do case Constants.get("event_end_date") do {:ok, date} -> - date |> Date.from_iso8601!() + ensure_date(date) {:error, _} -> # If the date is not set, set it to today's date by default @@ -71,4 +71,8 @@ defmodule Safira.Event do def change_event_end_date(date) do Constants.set("event_end_date", date) end + + defp ensure_date(string) when is_binary(string), do: Date.from_iso8601(string) + + defp ensure_date(date), do: date end diff --git a/test/safira/activities_test.exs b/test/safira/activities_test.exs index d166e49d..338a570c 100644 --- a/test/safira/activities_test.exs +++ b/test/safira/activities_test.exs @@ -2,6 +2,7 @@ defmodule Safira.ActivitiesTest do use Safira.DataCase alias Safira.Activities + alias Safira.Event describe "activities" do alias Safira.Activities.Activity @@ -56,6 +57,9 @@ defmodule Safira.ActivitiesTest do test "update_activity/2 with valid data updates the activity" do activity = activity_fixture() + Event.change_event_start_date(~D[2024-10-28]) + Event.change_event_end_date(~D[2024-10-28]) + update_attrs = %{ date: ~D[2024-10-28], description: "some updated description", diff --git a/test/support/fixtures/activities_fixtures.ex b/test/support/fixtures/activities_fixtures.ex index ab99e2dd..78ea3c86 100644 --- a/test/support/fixtures/activities_fixtures.ex +++ b/test/support/fixtures/activities_fixtures.ex @@ -3,11 +3,15 @@ defmodule Safira.ActivitiesFixtures do This module defines test helpers for creating entities via the `Safira.Activities` context. """ + alias Safira.Event @doc """ Generate a activity. """ def activity_fixture(attrs \\ %{}) do + Event.change_event_start_date(~D[2024-10-27]) + Event.change_event_end_date(~D[2024-10-27]) + {:ok, activity} = attrs |> Enum.into(%{ From 9b4a4949ef22047640f33d1b08947588e61e26ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lobo?= Date: Mon, 4 Nov 2024 02:57:21 +0000 Subject: [PATCH 09/15] fix: tests --- test/safira/activities_test.exs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/safira/activities_test.exs b/test/safira/activities_test.exs index 338a570c..4b0997b3 100644 --- a/test/safira/activities_test.exs +++ b/test/safira/activities_test.exs @@ -30,6 +30,9 @@ defmodule Safira.ActivitiesTest do end test "create_activity/1 with valid data creates a activity" do + Event.change_event_start_date(~D[2024-10-27]) + Event.change_event_end_date(~D[2024-10-27]) + valid_attrs = %{ date: ~D[2024-10-27], description: "some description", From a04898dd2bbf91907e12ab6e674db63fabb01c31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lobo?= Date: Mon, 4 Nov 2024 03:00:11 +0000 Subject: [PATCH 10/15] fix: date bug --- lib/safira/event.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/safira/event.ex b/lib/safira/event.ex index 40ccb12a..fb1bad0a 100644 --- a/lib/safira/event.ex +++ b/lib/safira/event.ex @@ -72,7 +72,7 @@ defmodule Safira.Event do Constants.set("event_end_date", date) end - defp ensure_date(string) when is_binary(string), do: Date.from_iso8601(string) + defp ensure_date(string) when is_binary(string), do: Date.from_iso8601!(string) defp ensure_date(date), do: date end From 26e733859c773136c4bffe90d1776c98378c455b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lobo?= Date: Mon, 4 Nov 2024 21:04:31 +0000 Subject: [PATCH 11/15] fix: initial --- lib/safira/accounts.ex | 57 +++++++++++++++++++ lib/safira_web/helpers.ex | 2 +- .../live/app/credential_live/edit.ex | 48 ++++++++++++++++ .../credential_live/edit.html.heex} | 8 +-- .../live/{ => app}/credential_live/show.ex | 0 .../{ => app}/credential_live/show.html.heex | 0 .../live/backoffice/scanner_live/index.ex | 33 ----------- lib/safira_web/live/credential_live/edit.ex | 37 ------------ .../live/credential_live/edit.html.heex | 17 ------ lib/safira_web/live/credential_live/index.ex | 16 ------ lib/safira_web/plugs/user_roles.ex | 30 ++++++++-- lib/safira_web/router.ex | 9 ++- 12 files changed, 143 insertions(+), 114 deletions(-) create mode 100644 lib/safira_web/live/app/credential_live/edit.ex rename lib/safira_web/live/{backoffice/scanner_live/index.html.heex => app/credential_live/edit.html.heex} (81%) rename lib/safira_web/live/{ => app}/credential_live/show.ex (100%) rename lib/safira_web/live/{ => app}/credential_live/show.html.heex (100%) delete mode 100644 lib/safira_web/live/backoffice/scanner_live/index.ex delete mode 100644 lib/safira_web/live/credential_live/edit.ex delete mode 100644 lib/safira_web/live/credential_live/edit.html.heex delete mode 100644 lib/safira_web/live/credential_live/index.ex diff --git a/lib/safira/accounts.ex b/lib/safira/accounts.ex index a2ec343d..82fc7725 100644 --- a/lib/safira/accounts.ex +++ b/lib/safira/accounts.ex @@ -585,6 +585,27 @@ defmodule Safira.Accounts do |> Repo.update() end + @doc """ + Links a credential to an attendee. + + ## Examples + + iex> link_credential(credential_id, attendee_id) + {:ok, %Credential{}} + + iex> link_credential(credential_id, attendee_id) + {:error, %Ecto.Changeset{}} + + """ + def link_credential(credential_id, attendee_id) do + credential = get_credential!(credential_id) + attendee = get_attendee!(attendee_id) + + credential + |> Credential.changeset(%{attendee_id: attendee.id}) + |> Repo.update() + end + @doc """ Deletes a credential. @@ -614,6 +635,42 @@ defmodule Safira.Accounts do Credential.changeset(credential, attrs) end + @doc """ + Checks if a credential exists. + + ## Examples + + iex> credential_exists?(123) + true + + iex> credential_exists?(456) + false + + """ + def credential_exists?(id) do + Credential + |> where([c], c.id == ^id) + |> Repo.exists?() + end + + @doc """ + Checks if a credential is linked to an attendee. + + ## Examples + + iex> credential_linked?(credential_id) + true + + iex> credential_linked?(credential_id) + false + + """ + def credential_linked?(credential_id) do + credential = get_credential!(credential_id) + + credential.attendee_id != nil + end + @doc """ Gets a single credential associated to the given attendee. diff --git a/lib/safira_web/helpers.ex b/lib/safira_web/helpers.ex index 9aaa1937..1d48692d 100644 --- a/lib/safira_web/helpers.ex +++ b/lib/safira_web/helpers.ex @@ -18,7 +18,7 @@ defmodule SafiraWeb.Helpers do case URI.parse(url) do %URI{host: host, path: path} -> - if host == app_host or Mix.env() == :dev do + if (host == app_host or Mix.env() == :dev) and not is_nil(path) do case extract_id_from_url_path(path) do :error -> {:error, "not a valid id"} result -> result diff --git a/lib/safira_web/live/app/credential_live/edit.ex b/lib/safira_web/live/app/credential_live/edit.ex new file mode 100644 index 00000000..2b6c526d --- /dev/null +++ b/lib/safira_web/live/app/credential_live/edit.ex @@ -0,0 +1,48 @@ +defmodule SafiraWeb.App.CredentialLive.Edit do + use SafiraWeb, :app_view + + alias Safira.Accounts + + @impl true + def mount(_params, _session, socket) do + {:ok, socket} + end + + @impl true + def handle_params(_params, _url, socket) do + {:noreply, socket |> assign(:modal_data, nil)} + end + + @impl true + def handle_event("scan", data, socket) do + case safely_extract_id_from_url(data) do + {:ok, id} -> + if Accounts.credential_exists?(id) do + if Accounts.credential_linked?(id) do + {:noreply, socket |> assign(:modal_data, :already_linked)} + else + Accounts.link_credential(id, socket.assigns.current_user.attendee.id) + {:noreply, socket |> push_navigate(to: ~p"/app")} + end + else + {:noreply, socket |> assign(:modal_data, :not_found)} + end + + {:error, _} -> + {:noreply, socket |> assign(:modal_data, :invalid)} + end + end + + @impl true + def handle_event("close-modal", _, socket) do + {:noreply, socket |> assign(:modal_data, nil)} + end + + def error_message(:not_found), + do: gettext("This credential is not registered in the event's system! (404)") + + def error_message(:already_linked), + do: gettext("This credential is already linked to another attendee! (400)") + + def error_message(:invalid), do: gettext("Not a valid credential! (400)") +end diff --git a/lib/safira_web/live/backoffice/scanner_live/index.html.heex b/lib/safira_web/live/app/credential_live/edit.html.heex similarity index 81% rename from lib/safira_web/live/backoffice/scanner_live/index.html.heex rename to lib/safira_web/live/app/credential_live/edit.html.heex index 4d13fe05..ad77d069 100644 --- a/lib/safira_web/live/backoffice/scanner_live/index.html.heex +++ b/lib/safira_web/live/app/credential_live/edit.html.heex @@ -1,4 +1,4 @@ -<.page title="Identify an attendee"> +<.page title="Link Credential">
<.icon name="hero-x-circle" class="text-red-500 w-8" />

- <%= if @modal_data == :not_found do %> - <%= gettext("This credential is not associated with an attendee!") %> - <% else %> - <%= gettext("Not a valid credential!") %> + <%= if @modal_data do %> + <%= error_message(@modal_data) %> <% end %>

diff --git a/lib/safira_web/live/credential_live/show.ex b/lib/safira_web/live/app/credential_live/show.ex similarity index 100% rename from lib/safira_web/live/credential_live/show.ex rename to lib/safira_web/live/app/credential_live/show.ex diff --git a/lib/safira_web/live/credential_live/show.html.heex b/lib/safira_web/live/app/credential_live/show.html.heex similarity index 100% rename from lib/safira_web/live/credential_live/show.html.heex rename to lib/safira_web/live/app/credential_live/show.html.heex diff --git a/lib/safira_web/live/backoffice/scanner_live/index.ex b/lib/safira_web/live/backoffice/scanner_live/index.ex deleted file mode 100644 index 37d7b69d..00000000 --- a/lib/safira_web/live/backoffice/scanner_live/index.ex +++ /dev/null @@ -1,33 +0,0 @@ -defmodule SafiraWeb.ScannerLive.Index do - use SafiraWeb, :backoffice_view - - @impl true - def mount(_params, _session, socket) do - {:ok, socket} - end - - @impl true - def handle_params(_params, _url, socket) do - {:noreply, socket |> assign(:modal_data, nil)} - end - - @impl true - def handle_event("scan", data, socket) do - case safely_extract_id_from_url(data) do - {:ok, id} -> - if Safira.Accounts.attendee_exists?(id) do - {:noreply, socket |> push_navigate(to: ~p"/dashboard/attendees/#{id}")} - else - {:noreply, socket |> assign(:modal_data, :not_found)} - end - - {:error, _} -> - {:noreply, socket |> assign(:modal_data, :invalid)} - end - end - - @impl true - def handle_event("close-modal", _, socket) do - {:noreply, socket |> assign(:modal_data, nil)} - end -end diff --git a/lib/safira_web/live/credential_live/edit.ex b/lib/safira_web/live/credential_live/edit.ex deleted file mode 100644 index b69e200d..00000000 --- a/lib/safira_web/live/credential_live/edit.ex +++ /dev/null @@ -1,37 +0,0 @@ -defmodule SafiraWeb.App.CredentialLive.Edit do - use SafiraWeb, :app_view - - alias Safira.Accounts - - @impl true - def mount(_params, _session, socket) do - {:ok, socket} - end - - @impl true - def handle_params(%{"id" => id}, _, socket) do - credential = Accounts.get_credential!(id, [:attendee]) - - if is_nil(credential.attendee) do - {:noreply, socket |> assign(credential: credential)} - else - {:noreply, socket |> push_navigate(to: ~p"/app/credential/#{credential.id}")} - end - end - - @impl true - def handle_event("claim", _, socket) do - socket.assigns.credential - |> Accounts.update_credential(%{attendee_id: socket.assigns.current_user.attendee.id}) - |> case do - {:ok, _credential} -> - {:noreply, - socket - |> put_flash(:info, "Credential claimed successfully") - |> push_navigate(to: ~p"/app/")} - - {:error, _changeset} -> - {:noreply, socket |> put_flash(:error, "Unable to claim credential. Try again later")} - end - end -end diff --git a/lib/safira_web/live/credential_live/edit.html.heex b/lib/safira_web/live/credential_live/edit.html.heex deleted file mode 100644 index 4d01bb8d..00000000 --- a/lib/safira_web/live/credential_live/edit.html.heex +++ /dev/null @@ -1,17 +0,0 @@ -<.page title="Claim Credential" size={:xl} title_class="font-terminal uppercase"> -
- <%= draw_qr_code(@credential) |> raw %> -
- -

- You are about to claim the scanned credential. This action cannot be undone. You can have multiple credentials claimed to your account. -

- - <.action_button - title="Claim Credential" - subtitle="" - class="w-full mt-8 max-w-96 block mx-auto w-fit" - disabled={false} - phx-click="claim" - /> - diff --git a/lib/safira_web/live/credential_live/index.ex b/lib/safira_web/live/credential_live/index.ex deleted file mode 100644 index 50818da4..00000000 --- a/lib/safira_web/live/credential_live/index.ex +++ /dev/null @@ -1,16 +0,0 @@ -defmodule SafiraWeb.App.CredentialLive.Index do - use SafiraWeb, :app_view - - alias Safira.Accounts - - @impl true - def mount(_params, _session, socket) do - {:ok, socket} - end - - @impl true - def handle_params(_, _, socket) do - credential = Accounts.get_credential_of_attendee!(socket.assigns.current_user.attendee) - {:noreply, socket |> push_navigate(to: ~p"/app/credential/#{credential.id}")} - end -end diff --git a/lib/safira_web/plugs/user_roles.ex b/lib/safira_web/plugs/user_roles.ex index ab304c25..7be49c03 100644 --- a/lib/safira_web/plugs/user_roles.ex +++ b/lib/safira_web/plugs/user_roles.ex @@ -4,26 +4,48 @@ defmodule SafiraWeb.UserRoles do """ use SafiraWeb, :verified_routes + import SafiraWeb.Gettext + import Plug.Conn import Phoenix.Controller alias Safira.Accounts def require_credential(conn, _opts) do - if is_nil(conn.assigns.current_user.attendee) or - not is_nil(Accounts.get_credential_of_attendee(conn.assigns.current_user.attendee)) do + if has_credential?(conn) do conn else conn |> put_flash( :error, - "You haven't assigned a credential to your account. You need one to participate in SEI" + gettext( + "You haven't assigned a credential to your account. You need one to participate in the event." + ) ) - |> redirect(to: ~p"/scanner") + |> redirect(to: ~p"/app/credential/link") |> halt() end end + def require_no_credential(conn, _opts) do + if not has_credential?(conn) do + conn + else + conn + |> put_flash( + :error, + gettext("You already have a credential assigned to your account.") + ) + |> redirect(to: ~p"/app") + |> halt() + end + end + + defp has_credential?(conn) do + is_nil(conn.assigns.current_user.attendee) or + not is_nil(Accounts.get_credential_of_attendee(conn.assigns.current_user.attendee)) + end + @doc """ Used for routes that require the user to be an attendee. """ diff --git a/lib/safira_web/router.ex b/lib/safira_web/router.ex index 3da0b0de..636ee3ef 100644 --- a/lib/safira_web/router.ex +++ b/lib/safira_web/router.ex @@ -73,7 +73,14 @@ defmodule SafiraWeb.Router do live "/scanner", ScannerLive.Index, :index scope "/app", App do - pipe_through [:require_attendee_user, :require_credential] + pipe_through [:require_attendee_user] + + scope "/credential", CredentialLive do + pipe_through [:require_no_credential] + live "/link", Edit, :edit + end + + pipe_through [:require_credential] live "/", HomeLive.Index, :index live "/wheel", WheelLive.Index, :index From f509bc83d000af73694ff5bdf2671fc14493c954 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lobo?= Date: Tue, 5 Nov 2024 02:24:45 +0000 Subject: [PATCH 12/15] fix: credential pages --- lib/safira/activities/activity.ex | 2 +- .../live/app/credential_live/edit.html.heex | 2 +- .../live/app/credential_live/index.ex | 15 +++++++ .../{show.html.heex => index.html.heex} | 2 +- .../live/app/credential_live/show.ex | 44 ------------------- lib/safira_web/router.ex | 8 +--- priv/repo/seeds/accounts.exs | 9 ++-- 7 files changed, 26 insertions(+), 56 deletions(-) create mode 100644 lib/safira_web/live/app/credential_live/index.ex rename lib/safira_web/live/app/credential_live/{show.html.heex => index.html.heex} (70%) delete mode 100644 lib/safira_web/live/app/credential_live/show.ex diff --git a/lib/safira/activities/activity.ex b/lib/safira/activities/activity.ex index b1fef78c..13d704f4 100644 --- a/lib/safira/activities/activity.ex +++ b/lib/safira/activities/activity.ex @@ -108,4 +108,4 @@ defmodule Safira.Activities.Activity do activity end end -end \ No newline at end of file +end diff --git a/lib/safira_web/live/app/credential_live/edit.html.heex b/lib/safira_web/live/app/credential_live/edit.html.heex index ad77d069..69f052e5 100644 --- a/lib/safira_web/live/app/credential_live/edit.html.heex +++ b/lib/safira_web/live/app/credential_live/edit.html.heex @@ -1,4 +1,4 @@ -<.page title="Link Credential"> +<.page title="Link Credential" size={:xl} title_class="font-terminal uppercase">
assign( + :credential, + Accounts.get_credential_of_attendee!(socket.assigns.current_user.attendee) + )} + end +end diff --git a/lib/safira_web/live/app/credential_live/show.html.heex b/lib/safira_web/live/app/credential_live/index.html.heex similarity index 70% rename from lib/safira_web/live/app/credential_live/show.html.heex rename to lib/safira_web/live/app/credential_live/index.html.heex index 807f6f9c..c89a3ea0 100644 --- a/lib/safira_web/live/app/credential_live/show.html.heex +++ b/lib/safira_web/live/app/credential_live/index.html.heex @@ -1,5 +1,5 @@ <.page title="Credential" size={:xl} title_class="font-terminal uppercase"> -
+
<%= draw_qr_code(@credential) |> raw %>
diff --git a/lib/safira_web/live/app/credential_live/show.ex b/lib/safira_web/live/app/credential_live/show.ex deleted file mode 100644 index b6defdf7..00000000 --- a/lib/safira_web/live/app/credential_live/show.ex +++ /dev/null @@ -1,44 +0,0 @@ -defmodule SafiraWeb.App.CredentialLive.Show do - use SafiraWeb, :app_view - - alias Safira.Accounts - - @impl true - def mount(_params, _session, socket) do - {:ok, socket} - end - - @impl true - def handle_params(%{"id" => id}, _, socket) do - credential = Accounts.get_credential!(id, [:attendee]) - - # QR Code not yet assigned - if is_nil(credential.attendee) do - # User is not an attendee, show nothing - # User is an attendee, redirect to assigning page - if is_nil(socket.assigns.current_user.attendee.id) do - {:noreply, - socket - |> push_navigate(to: "/404", replace: true)} - else - {:noreply, - socket - |> push_navigate(to: ~p"/app/credential/#{credential.id}/edit", replace: true)} - end - else - # Current user is the attendee which the QR Code belongs to, display - # the QR code - # Redirect to the user profile - if credential.attendee_id == socket.assigns.current_user.attendee.id do - {:noreply, - socket - |> assign(:page_title, "Show QR Code") - |> assign(:credential, credential)} - else - {:noreply, - socket - |> push_navigate(to: "/attendees/#{credential.attendee_id}", replace: true)} - end - end - end -end diff --git a/lib/safira_web/router.ex b/lib/safira_web/router.ex index 636ee3ef..558e94d2 100644 --- a/lib/safira_web/router.ex +++ b/lib/safira_web/router.ex @@ -83,6 +83,8 @@ defmodule SafiraWeb.Router do pipe_through [:require_credential] live "/", HomeLive.Index, :index + live "/credential", CredentialLive.Index, :index + live "/wheel", WheelLive.Index, :index scope "/store", StoreLive do @@ -90,12 +92,6 @@ defmodule SafiraWeb.Router do live "/product/:id", Show, :show end - scope "/credential", CredentialLive do - live "/", Index, :index - live "/:id", Show, :show - live "/:id/edit", Edit, :edit - end - live "/vault", VaultLive.Index, :index end diff --git a/priv/repo/seeds/accounts.exs b/priv/repo/seeds/accounts.exs index 7f6a653f..fbcfc524 100644 --- a/priv/repo/seeds/accounts.exs +++ b/priv/repo/seeds/accounts.exs @@ -26,7 +26,7 @@ defmodule Safira.Repo.Seeds.Accounts do case Accounts.list_credentials() do [] -> - seed_credentials(credential_count) + seed_credentials(credential_count, div(attendee_names |> length(), 2)) _ -> Mix.shell().erroring("Found credentials, aborting seeding credentials.") end @@ -78,9 +78,12 @@ defmodule Safira.Repo.Seeds.Accounts do end end - def seed_credentials(credential_count) do + def seed_credentials(credential_count, attendee_to_link_count) do + attendees = Accounts.list_attendees() + for i <- 0..credential_count do - Accounts.create_credential(%{attendee_id: nil}) + id = if i < attendee_to_link_count do Enum.at(attendees, i).attendee.id else nil end + Accounts.create_credential(%{attendee_id: id}) end end end From 75b5787c36b80270b76db0ed51df38f55d74fa75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lobo?= Date: Tue, 5 Nov 2024 02:26:44 +0000 Subject: [PATCH 13/15] fix: add current page --- lib/safira_web/live/app/credential_live/index.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/safira_web/live/app/credential_live/index.ex b/lib/safira_web/live/app/credential_live/index.ex index b211c4f5..afb6fa46 100644 --- a/lib/safira_web/live/app/credential_live/index.ex +++ b/lib/safira_web/live/app/credential_live/index.ex @@ -7,6 +7,7 @@ defmodule SafiraWeb.App.CredentialLive.Index do def mount(_params, _session, socket) do {:ok, socket + |> assign(:current_page, :credential) |> assign( :credential, Accounts.get_credential_of_attendee!(socket.assigns.current_user.attendee) From 73152e1880311eb0b94f03123a8850f639d038de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lobo?= Date: Tue, 5 Nov 2024 02:29:56 +0000 Subject: [PATCH 14/15] ci: credo --- lib/safira_web/plugs/user_roles.ex | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/safira_web/plugs/user_roles.ex b/lib/safira_web/plugs/user_roles.ex index 7be49c03..09d4f2da 100644 --- a/lib/safira_web/plugs/user_roles.ex +++ b/lib/safira_web/plugs/user_roles.ex @@ -28,9 +28,7 @@ defmodule SafiraWeb.UserRoles do end def require_no_credential(conn, _opts) do - if not has_credential?(conn) do - conn - else + if has_credential?(conn) do conn |> put_flash( :error, @@ -38,6 +36,8 @@ defmodule SafiraWeb.UserRoles do ) |> redirect(to: ~p"/app") |> halt() + else + conn end end From 0cfbba0b7075076fca52a8d429f585d80a0ac0c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lobo?= Date: Wed, 6 Nov 2024 00:15:43 +0000 Subject: [PATCH 15/15] feat: improve credential page --- lib/safira_web/components/layouts/app.html.heex | 2 +- lib/safira_web/helpers.ex | 2 +- .../live/app/credential_live/index.html.heex | 10 +++++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/safira_web/components/layouts/app.html.heex b/lib/safira_web/components/layouts/app.html.heex index 38cd02e8..5a213407 100644 --- a/lib/safira_web/components/layouts/app.html.heex +++ b/lib/safira_web/components/layouts/app.html.heex @@ -26,7 +26,7 @@
-
+
<.flash_group flash={@flash} /> <%= @inner_content %>
diff --git a/lib/safira_web/helpers.ex b/lib/safira_web/helpers.ex index 1d48692d..7971c6bd 100644 --- a/lib/safira_web/helpers.ex +++ b/lib/safira_web/helpers.ex @@ -154,7 +154,7 @@ defmodule SafiraWeb.Helpers do end def draw_qr_code(qr_code) do - internal_route = "/qr_codes/#{qr_code.id}" + internal_route = "/app/attendees/#{qr_code.id}" url = build_url() <> internal_route url diff --git a/lib/safira_web/live/app/credential_live/index.html.heex b/lib/safira_web/live/app/credential_live/index.html.heex index c89a3ea0..bef6e498 100644 --- a/lib/safira_web/live/app/credential_live/index.html.heex +++ b/lib/safira_web/live/app/credential_live/index.html.heex @@ -1,5 +1,13 @@ <.page title="Credential" size={:xl} title_class="font-terminal uppercase"> -
+

+ <%= gettext( + "The code below is used to identify you in case you are missing your physical credential." + ) %> +

+
<%= draw_qr_code(@credential) |> raw %> +

+ <%= @current_user.name %> +