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