Deployment and lifecycle management for Nix
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add Flop-powered filtering, sorting, and pagination to seed list

Derive Flop.Schema on Seed with filterable/sortable fields, add list_flop/1
context function, extend .table component with optional sortable column headers,
switch SeedLive.Index from stream to handle_params-driven Flop queries, add
filter form (name search + seed_type select) and Flop.Phoenix pagination.

SOW-110

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+194 -11
+21
apps/sower/lib/sower/orchestration/seed.ex
··· 12 12 13 13 @derive {Phoenix.Param, key: :sid} 14 14 15 + @derive { 16 + Flop.Schema, 17 + filterable: [:name, :seed_type], 18 + sortable: [:name, :seed_type, :updated_at], 19 + default_limit: 20, 20 + default_order: %{ 21 + order_by: [:updated_at], 22 + order_directions: [:desc] 23 + } 24 + } 25 + 15 26 @seed_types SowerClient.Seed.seed_types() 16 27 17 28 schema "seeds" do ··· 137 148 138 149 Repo.all(query) 139 150 |> Repo.preload([:tags]) 151 + end 152 + 153 + def list_flop(params \\ %{}) do 154 + case Flop.validate_and_run(Seed, params, for: Seed) do 155 + {:ok, {seeds, meta}} -> 156 + {:ok, {Repo.preload(seeds, [:tags]), meta}} 157 + 158 + {:error, meta} -> 159 + {:error, meta} 160 + end 140 161 end 141 162 142 163 @doc """
+47 -2
apps/sower/lib/sower_web/components/sower_components.ex
··· 4 4 import SowerWeb.CoreComponents, only: [button: 1] 5 5 6 6 @doc """ 7 - Renders a table with responsive column hiding. 7 + Renders a table with responsive column hiding and optional sortable headers. 8 8 9 9 Columns can be hidden below a breakpoint by setting `hide_on={:sm}` (or `:md`, `:lg`, `:xl`) 10 10 on the `:col` slot, which applies `hidden <bp>:table-cell` classes to both `<th>` and `<td>`. 11 + 12 + For sortable columns, set `field={:field_name}` on the `:col` slot and provide `meta` and `path` 13 + on the table. Columns without `field` render plain labels as before. 11 14 """ 12 15 attr :id, :string, required: true 13 16 attr :rows, :list, required: true 14 17 attr :row_id, :any, default: nil 15 18 attr :row_click, :any, default: nil 16 19 attr :row_item, :any, default: &Function.identity/1 20 + attr :meta, :any, default: nil 21 + attr :path, :string, default: nil 17 22 18 23 slot :col, required: true do 19 24 attr :label, :string 20 25 attr :hide_on, :atom 26 + attr :field, :atom 21 27 end 22 28 23 29 attr :action_hide_on, :atom, default: nil ··· 44 50 col[:hide_on] && "hidden #{col[:hide_on]}:table-cell" 45 51 ]} 46 52 > 47 - {col[:label]} 53 + <%= if col[:field] && @meta && @path do %> 54 + <.sort_link field={col[:field]} label={col[:label]} meta={@meta} path={@path} /> 55 + <% else %> 56 + {col[:label]} 57 + <% end %> 48 58 </th> 49 59 <th 50 60 :if={@action != []} ··· 101 111 </tbody> 102 112 </table> 103 113 </div> 114 + """ 115 + end 116 + 117 + attr :field, :atom, required: true 118 + attr :label, :string, required: true 119 + attr :meta, Flop.Meta, required: true 120 + attr :path, :string, required: true 121 + 122 + defp sort_link(assigns) do 123 + flop = assigns.meta.flop 124 + current_field = List.first(flop.order_by || []) 125 + current_dir = List.first(flop.order_directions || []) 126 + 127 + indicator = 128 + if current_field == assigns.field do 129 + case current_dir do 130 + :asc -> " \u25B4" 131 + :asc_nulls_first -> " \u25B4" 132 + :asc_nulls_last -> " \u25B4" 133 + _ -> " \u25BE" 134 + end 135 + end 136 + 137 + new_flop = Flop.push_order(flop, assigns.field) 138 + href = Flop.Phoenix.build_path(assigns.path, new_flop) 139 + 140 + assigns = assign(assigns, indicator: indicator, href: href) 141 + 142 + ~H""" 143 + <.link 144 + patch={@href} 145 + class="group inline-flex items-center hover:text-zinc-700 dark:hover:text-zinc-300" 146 + > 147 + {@label}<span :if={@indicator} class="ml-1">{@indicator}</span> 148 + </.link> 104 149 """ 105 150 end 106 151
+42 -3
apps/sower/lib/sower_web/live/seed_live/index.ex
··· 1 1 defmodule SowerWeb.SeedLive.Index do 2 2 use SowerWeb, :live_view 3 3 4 - @impl true 5 - def mount(_params, _session, socket) do 6 - {:ok, stream(socket, :seeds, Sower.Orchestration.Seed.list())} 4 + alias Sower.Orchestration.Seed 5 + 6 + @impl Phoenix.LiveView 7 + def handle_params(params, _uri, socket) do 8 + case Seed.list_flop(params) do 9 + {:ok, {seeds, meta}} -> 10 + {:noreply, assign(socket, seeds: seeds, meta: meta)} 11 + 12 + {:error, meta} -> 13 + {:noreply, assign(socket, seeds: [], meta: meta)} 14 + end 15 + end 16 + 17 + @impl Phoenix.LiveView 18 + def handle_event("filter", params, socket) do 19 + filters = 20 + [] 21 + |> maybe_add_filter(:name, :ilike_and, params["name"]) 22 + |> maybe_add_filter(:seed_type, :==, params["seed_type"]) 23 + 24 + flop = %Flop{filters: filters} 25 + path = Flop.Phoenix.build_path(~p"/seeds", flop) 26 + 27 + {:noreply, push_patch(socket, to: path)} 28 + end 29 + 30 + defp maybe_add_filter(filters, _field, _op, nil), do: filters 31 + defp maybe_add_filter(filters, _field, _op, ""), do: filters 32 + 33 + defp maybe_add_filter(filters, field, op, value) do 34 + filters ++ [%Flop.Filter{field: field, op: op, value: value}] 7 35 end 36 + 37 + defp filter_value(%Flop.Meta{flop: %Flop{filters: filters}}, field) do 38 + case Enum.find(filters, &(&1.field == field)) do 39 + %Flop.Filter{value: value} -> value 40 + nil -> nil 41 + end 42 + end 43 + 44 + defp filter_value(_meta, _field), do: nil 45 + 46 + defp seed_types, do: SowerClient.Seed.seed_types() 8 47 end
+38 -6
apps/sower/lib/sower_web/live/seed_live/index.html.heex
··· 4 4 <:subtitle>A seed is an installable configuration.</:subtitle> 5 5 </.header> 6 6 7 + <form phx-change="filter" class="mt-6 flex gap-4 items-end"> 8 + <div class="flex-1"> 9 + <label class="block text-sm font-medium text-zinc-700 dark:text-zinc-300">Name</label> 10 + <input 11 + type="text" 12 + name="name" 13 + value={filter_value(@meta, :name)} 14 + placeholder="Search by name..." 15 + phx-debounce="300" 16 + class="mt-1 block w-full rounded-md border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 shadow-sm focus:border-zinc-400 focus:ring-0 sm:text-sm" 17 + /> 18 + </div> 19 + <div> 20 + <label class="block text-sm font-medium text-zinc-700 dark:text-zinc-300">Type</label> 21 + <select 22 + name="seed_type" 23 + class="mt-1 block w-full rounded-md border-zinc-300 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 shadow-sm focus:border-zinc-400 focus:ring-0 sm:text-sm" 24 + > 25 + <option value="">All types</option> 26 + <option :for={t <- seed_types()} value={t} selected={filter_value(@meta, :seed_type) == t}> 27 + {t} 28 + </option> 29 + </select> 30 + </div> 31 + </form> 32 + 7 33 <.table 8 34 id="seeds" 9 - rows={@streams.seeds} 10 - row_click={fn {_id, seed} -> JS.navigate(~p"/seeds/#{seed}") end} 35 + rows={@seeds} 36 + meta={@meta} 37 + path={~p"/seeds"} 38 + row_click={fn seed -> JS.navigate(~p"/seeds/#{seed}") end} 11 39 > 12 - <:col :let={{_id, seed}} label="name"> 40 + <:col :let={seed} label="name" field={:name}> 13 41 <span class="block truncate max-sm:max-w-[8rem]">{seed.name}</span> 14 42 </:col> 15 - <:col :let={{_id, seed}} label="seed_type">{seed.seed_type}</:col> 16 - <:col :let={{_id, seed}} label="updated" hide_on={:sm}> 43 + <:col :let={seed} label="seed_type" field={:seed_type}>{seed.seed_type}</:col> 44 + <:col :let={seed} label="updated" field={:updated_at} hide_on={:sm}> 17 45 <.local_datetime datetime={seed.updated_at} user_timezone={@user_timezone} /> 18 46 </:col> 19 - <:action :let={{_id, seed}}> 47 + <:action :let={seed}> 20 48 <div class="sr-only"> 21 49 <.link navigate={~p"/seeds/#{seed}"}>Show</.link> 22 50 </div> 23 51 </:action> 24 52 </.table> 53 + 54 + <div class="mt-4"> 55 + <Flop.Phoenix.pagination meta={@meta} path={~p"/seeds"} /> 56 + </div> 25 57 </Layouts.app>
+46
nix/packages/deps.nix
··· 591 591 in 592 592 drv; 593 593 594 + flop = 595 + let 596 + version = "0.26.3"; 597 + drv = buildMix { 598 + inherit version; 599 + name = "flop"; 600 + appConfigPath = ../../config; 601 + 602 + src = fetchHex { 603 + inherit version; 604 + pkg = "flop"; 605 + sha256 = "cd77588229778ac55560c90dfbe15ab6486773f067d6e52db9fa703b8c9a9d2d"; 606 + }; 607 + 608 + beamDeps = [ 609 + ecto 610 + nimble_options 611 + ]; 612 + }; 613 + in 614 + drv; 615 + 616 + flop_phoenix = 617 + let 618 + version = "0.25.3"; 619 + drv = buildMix { 620 + inherit version; 621 + name = "flop_phoenix"; 622 + appConfigPath = ../../config; 623 + 624 + src = fetchHex { 625 + inherit version; 626 + pkg = "flop_phoenix"; 627 + sha256 = "912fae3c343dde43c5ea4f642275793d9dbef32989bf200013e12b85adb93b9c"; 628 + }; 629 + 630 + beamDeps = [ 631 + flop 632 + phoenix 633 + phoenix_html 634 + phoenix_live_view 635 + ]; 636 + }; 637 + in 638 + drv; 639 + 594 640 gen_stage = 595 641 let 596 642 version = "1.3.2";