From b65a870eb8df7b0708c0e8958377a68e22aa6bbc Mon Sep 17 00:00:00 2001 From: GHX5T-SOL Date: Wed, 20 May 2026 20:33:50 +0200 Subject: [PATCH] Add bounty sort dropdown --- lib/algora/bounties/bounties.ex | 23 ++- lib/algora_web/live/bounties_live.ex | 215 ++++++++++++++------------- test/algora/bounties_test.exs | 33 ++++ 3 files changed, 163 insertions(+), 108 deletions(-) diff --git a/lib/algora/bounties/bounties.ex b/lib/algora/bounties/bounties.ex index 6e3054570..db16169d5 100644 --- a/lib/algora/bounties/bounties.ex +++ b/lib/algora/bounties/bounties.ex @@ -30,11 +30,13 @@ defmodule Algora.Bounties do @type criterion :: {:id, String.t()} | {:limit, non_neg_integer() | :infinity} + | {:offset, non_neg_integer()} | {:ticket_id, String.t()} | {:owner_id, String.t()} | {:owner_handles, [String.t()]} | {:status, :open | :paid} | {:tech_stack, [String.t()]} + | {:order, :date | :amount | :technology} | {:before, %{inserted_at: DateTime.t(), id: String.t()}} | {:amount_gt, Money.t()} | {:current_user, User.t()} @@ -1199,6 +1201,9 @@ defmodule Algora.Bounties do {:limit, limit}, query -> from([b] in query, limit: ^limit) + {:offset, offset}, query -> + from([b] in query, offset: ^offset) + {:ticket_id, ticket_id}, query -> from([b] in query, where: b.ticket_id == ^ticket_id) @@ -1340,7 +1345,7 @@ defmodule Algora.Bounties do base_query |> list_bounties_query(criteria) # TODO: sort by b.paid_at if criteria[:status] == :paid - |> order_by([b], desc: b.inserted_at, desc: b.id) + |> order_bounties(criteria[:order]) |> select([b, o: o, t: t, ro: ro, r: r], %{ id: b.id, inserted_at: b.inserted_at, @@ -1378,6 +1383,22 @@ defmodule Algora.Bounties do |> Repo.all() end + defp order_bounties(query, :amount) do + order_by(query, [b], desc: fragment("amount(?)", b.amount), desc: b.inserted_at, desc: b.id) + end + + defp order_bounties(query, :technology) do + order_by(query, [b, r: r], + asc: fragment("COALESCE(LOWER((?)[1]::text), '~')", r.tech_stack), + desc: b.inserted_at, + desc: b.id + ) + end + + defp order_bounties(query, _order) do + order_by(query, [b], desc: b.inserted_at, desc: b.id) + end + def list_tech(criteria \\ []) do base_query() |> list_bounties_query(Keyword.put(criteria, :limit, :infinity)) diff --git a/lib/algora_web/live/bounties_live.ex b/lib/algora_web/live/bounties_live.ex index 05c7dfe5c..b7d9b13e7 100644 --- a/lib/algora_web/live/bounties_live.ex +++ b/lib/algora_web/live/bounties_live.ex @@ -10,102 +10,51 @@ defmodule AlgoraWeb.BountiesLive do require Logger + @default_sort "date" + @sort_options [{"date", "Date"}, {"price", "Price"}, {"technology", "Technology"}] + @impl true - def handle_params(%{"tech" => tech}, _uri, socket) when is_binary(tech) do - selected_techs = tech |> String.split(",") |> Enum.reject(&(&1 == "")) |> Enum.map(&String.downcase/1) - valid_techs = Enum.map(socket.assigns.techs, fn {tech, _} -> String.downcase(tech) end) - # Only keep valid techs that exist in the available tech list - selected_techs = Enum.filter(selected_techs, &(&1 in valid_techs)) + def handle_params(params, _uri, socket) do + selected_techs = parse_selected_techs(params["tech"], socket.assigns.techs) + selected_sort = parse_sort(params["sort"]) query_opts = - if selected_techs == [] do - Keyword.delete(socket.assigns.query_opts, :tech_stack) - else - Keyword.put(socket.assigns.query_opts, :tech_stack, selected_techs) - end + socket.assigns.query_opts + |> Keyword.put(:order, sort_order(selected_sort)) + |> put_selected_techs(selected_techs) {:noreply, socket - |> assign(:page_title, "#{Enum.map_join(selected_techs, "/", &String.capitalize/1)} Bounties") + |> assign(:page_title, page_title(selected_techs)) |> assign(:selected_techs, selected_techs) + |> assign(:selected_sort, selected_sort) |> assign(:query_opts, query_opts) |> assign_bounties()} end - def handle_params(_params, _uri, socket) do - {:noreply, - socket - |> assign(:page_title, "Bounties") - |> assign(:selected_techs, []) - |> assign(:query_opts, Keyword.delete(socket.assigns.query_opts, :tech_stack)) - |> assign_bounties()} - end - @impl true - def mount(%{"tech" => tech}, _session, socket) when is_binary(tech) do + def mount(params, _session, socket) do if connected?(socket) do Bounties.subscribe() end - # Parse selected techs from URL params and ensure lowercase - selected_techs = - tech - |> String.split(",") - |> Enum.reject(&(&1 == "")) - |> Enum.map(&String.downcase/1) - - query_opts = - [ - status: :open, - limit: page_size(), - current_user: socket.assigns[:current_user] - ] ++ - if socket.assigns[:current_user] do - [amount_gt: Money.new(:USD, 100)] - else - [amount_gt: Money.new(:USD, 500)] - end - + query_opts = base_query_opts(socket) techs = Bounties.list_tech(query_opts) - # Only keep valid techs that exist in the available tech list (case insensitive) - valid_techs = Enum.map(techs, fn {tech, _} -> String.downcase(tech) end) - selected_techs = Enum.filter(selected_techs, &(&1 in valid_techs)) - - query_opts = if selected_techs == [], do: query_opts, else: Keyword.put(query_opts, :tech_stack, selected_techs) - - {:ok, - socket - |> assign(:techs, techs) - |> assign(:selected_techs, selected_techs) - |> assign(:query_opts, query_opts) - |> assign_bounties() - |> assign_events()} - end - - def mount(_params, _session, socket) do - if connected?(socket) do - Bounties.subscribe() - end + selected_techs = parse_selected_techs(params["tech"], techs) + selected_sort = parse_sort(params["sort"]) query_opts = - [ - status: :open, - limit: page_size(), - current_user: socket.assigns[:current_user] - ] ++ - if socket.assigns[:current_user] do - [amount_gt: Money.new(:USD, 100)] - else - [amount_gt: Money.new(:USD, 500)] - end - - techs = Bounties.list_tech(query_opts) + query_opts + |> Keyword.put(:order, sort_order(selected_sort)) + |> put_selected_techs(selected_techs) {:ok, socket |> assign(:techs, techs) - |> assign(:selected_techs, []) + |> assign(:sort_options, @sort_options) + |> assign(:selected_techs, selected_techs) + |> assign(:selected_sort, selected_sort) |> assign(:query_opts, query_opts) |> assign_bounties() |> assign_events()} @@ -116,21 +65,42 @@ defmodule AlgoraWeb.BountiesLive do ~H"""
<.section title="Bounties" subtitle="Open bounties for you"> -
- <%= for {tech, count} <- @techs do %> -
- <.badge - variant={if String.downcase(tech) in @selected_techs, do: "success", else: "default"} - class={ - if String.downcase(tech) in @selected_techs, - do: "hover:bg-success/5 transition-colors", - else: "hover:bg-accent/80 transition-colors" - } +
+
+ <%= for {tech, count} <- @techs do %> +
+ <.badge + variant={ + if String.downcase(tech) in @selected_techs, do: "success", else: "default" + } + class={ + if String.downcase(tech) in @selected_techs, + do: "hover:bg-success/5 transition-colors", + else: "hover:bg-accent/80 transition-colors" + } + > + {tech} ({count}) + +
+ <% end %> +
+ +
+ + +
<%= if Enum.empty?(@bounties) do %> <.card class="rounded-lg bg-card py-12 text-center lg:rounded-[2rem]"> @@ -585,12 +555,7 @@ defmodule AlgoraWeb.BountiesLive do %{bounties: bounties} = socket.assigns more_bounties = - Bounties.list_bounties( - Keyword.put(socket.assigns.query_opts, :before, %{ - inserted_at: List.last(bounties).inserted_at, - id: List.last(bounties).id - }) - ) + Bounties.list_bounties(Keyword.put(socket.assigns.query_opts, :offset, length(bounties))) {:noreply, socket @@ -609,22 +574,11 @@ defmodule AlgoraWeb.BountiesLive do [tech | socket.assigns.selected_techs] end - query_opts = - if selected_techs == [] do - Keyword.delete(socket.assigns.query_opts, :tech_stack) - else - Keyword.put(socket.assigns.query_opts, :tech_stack, selected_techs) - end - - # Update the URL with selected techs - path = if selected_techs == [], do: ~p"/bounties", else: ~p"/bounties/#{Enum.join(selected_techs, ",")}" + {:noreply, push_patch(socket, to: bounty_path(selected_techs, socket.assigns.selected_sort))} + end - {:noreply, - socket - |> push_patch(to: path) - |> assign(:selected_techs, selected_techs) - |> assign(:query_opts, query_opts) - |> assign_bounties()} + def handle_event("change_sort", %{"sort" => sort}, socket) do + {:noreply, push_patch(socket, to: bounty_path(socket.assigns.selected_techs, parse_sort(sort)))} end defp assign_bounties(socket) do @@ -637,6 +591,53 @@ defmodule AlgoraWeb.BountiesLive do defp page_size, do: 10 + defp base_query_opts(socket) do + [ + status: :open, + limit: page_size(), + current_user: socket.assigns[:current_user] + ] ++ + if socket.assigns[:current_user] do + [amount_gt: Money.new(:USD, 100)] + else + [amount_gt: Money.new(:USD, 500)] + end + end + + defp parse_selected_techs(nil, _techs), do: [] + + defp parse_selected_techs(tech, techs) when is_binary(tech) do + valid_techs = Enum.map(techs, fn {tech, _} -> String.downcase(tech) end) + + tech + |> String.split(",") + |> Enum.reject(&(&1 == "")) + |> Enum.map(&String.downcase/1) + |> Enum.filter(&(&1 in valid_techs)) + end + + defp parse_sort(sort) when sort in ["price", "technology"], do: sort + defp parse_sort(_sort), do: @default_sort + + defp sort_order("price"), do: :amount + defp sort_order("technology"), do: :technology + defp sort_order(_sort), do: :date + + defp put_selected_techs(query_opts, []), do: Keyword.delete(query_opts, :tech_stack) + defp put_selected_techs(query_opts, selected_techs), do: Keyword.put(query_opts, :tech_stack, selected_techs) + + defp page_title([]), do: "Bounties" + defp page_title(selected_techs), do: "#{Enum.map_join(selected_techs, "/", &String.capitalize/1)} Bounties" + + defp bounty_path(selected_techs, selected_sort) do + query = if selected_sort == @default_sort, do: [], else: [sort: selected_sort] + + case selected_techs do + [] -> ~p"/bounties?#{query}" + techs -> ~p"/bounties/#{Enum.join(techs, ",")}?#{query}" + end + end + defp events(assigns) do ~H"""
    diff --git a/test/algora/bounties_test.exs b/test/algora/bounties_test.exs index 5e61eab8f..e05dc7d79 100644 --- a/test/algora/bounties_test.exs +++ b/test/algora/bounties_test.exs @@ -658,6 +658,26 @@ defmodule Algora.BountiesTest do assert Enum.any?(bounties, &(&1.status == :paid)) refute Enum.any?(bounties, &(&1.status == :cancelled)) end + + test "orders bounties by amount descending" do + low = insert_bounty_for_sort(amount: Money.new!(100, :USD), tech_stack: ["Elixir"]) + high = insert_bounty_for_sort(amount: Money.new!(900, :USD), tech_stack: ["Ruby"]) + mid = insert_bounty_for_sort(amount: Money.new!(500, :USD), tech_stack: ["JavaScript"]) + + bounties = Bounties.list_bounties(order: :amount, limit: 3) + + assert Enum.map(bounties, & &1.id) == [high.id, mid.id, low.id] + end + + test "orders bounties by primary repository technology" do + javascript = insert_bounty_for_sort(amount: Money.new!(100, :USD), tech_stack: ["JavaScript"]) + elixir = insert_bounty_for_sort(amount: Money.new!(100, :USD), tech_stack: ["Elixir"]) + c = insert_bounty_for_sort(amount: Money.new!(100, :USD), tech_stack: ["C"]) + + bounties = Bounties.list_bounties(order: :technology, limit: 3) + + assert Enum.map(bounties, & &1.id) == [c.id, elixir.id, javascript.id] + end end describe "list_claims/1" do @@ -738,4 +758,17 @@ defmodule Algora.BountiesTest do assert payout.description == "repo#123" end end + + defp insert_bounty_for_sort(attrs) do + owner = insert!(:user) + repo = insert!(:repository, user: owner, tech_stack: attrs[:tech_stack]) + ticket = insert!(:ticket, repository: repo) + + insert!(:bounty, + amount: attrs[:amount], + owner: owner, + creator: owner, + ticket: ticket + ) + end end