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