Deployment and lifecycle management for Nix
0
fork

Configure Feed

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

feat: toggleable columns + version column on gardens index

Users can show/hide columns via a Columns dropdown above the gardens
table. Selection is persisted in the URL as ?cols=... so views are
shareable and survive reload. The existing :version field is exposed as
the first togglable column; Name is locked on so the table can't be
emptied.

Column visibility and Flop sort/page state coexist because cols= is
baked into the path passed to the table and pagination components;
Flop.Phoenix.build_path preserves existing query params on the base.

feat(sow-177): move Columns toggle to header as icon button

Relocate the Columns toggle into the page header's :actions slot as an
icon-only button (hero-view-columns) with aria-label/title. Reclaims the
vertical space previously taken by a dedicated row above the table.

sow-177

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

+260 -7
+1 -1
apps/sower/lib/sower/orchestration/garden.ex
··· 14 14 @derive { 15 15 Flop.Schema, 16 16 filterable: [], 17 - sortable: [:name, :inserted_at], 17 + sortable: [:name, :inserted_at, :version], 18 18 default_limit: 20, 19 19 default_order: %{ 20 20 order_by: [:name],
+96 -1
apps/sower/lib/sower_web/live/garden_live/index.ex
··· 1 + defmodule SowerWeb.GardenLive.Index.Column do 2 + use TypedStruct 3 + 4 + typedstruct do 5 + field :key, atom(), enforce: true 6 + field :label, String.t(), enforce: true 7 + field :default, boolean(), default: false 8 + field :lockable, boolean(), default: false 9 + end 10 + end 11 + 1 12 defmodule SowerWeb.GardenLive.Index do 2 13 use SowerWeb, :live_view 3 14 4 15 alias Phoenix.Socket.Broadcast 5 16 alias Sower.Orchestration 6 17 alias Sower.Orchestration.Garden 18 + alias SowerWeb.GardenLive.Index.Column 7 19 alias SowerWeb.Presence 8 20 21 + @columns [ 22 + %Column{key: :name, label: "Name", default: true, lockable: true}, 23 + %Column{key: :online, label: "Online", default: true}, 24 + %Column{key: :deploy, label: "Deploy", default: true}, 25 + %Column{key: :version, label: "Version", default: true} 26 + ] 27 + 9 28 @impl Phoenix.LiveView 10 29 def mount(_params, _session, socket) do 11 30 if connected?(socket) do 12 31 Phoenix.PubSub.subscribe(Sower.PubSub, "garden:presence") 13 32 end 14 33 15 - {:ok, assign(socket, :garden_presence, Presence.list("garden:presence"))} 34 + {:ok, 35 + socket 36 + |> assign(:garden_presence, Presence.list("garden:presence")) 37 + |> assign(:columns, @columns)} 16 38 end 17 39 18 40 @impl Phoenix.LiveView 19 41 def handle_params(params, _url, socket) do 42 + visible_cols = parse_cols(params) 43 + 44 + socket = 45 + socket 46 + |> assign(:visible_cols, visible_cols) 47 + |> assign(:cols_path, cols_path(visible_cols)) 48 + 20 49 socket = 21 50 case Orchestration.list_gardens_flop(params) do 22 51 {:ok, {gardens, meta}} -> ··· 87 116 {:noreply, assign(socket, gardens: [], meta: meta)} 88 117 end 89 118 end 119 + 120 + def handle_event("toggle_col", %{"col" => col}, socket) do 121 + new_cols = toggle_col(socket.assigns.visible_cols, col) 122 + path = Flop.Phoenix.build_path(cols_path(new_cols), socket.assigns.meta.flop) 123 + {:noreply, push_patch(socket, to: path)} 124 + end 125 + 126 + defp toggle_col(visible_cols, col) do 127 + case Enum.find(@columns, &(Atom.to_string(&1.key) == col)) do 128 + nil -> visible_cols 129 + %Column{lockable: true} -> visible_cols 130 + %Column{key: key} -> flip(visible_cols, key) 131 + end 132 + end 133 + 134 + defp flip(set, key) do 135 + if MapSet.member?(set, key), 136 + do: MapSet.delete(set, key), 137 + else: MapSet.put(set, key) 138 + end 139 + 140 + @doc false 141 + def parse_cols(params) do 142 + case Map.get(params, "cols") do 143 + raw when is_binary(raw) and raw != "" -> 144 + parsed = 145 + raw 146 + |> String.split(",", trim: true) 147 + |> Enum.map(&String.trim/1) 148 + |> Enum.flat_map(fn s -> 149 + case Enum.find(@columns, &(Atom.to_string(&1.key) == s)) do 150 + nil -> [] 151 + %Column{key: key} -> [key] 152 + end 153 + end) 154 + |> MapSet.new() 155 + 156 + if MapSet.size(parsed) == 0 do 157 + default_cols() 158 + else 159 + MapSet.union(parsed, lockable_cols()) 160 + end 161 + 162 + _ -> 163 + default_cols() 164 + end 165 + end 166 + 167 + @doc false 168 + def cols_query_string(visible_cols) do 169 + if MapSet.equal?(visible_cols, default_cols()) do 170 + "" 171 + else 172 + keys = 173 + @columns 174 + |> Enum.filter(&MapSet.member?(visible_cols, &1.key)) 175 + |> Enum.map_join(",", &Atom.to_string(&1.key)) 176 + 177 + "?cols=" <> keys 178 + end 179 + end 180 + 181 + defp cols_path(visible_cols), do: "/gardens" <> cols_query_string(visible_cols) 182 + 183 + defp default_cols, do: MapSet.new(for c <- @columns, c.default, do: c.key) 184 + defp lockable_cols, do: MapSet.new(for c <- @columns, c.lockable, do: c.key) 90 185 end
+34 -5
apps/sower/lib/sower_web/live/garden_live/index.html.heex
··· 1 1 <Layouts.app flash={@flash} current_user={@current_user} nav_section={assigns[:nav_section]}> 2 2 <.header> 3 3 Listing Gardens 4 + <:actions> 5 + <details class="relative"> 6 + <summary 7 + class="flex list-none cursor-pointer items-center rounded-lg p-2 text-zinc-900 hover:bg-zinc-200 dark:text-zinc-200 dark:hover:bg-zinc-700" 8 + aria-label="Toggle columns" 9 + title="Columns" 10 + > 11 + <.icon name="hero-view-columns" class="h-5 w-5" /> 12 + </summary> 13 + <ul class="absolute right-0 top-full z-50 mt-2 w-48 rounded-lg border border-zinc-300 bg-zinc-100 p-2 shadow-lg dark:border-zinc-600 dark:bg-zinc-700"> 14 + <li :for={col <- @columns} :if={not col.lockable}> 15 + <label class="flex items-center gap-2 px-2 py-1 text-sm cursor-pointer hover:bg-zinc-200 dark:hover:bg-zinc-600 rounded"> 16 + <input 17 + type="checkbox" 18 + checked={col.key in @visible_cols} 19 + phx-click="toggle_col" 20 + phx-value-col={col.key} 21 + /> 22 + {col.label} 23 + </label> 24 + </li> 25 + </ul> 26 + </details> 27 + </:actions> 4 28 </.header> 5 29 6 30 <.table ··· 8 32 rows={@gardens} 9 33 row_click={fn garden -> JS.navigate(~p"/gardens/#{garden}") end} 10 34 meta={@meta} 11 - path={~p"/gardens"} 35 + path={@cols_path} 12 36 action_hide_on={:sm} 13 37 > 14 - <:col :let={garden} label="Name" field={:name}>{garden.name}</:col> 15 - <:col :let={garden} label="Online"> 38 + <:col :let={garden} :if={:name in @visible_cols} label="Name" field={:name}> 39 + {garden.name} 40 + </:col> 41 + <:col :let={garden} :if={:online in @visible_cols} label="Online"> 16 42 <.online state={garden.sid in Map.keys(@garden_presence)} /> 17 43 </:col> 18 - <:col :let={garden} label="Deploy"> 44 + <:col :let={garden} :if={:deploy in @visible_cols} label="Deploy"> 19 45 <.result result={garden.latest_deployment && garden.latest_deployment.result} /> 46 + </:col> 47 + <:col :let={garden} :if={:version in @visible_cols} label="Version" field={:version}> 48 + {garden.version || "—"} 20 49 </:col> 21 50 <:action :let={garden}> 22 51 <div class="sr-only"> ··· 25 54 </:action> 26 55 </.table> 27 56 28 - <.pagination meta={@meta} path={~p"/gardens"} /> 57 + <.pagination meta={@meta} path={@cols_path} /> 29 58 30 59 <.modal 31 60 :if={@live_action in [:new, :edit]}
+129
apps/sower/test/sower_web/live/garden_live_index_test.exs
··· 1 + defmodule SowerWeb.GardenLive.IndexTest do 2 + use SowerWeb.ConnCase, async: true 3 + 4 + import Phoenix.LiveViewTest 5 + import Sower.OrchestrationFixtures 6 + 7 + alias SowerWeb.GardenLive.Index 8 + 9 + setup [:register_and_log_in_user] 10 + 11 + describe "parse_cols/1" do 12 + test "absent param falls back to defaults" do 13 + assert Index.parse_cols(%{}) == MapSet.new([:name, :online, :deploy]) 14 + end 15 + 16 + test "empty string falls back to defaults" do 17 + assert Index.parse_cols(%{"cols" => ""}) == MapSet.new([:name, :online, :deploy]) 18 + end 19 + 20 + test "all-unknown values fall back to defaults" do 21 + assert Index.parse_cols(%{"cols" => "nope,missing"}) == 22 + MapSet.new([:name, :online, :deploy]) 23 + end 24 + 25 + test "known values are kept" do 26 + assert Index.parse_cols(%{"cols" => "version,deploy"}) == 27 + MapSet.new([:name, :version, :deploy]) 28 + end 29 + 30 + test "unknown values are dropped, known kept" do 31 + assert Index.parse_cols(%{"cols" => "version,whatever"}) == 32 + MapSet.new([:name, :version]) 33 + end 34 + 35 + test "duplicates are deduped" do 36 + assert Index.parse_cols(%{"cols" => "version,version"}) == 37 + MapSet.new([:name, :version]) 38 + end 39 + 40 + test "lockable keys are always included" do 41 + assert :name in Index.parse_cols(%{"cols" => "version"}) 42 + end 43 + end 44 + 45 + describe "cols_query_string/1" do 46 + test "returns empty string for default set" do 47 + assert Index.cols_query_string(MapSet.new([:name, :online, :deploy])) == "" 48 + end 49 + 50 + test "returns cols param preserving @columns order" do 51 + assert Index.cols_query_string(MapSet.new([:version, :name])) == 52 + "?cols=name,version" 53 + end 54 + 55 + test "roundtrips through parse_cols" do 56 + set = MapSet.new([:name, :version]) 57 + "?" <> query = Index.cols_query_string(set) 58 + params = URI.decode_query(query) 59 + assert Index.parse_cols(params) == set 60 + end 61 + end 62 + 63 + describe "live view" do 64 + test "default view hides version column data", %{conn: conn, user: user} do 65 + Sower.Repo.put_org_id(user.org_id) 66 + garden_fixture(%{version: "hidden-default-version"}) 67 + 68 + {:ok, _view, html} = live(conn, ~p"/gardens") 69 + 70 + assert html =~ "Name" 71 + assert html =~ "Online" 72 + assert html =~ "Deploy" 73 + refute html =~ "hidden-default-version" 74 + end 75 + 76 + test "renders Version column when cols includes version", %{conn: conn, user: user} do 77 + Sower.Repo.put_org_id(user.org_id) 78 + garden_fixture(%{version: "1.2.3"}) 79 + 80 + {:ok, _view, html} = live(conn, ~p"/gardens?cols=name,version") 81 + 82 + assert html =~ ~r/>\s*Version\s*</ 83 + assert html =~ "1.2.3" 84 + end 85 + 86 + test "toggle_col adds a column and updates URL", %{conn: conn, user: user} do 87 + Sower.Repo.put_org_id(user.org_id) 88 + garden_fixture(%{version: "9.9.9"}) 89 + 90 + {:ok, view, _html} = live(conn, ~p"/gardens") 91 + 92 + view 93 + |> element("input[phx-value-col=\"version\"]") 94 + |> render_click() 95 + 96 + assert_patch(view) 97 + html = render(view) 98 + assert html =~ ~r/>\s*Version\s*</ 99 + assert html =~ "9.9.9" 100 + end 101 + 102 + test "sort preserved when column hidden then re-shown", %{conn: conn, user: user} do 103 + Sower.Repo.put_org_id(user.org_id) 104 + garden_fixture(%{name: "a", version: "2.0"}) 105 + garden_fixture(%{name: "b", version: "1.0"}) 106 + 107 + {:ok, view, html} = 108 + live(conn, ~p"/gardens?cols=name,version&order_by[]=version&order_directions[]=asc") 109 + 110 + assert html =~ "2.0" 111 + 112 + view 113 + |> element("input[phx-value-col=\"version\"]") 114 + |> render_click() 115 + 116 + hidden_path = assert_patch(view) 117 + assert hidden_path =~ "order_by" 118 + refute render(view) =~ "2.0" 119 + 120 + view 121 + |> element("input[phx-value-col=\"version\"]") 122 + |> render_click() 123 + 124 + shown_path = assert_patch(view) 125 + assert shown_path =~ "order_by" 126 + assert render(view) =~ "2.0" 127 + end 128 + end 129 + end