diff --git a/assets/css/components/field.css b/assets/css/components/field.css index 6a2f958f..2b49bb09 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 */ @@ -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/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..4828f52a --- /dev/null +++ b/lib/safira/activities.ex @@ -0,0 +1,377 @@ +defmodule Safira.Activities do + @moduledoc """ + The Activities context. + """ + + use Safira.Context + + alias Safira.Activities.{Activity, ActivityCategory, Speaker} + + @doc """ + Returns the list of activities. + + ## Examples + + iex> list_activities() + [%Activity{}, ...] + + """ + def list_activities do + 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 + + @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 + Activity + |> preload(:speakers) + |> Repo.get!(id) + end + + @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 """ + 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 + ids = speaker_ids || [] + + speakers = + Speaker + |> where([s], s.id in ^ids) + |> Repo.all() + + activity + |> Activity.changeset_update_speakers(speakers) + |> 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 + + @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 + + @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/activity.ex b/lib/safira/activities/activity.ex new file mode 100644 index 00000000..13d704f4 --- /dev/null +++ b/lib/safira/activities/activity.ex @@ -0,0 +1,111 @@ +defmodule Safira.Activities.Activity do + @moduledoc """ + Activities scheduled for the event. + """ + 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 + + @derive { + Flop.Schema, + 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 + 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 + field :max_enrolments, :integer, default: 0 + + 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 + + @doc false + def changeset(activity, attrs) do + activity + |> cast(attrs, @required_fields ++ @optional_fields) + |> validate_required(@required_fields) + |> validate_activity_date() + |> validate_activity_times() + end + + @doc false + def changeset_update_speakers(activity, speakers) do + activity + |> 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) + + # Validate if the activity's date is within the event's start and end date + cond do + date == nil -> + activity + + Date.compare(date, event_start) == :lt -> + Ecto.Changeset.add_error( + activity, + :date, + "must be after or in the event's start date (#{Date.to_string(event_start)})" + ) + + Date.compare(date, event_end) == :gt -> + Ecto.Changeset.add_error( + activity, + :date, + "must be before or in the event's end date (#{Date.to_string(event_end)})" + ) + + true -> + activity + end + end + + def validate_activity_times(activity) do + time_start = get_field(activity, :time_start) + time_end = get_field(activity, :time_end) + + # Validate if the activity's time_end is after time_start + if time_start != nil and time_end != nil do + if Time.compare(time_end, time_start) in [:lt] do + activity + |> Ecto.Changeset.add_error( + :time_end, + "must be after the activity's start time (#{Time.to_string(time_start)})" + ) + else + activity + end + else + activity + end + 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/activities/speaker.ex b/lib/safira/activities/speaker.ex new file mode 100644 index 00000000..069681f9 --- /dev/null +++ b/lib/safira/activities/speaker.ex @@ -0,0 +1,98 @@ +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 + + many_to_many :activities, Activities.Activity, + join_through: "activities_speakers", + on_replace: :delete + + 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/event.ex b/lib/safira/event.ex new file mode 100644 index 00000000..fb1bad0a --- /dev/null +++ b/lib/safira/event.ex @@ -0,0 +1,78 @@ +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} -> + ensure_date(date) + + {: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} -> + ensure_date(date) + + {: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 + + defp ensure_date(string) when is_binary(string), do: Date.from_iso8601!(string) + + defp ensure_date(date), do: date +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/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/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.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/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/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/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/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/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..0af69aa6 --- /dev/null +++ b/lib/safira_web/live/backoffice/schedule_live/form_component.ex @@ -0,0 +1,104 @@ +defmodule SafiraWeb.Backoffice.ScheduleLive.FormComponent do + @moduledoc false + use SafiraWeb, :live_component + + import SafiraWeb.Components.Forms + + alias Ecto.Changeset + alias Safira.Event + + def render(assigns) do + ~H""" +
+ <.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[:event_start_date]} + name="event_start_date" + label="Start date" + type="date" + /> + <.field + field={@form[:event_end_date]} + name="event_end_date" + label="End date" + type="date" + /> +
+
+ <.button phx-disable-with="Saving...">Save Configuration +
+ +
+ +
+ """ + end + + def mount(socket) do + {:ok, + socket + |> assign( + form: + to_form( + %{ + "event_start_date" => Event.get_event_start_date(), + "event_end_date" => Event.get_event_end_date() + }, + as: :wheel_configuration + ) + )} + end + + 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, as: :event_configuration))} + end + + 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 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 valid_config?(params) do + validation = validate_configuration(params["event_start_date"], params["event_end_date"]) + validation.errors == [] + 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) + + 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 +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..c6ced199 --- /dev/null +++ b/lib/safira_web/live/backoffice/schedule_live/index.ex @@ -0,0 +1,122 @@ +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, Speaker} + + 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( + if socket.assigns.live_action != :speakers do + params + else + %{} + end, + 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{speakers: []}) + |> 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 + + 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, :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}} -> + 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) + + {: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..8670da7b --- /dev/null +++ b/lib/safira_web/live/backoffice/schedule_live/index.html.heex @@ -0,0 +1,181 @@ +<.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/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> + <.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> + <.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={:timestamp} label="Date"> + <%= Timex.format!(activity.date, "{D}/{M}/{YYYY}") %> + + <:col :let={{_id, activity}} 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="activites-modal" + show + on_cancel={JS.navigate(~p"/dashboard/schedule/activities")} +> + <.live_component + module={SafiraWeb.Backoffice.ScheduleLive.ActivityLive.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 [: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" + show + on_cancel={JS.patch(~p"/dashboard/schedule/activities")} +> + <.live_component + module={SafiraWeb.Backoffice.ScheduleLive.CategoryLive.Index} + id="categories" + 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"} + /> + + +<.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/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 cf3a31ce..3da0b0de 100644 --- a/lib/safira_web/router.ex +++ b/lib/safira_web/router.ex @@ -124,6 +124,28 @@ defmodule SafiraWeb.Router do end end + scope "/schedule", ScheduleLive do + live "/edit", Index, :edit_schedule + + scope "/activities" do + live "/", Index, :index + 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 + live "/:id/edit", Index, :categories_edit + end + end + end + scope "/store/products", ProductLive do live "/", Index, :index live "/new", Index, :new diff --git a/mix.exs b/mix.exs index 97eef8e4..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}, @@ -70,6 +71,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..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"}, @@ -28,6 +29,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 +42,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/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..0542d29e --- /dev/null +++ b/priv/repo/migrations/20241028131718_create_activities.exs @@ -0,0 +1,20 @@ +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, :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) + 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/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/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..6cacdb69 --- /dev/null +++ b/priv/repo/seeds/activities.exs @@ -0,0 +1,154 @@ +defmodule Safira.Repo.Seeds.Activities do + alias Safira.Repo + + 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() + _ -> + 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() + _ -> + Mix.shell().error("Found activities, aborting seeding activities.") + 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 = [ + %{ + 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_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 + 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 + +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..4b0997b3 --- /dev/null +++ b/test/safira/activities_test.exs @@ -0,0 +1,242 @@ +defmodule Safira.ActivitiesTest do + use Safira.DataCase + + alias Safira.Activities + alias Safira.Event + + 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 + 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", + 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() + + 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", + 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 + + 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 new file mode 100644 index 00000000..78ea3c86 --- /dev/null +++ b/test/support/fixtures/activities_fixtures.ex @@ -0,0 +1,62 @@ +defmodule Safira.ActivitiesFixtures do + @moduledoc """ + 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(%{ + 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() + + Map.put(activity, :speakers, []) + 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 + + @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