diff --git a/assets/css/app.css b/assets/css/app.css index e5bc31aca..8430f46b9 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -26,3 +26,15 @@ input:-webkit-autofill:focus, input:-webkit-autofill:active { -webkit-box-shadow: 0 0 0 30px white inset !important; } + +/* Chrome, Safari, Edge, Opera */ +input::-webkit-outer-spin-button, +input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* Firefox */ +input[type=number] { + -moz-appearance: textfield; +} \ No newline at end of file diff --git a/assets/js/app.js b/assets/js/app.js index 835747ba8..984b92b96 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -24,14 +24,15 @@ import {Socket} from "phoenix" import {LiveSocket} from "phoenix_live_view" import "../vendor/alpine.js"; import topbar from "../vendor/topbar" -import { QrScanner, InitSorting, StickyScroll, ScrollToTop } from "./hooks"; +import { QrScanner, InitSorting, StickyScroll, ScrollToTop, AutoFocus } from "./hooks"; import phxFeedbackDom from "./shims/phx_feedback_dom.js" let Hooks = { QrScanner: QrScanner, InitSorting: InitSorting, StickyScroll: StickyScroll, - ScrollToTop: ScrollToTop + ScrollToTop: ScrollToTop, + AutoFocus: AutoFocus, }; let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") diff --git a/assets/js/hooks/auto_focus.js b/assets/js/hooks/auto_focus.js new file mode 100644 index 000000000..42d3b1ffa --- /dev/null +++ b/assets/js/hooks/auto_focus.js @@ -0,0 +1,5 @@ +export const AutoFocus = { + mounted() { + this.el.focus() + } +} \ No newline at end of file diff --git a/lib/atomic/activities.ex b/lib/atomic/activities.ex index f6e5704c6..265a035fa 100644 --- a/lib/atomic/activities.ex +++ b/lib/atomic/activities.ex @@ -9,6 +9,8 @@ defmodule Atomic.Activities do alias Atomic.Activities.Enrollment alias Atomic.Feed.Post + @pubsub Atomic.PubSub + @doc """ Returns the list of activities. @@ -222,7 +224,7 @@ defmodule Atomic.Activities do end @doc """ - Updates a activity. + Updates an activity and broadcasts the changes to all subscribed clients. ## Examples @@ -234,10 +236,14 @@ defmodule Atomic.Activities do """ def update_activity(%Activity{} = activity, attrs, after_save \\ &{:ok, &1}) do - activity - |> Activity.changeset(attrs) - |> Repo.update() - |> after_save(after_save) + result = + activity + |> Activity.changeset(attrs) + |> Repo.update() + |> after_save(after_save) + + broadcast_activity_update(activity.id) + result end @doc """ @@ -322,27 +328,6 @@ defmodule Atomic.Activities do |> Repo.one() end - @doc """ - Gets the user enrolled in an given activity. - - ## Examples - - iex> get_user_enrolled(user, activity_id) - %Enrollment{} - - iex> get_user_enrolled(user, activity_id) - ** (Ecto.NoResultsError) - """ - def get_user_enrolled(user, activity_id) do - Enrollment - |> where(user_id: ^user.id, activity_id: ^activity_id) - |> Repo.one() - |> case do - nil -> create_enrollment(activity_id, user) - enrollment -> enrollment - end - end - @doc """ Gets all user enrollments. @@ -360,6 +345,27 @@ defmodule Atomic.Activities do |> Repo.all() end + @doc """ + Returns the list of participants in an activity. + + ## Examples + + iex> list_activity_participants(activity_id) + [%Enrollment{}, ...] + """ + def list_activity_participants(id) do + from(u in User, + join: e in assoc(u, :enrollments), + where: e.activity_id == ^id, + preload: [enrollments: e] + ) + end + + def list_display_participants(id, %{} = flop, opts \\ []) do + list_activity_participants(id) + |> Flop.validate_and_run(flop, opts) + end + @doc """ Returns the list of activities a user has enrolled in. @@ -397,7 +403,136 @@ defmodule Atomic.Activities do end @doc """ - Creates an enrollment. + Returns the list of upcoming activities a user has enrolled in. + + ## Examples + + iex> list_upcoming_user_activities(user_id) + [%Activity{}, ...] + """ + def list_upcoming_user_activities(user_id, params \\ %{}) + + def list_upcoming_user_activities(user_id, opts) when is_list(opts) do + from(a in Activity, + join: e in assoc(a, :enrollments), + where: e.user_id == ^user_id + ) + |> where([a], fragment("? > now()", a.start)) + |> apply_filters(opts) + |> Repo.all() + end + + def list_upcoming_user_activities(user_id, flop) do + from(a in Activity, + join: e in assoc(a, :enrollments), + where: e.user_id == ^user_id + ) + |> where([a], fragment("? > now()", a.start)) + |> Flop.validate_and_run(flop, for: Activity) + end + + def list_upcoming_user_activities(user_id, %{} = flop, opts) when is_list(opts) do + from(a in Activity, + join: e in assoc(a, :enrollments), + where: e.user_id == ^user_id + ) + |> where([a], fragment("? > now()", a.start)) + |> apply_filters(opts) + |> Flop.validate_and_run(flop, for: Activity) + end + + @doc """ + Returns the list of past activities a user has enrolled in. + + ## Examples + + iex> list_past_user_activities(user_id) + [%Activity{}, ...] + """ + def list_past_user_activities(user_id, params \\ %{}) + + def list_past_user_activities(user_id, opts) when is_list(opts) do + from(a in Activity, + join: e in assoc(a, :enrollments), + where: e.user_id == ^user_id + ) + |> where([a], fragment("? < now()", a.start)) + |> apply_filters(opts) + |> Repo.all() + end + + def list_past_user_activities(user_id, flop) do + from(a in Activity, + join: e in assoc(a, :enrollments), + where: e.user_id == ^user_id + ) + |> where([a], fragment("? < now()", a.start)) + |> Flop.validate_and_run(flop, for: Activity) + end + + def list_past_user_activities(user_id, %{} = flop, opts) when is_list(opts) do + from(a in Activity, + join: e in assoc(a, :enrollments), + where: e.user_id == ^user_id + ) + |> where([a], fragment("? < now()", a.start)) + |> apply_filters(opts) + |> Flop.validate_and_run(flop, for: Activity) + end + + @doc """ + Returns the list of activities a user has enrolled in. + + ## Examples + + iex> list_user_activities(user_id) + [%Activity{}, ...] + """ + + def list_organization_activities(organization_id, params \\ %{}) + + def list_organization_activities(organization_id, opts) when is_list(opts) do + from(a in Activity, + where: a.organization_id == ^organization_id + ) + |> apply_filters(opts) + |> Repo.all() + end + + def list_organization_activities(organization_id, flop) do + from(a in Activity, + where: a.organization_id == ^organization_id + ) + |> Flop.validate_and_run(flop, for: Activity) + end + + def list_organization_activities(organization_id, %{} = flop, opts) when is_list(opts) do + from(a in Activity, + where: a.organization_id == ^organization_id + ) + |> apply_filters(opts) + |> Flop.validate_and_run(flop, for: Activity) + end + + @doc """ + Returns the count of upcoming activities a user has enrolled in. + + ## Examples + + iex> user_upcoming_activities_count(user_id) + 1 + """ + def user_upcoming_activities_count(user_id) do + from(a in Activity, + join: e in assoc(a, :enrollments), + where: e.user_id == ^user_id + ) + |> where([a], fragment("? > now()", a.start)) + |> Repo.aggregate(:count, :id) + end + + @doc """ + Creates an enrollment and broadcasts the activity change to all subscribed clients. ## Examples @@ -409,23 +544,15 @@ defmodule Atomic.Activities do """ def create_enrollment(activity_id, %User{} = user) do - Ecto.Multi.new() - |> Ecto.Multi.insert(:enrollments, %Enrollment{ - activity_id: activity_id, - user_id: user.id - }) - |> Multi.update(:activity, fn %{enrollments: enrollment} -> - activity = get_activity!(enrollment.activity_id) - - Activity.changeset(activity, %{enrolled: activity.enrolled + 1}) - end) - |> Repo.transaction() + %Enrollment{} + |> Enrollment.changeset(%{activity_id: activity_id, user_id: user.id}) + |> Repo.insert() |> case do - {:ok, %{enrollments: enrollment, activity: _activity}} -> - broadcast({:ok, enrollment}, :new_enrollment) + {:ok, enrollment} -> + broadcast_activity_update(activity_id) {:ok, enrollment} - {:error, _reason, changeset, _actions} -> + {:error, changeset} -> {:error, changeset} end end @@ -449,7 +576,7 @@ defmodule Atomic.Activities do end @doc """ - Deletes a enrollment. + Deletes a enrollment and broadcasts the activity change to all subscribed clients. ## Examples @@ -461,20 +588,15 @@ defmodule Atomic.Activities do """ def delete_enrollment(activity_id, %User{} = user) do - Ecto.Multi.new() - |> Ecto.Multi.delete(:enrollments, get_user_enrolled(user, activity_id)) - |> Multi.update(:activity, fn %{enrollments: _enrollment} -> - activity = get_activity!(activity_id) - - Activity.changeset(activity, %{enrolled: activity.enrolled - 1}) - end) - |> Repo.transaction() + get_enrollment!(activity_id, user.id) + |> Enrollment.delete_changeset() + |> Repo.delete() |> case do - {:ok, %{enrollments: _enrollment, activity: _activity}} -> - broadcast({1, nil}, :deleted_enrollment) - {1, nil} + {:ok, _} -> + broadcast_activity_update(activity_id) + {:ok, nil} - {:error, _reason, changeset, _actions} -> + {:error, changeset} -> {:error, changeset} end end @@ -493,32 +615,21 @@ defmodule Atomic.Activities do end @doc """ - Broadcasts an event to the pubsub. + Subscribes the caller to the specific activity's updates. ## Examples - iex> broadcast(:new_enrollment, enrollment) - {:ok, %Enrollment{}} - - iex> broadcast(:deleted_enrollment, nil) - {:ok, nil} - + iex> subscribe_to_activity_update(activity_id) + :ok """ - def subscribe(topic) when topic in ["new_enrollment", "deleted_enrollment"] do - Phoenix.PubSub.subscribe(Atomic.PubSub, topic) + def subscribe_to_activity_update(activity_id) do + Phoenix.PubSub.subscribe(@pubsub, topic(activity_id)) end - defp broadcast({:error, _reason} = error, _event), do: error - - defp broadcast({:ok, %Enrollment{} = enrollment}, event) - when event in [:new_enrollment] do - Phoenix.PubSub.broadcast!(Atomic.PubSub, "new_enrollment", {event, enrollment}) - {:ok, enrollment} - end + defp topic(activity_id), do: "activity:#{activity_id}" - defp broadcast({number, nil}, event) - when event in [:deleted_enrollment] do - Phoenix.PubSub.broadcast!(Atomic.PubSub, "deleted_enrollment", {event, nil}) - {number, nil} + defp broadcast_activity_update(activity_id) do + activity = get_activity!(activity_id) + Phoenix.PubSub.broadcast(@pubsub, topic(activity_id), activity) end end diff --git a/lib/atomic/activities/activity.ex b/lib/atomic/activities/activity.ex index 4735f2e2a..48e921595 100644 --- a/lib/atomic/activities/activity.ex +++ b/lib/atomic/activities/activity.ex @@ -9,8 +9,8 @@ defmodule Atomic.Activities.Activity do alias Atomic.Location alias Atomic.Organizations.Organization - @required_fields ~w(title description start finish minimum_entries maximum_entries enrolled organization_id)a - @optional_fields ~w()a + @required_fields ~w(title description start finish enrolled organization_id)a + @optional_fields ~w(maximum_entries)a @derive { Flop.Schema, @@ -29,11 +29,10 @@ defmodule Atomic.Activities.Activity do field :start, :naive_datetime field :finish, :naive_datetime - field :maximum_entries, :integer - field :minimum_entries, :integer + field :maximum_entries, :integer, default: nil field :enrolled, :integer, default: 0 - field :image, Uploaders.Post.Type + field :card, Uploaders.Post.Type embeds_one :location, Location, on_replace: :update belongs_to :organization, Organization @@ -50,50 +49,13 @@ defmodule Atomic.Activities.Activity do |> cast_embed(:location, with: &Location.changeset/2) |> validate_required(@required_fields) |> validate_dates() - |> validate_entries() |> validate_enrollments() - |> check_constraint(:enrolled, name: :enrolled_less_than_max) |> maybe_mark_for_deletion() end def image_changeset(activity, attrs) do activity - |> cast_attachments(attrs, [:image]) - end - - defp validate_entries(changeset) do - minimum_entries = get_change(changeset, :minimum_entries) - maximum_entries = get_change(changeset, :maximum_entries) - - case {minimum_entries, maximum_entries} do - {nil, nil} -> - validate_entries_values( - changeset.data.minimum_entries, - changeset.data.maximum_entries, - changeset - ) - - {nil, maximum} -> - validate_entries_values(changeset.data.minimum_entries, maximum, changeset) - - {minimum, nil} -> - validate_entries_values(minimum, changeset.data.maximum_entries, changeset) - - {min, max} -> - validate_entries_values(min, max, changeset) - end - end - - def validate_entries_values(min_value, max_value, changeset) do - if min_value > max_value do - add_error( - changeset, - :maximum_entries, - gettext("must be greater than minimum entries") - ) - else - changeset - end + |> cast_attachments(attrs, [:card]) end defp validate_dates(changeset) do @@ -140,6 +102,8 @@ defmodule Atomic.Activities.Activity do end end + def validate_enrollments_values(_enrolled, nil, changeset), do: changeset + def validate_enrollments_values(enrolled, maximum_entries, changeset) do if enrolled > maximum_entries do add_error(changeset, :maximum_entries, gettext("maximum number of enrollments reached")) diff --git a/lib/atomic/activities/enrollment.ex b/lib/atomic/activities/enrollment.ex index 4284f7f94..46640d87d 100644 --- a/lib/atomic/activities/enrollment.ex +++ b/lib/atomic/activities/enrollment.ex @@ -25,6 +25,8 @@ defmodule Atomic.Activities.Enrollment do |> cast(attrs, @required_fields ++ @optional_fields) |> validate_maximum_entries() |> validate_required(@required_fields) + |> unique_constraint([:activity_id, :user_id], name: :unique_enrollments) + |> prepare_changes(&update_activity_enrolled/1) end def update_changeset(enrollment, attrs) do @@ -33,6 +35,12 @@ defmodule Atomic.Activities.Enrollment do |> validate_required(@required_fields) end + def delete_changeset(enrollment, attrs \\ %{}) do + enrollment + |> cast(attrs, @required_fields ++ @optional_fields) + |> prepare_changes(&update_activity_enrolled/1) + end + defp validate_maximum_entries(changeset) do activity_id = get_field(changeset, :activity_id) activity = Activities.get_activity!(activity_id) @@ -43,4 +51,21 @@ defmodule Atomic.Activities.Enrollment do changeset end end + + defp update_activity_enrolled(changeset) do + if activity_id = get_field(changeset, :activity_id) do + query = from Activity, where: [id: ^activity_id] + value = if changeset.action == :insert, do: 1, else: -1 + + case changeset.action do + action when action in [:insert, :delete] -> + changeset.repo.update_all(query, inc: [enrolled: value]) + + _ -> + changeset + end + end + + changeset + end end diff --git a/lib/atomic/location/location.ex b/lib/atomic/location/location.ex index 3b6234f43..ec6d229ee 100644 --- a/lib/atomic/location/location.ex +++ b/lib/atomic/location/location.ex @@ -4,14 +4,14 @@ defmodule Atomic.Location do """ use Atomic.Schema - @required_fields ~w(name)a - @optional_fields ~w(url)a + @required_fields ~w(name address)a + @optional_fields ~w()a @derive Jason.Encoder @primary_key false embedded_schema do field :name, :string - field :url, :string + field :address, :string end def changeset(location, attrs) do diff --git a/lib/atomic_web/components/activity.ex b/lib/atomic_web/components/activity.ex index 15cd7db89..c85bea9fb 100644 --- a/lib/atomic_web/components/activity.ex +++ b/lib/atomic_web/components/activity.ex @@ -36,9 +36,9 @@ defmodule AtomicWeb.Components.Activity do

{maybe_slice_string(@activity.description, 300)}

- <%= if @activity.image do %> + <%= if @activity.card do %>
- +
<% end %> @@ -47,14 +47,20 @@ defmodule AtomicWeb.Components.Activity do <.icon name="hero-calendar-solid" class="mr-1.5 h-5 w-5 flex-shrink-0 text-zinc-400" /> - {pretty_display_date(@activity.start)} + <%= if Timex.to_date(@activity.start) == Timex.to_date(@activity.finish) do %> + {pretty_display_date(@activity.start)} + <% else %> + {pretty_display_date(@activity.start)} + <.icon name="hero-arrow-right" class="size-4 mt-[3px]" /> + {pretty_display_date(@activity.finish)} + <% end %> starting in <.icon name="hero-user-group-solid" class="size-5" /> - {@activity.enrolled}/{@activity.maximum_entries} + {@activity.enrolled} {if @activity.maximum_entries, do: "/"} {@activity.maximum_entries} enrollments @@ -72,7 +78,7 @@ defmodule AtomicWeb.Components.Activity do end defp footer_margin_top_class(%Activity{} = activity) do - if activity.image do + if activity.card do "mt-4" else "mt-2" diff --git a/lib/atomic_web/components/button.ex b/lib/atomic_web/components/button.ex index 997a28a0b..c3aa7a169 100644 --- a/lib/atomic_web/components/button.ex +++ b/lib/atomic_web/components/button.ex @@ -52,7 +52,7 @@ defmodule AtomicWeb.Components.Button do attr :rest, :global, include: - ~w(csrf_token disabled download form href hreflang method name navigate patch referrerpolicy rel replace target type value autofocus tabindex), + ~w(csrf_token download form href hreflang method name navigate patch referrerpolicy rel replace target type value autofocus tabindex), doc: "Arbitrary HTML or phx attributes." slot :inner_block, required: false, doc: "Slot for the content of the button." @@ -121,7 +121,7 @@ defmodule AtomicWeb.Components.Button do defp icon_content(assigns) do ~H""" - <.icon name={@icon} class={"#{generate_icon_classes(assigns)}"} /> + <.icon name={@icon <> ""} class={"#{generate_icon_classes(assigns)}"} /> """ end diff --git a/lib/atomic_web/components/dropdown.ex b/lib/atomic_web/components/dropdown.ex index 449ecbf25..f568e919d 100644 --- a/lib/atomic_web/components/dropdown.ex +++ b/lib/atomic_web/components/dropdown.ex @@ -46,14 +46,14 @@ defmodule AtomicWeb.Components.Dropdown do method={Map.get(item, :method, "get")} > <%= if item[:icon] do %> - <.icon name={item.icon} class="size-5 ml-2 inline-block" /> + <.icon name={"hero-" <> item.icon} class="size-5 ml-2 inline-block" /> <% end %> {item.name} <% else %>
<%= if item[:icon] do %> - <.icon name={item.icon} class="size-5 ml-2 inline-block" /> + <.icon name={"hero-" <> item.icon} class="size-5 ml-2 inline-block" /> <% end %> {item.name}
diff --git a/lib/atomic_web/components/forms.ex b/lib/atomic_web/components/forms.ex index a6020ea07..02b43ff65 100644 --- a/lib/atomic_web/components/forms.ex +++ b/lib/atomic_web/components/forms.ex @@ -52,7 +52,7 @@ defmodule AtomicWeb.Components.Forms do attr :errors, :list, default: [], - doc: "A list of erros to be displayed. If not provided, it will be generated." + doc: "A list of errors to be displayed. If not provided, it will be generated." attr :checked, :any, doc: "The checked flag for checkboxes and checkboxes groups." @@ -82,6 +82,8 @@ defmodule AtomicWeb.Components.Forms do attr :class, :string, default: nil, doc: "The class to be added to the input." attr :wrapper_class, :string, default: nil, doc: "The wrapper div class." attr :label_class, :string, default: nil, doc: "Extra class for the label." + attr :error_class, :string, default: nil, doc: "Extra error class for the input." + attr :error_label_class, :string, default: nil, doc: "Extra class for the error message." attr :help_text, :string, default: nil, doc: "Context/help for the input." attr :required, :boolean, @@ -113,10 +115,10 @@ defmodule AtomicWeb.Components.Forms do assign_new(assigns, :checked, fn -> HTML.Form.normalize_value("checkbox", value) end) ~H""" - <.field_wrapper errors={@errors} name={@name} class={@wrapper_class}> + <.field_wrapper errors={@errors} name={@name} class={@wrapper_class} error_class={@error_class}>