Give /orgs its own new-org page and rich org cards

7eeae4a · Gabriel Morell · 2026-09-09 21:08

8 files +378 -58
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/organizations.ex
+30 −2
@@ -56,7 +56,9 @@ defmodule GitGud.Organizations do
56 56 new_rank = Organization.visibility_rank(new_visibility)
57 57
58 58 Repo.transaction(fn ->
59 case org |> Organization.visibility_changeset(%{visibility: new_visibility}) |> Repo.update() do
59 + case org
60 + |> Organization.visibility_changeset(%{visibility: new_visibility})
61 + |> Repo.update() do
60 62 {:ok, updated} ->
61 63 # Cascade: every repo with a lower visibility rank than the
62 64 # org bubbles up to match.
@@ -121,7 +123,10 @@ defmodule GitGud.Organizations do
121 123
122 124 {:error, reason} ->
123 125 require Logger
124 Logger.warning("could not create profile repo for org #{org.handle}: #{inspect(reason)}")
126 +
127 + Logger.warning(
128 + "could not create profile repo for org #{org.handle}: #{inspect(reason)}"
129 + )
125 130 end
126 131
127 132 {:ok, org}
@@ -181,6 +186,29 @@ defmodule GitGud.Organizations do
181 186
182 187 def member?(_, _), do: false
183 188
189 + @doc """
190 + The viewer's role in every org they belong to, keyed by org id.
191 +
192 + One query for the whole `/orgs` listing, instead of an
193 + `org_admin?/2` round-trip per card.
194 + """
195 + def member_roles_for(%User{id: uid}) do
196 + from(m in Membership, where: m.user_id == ^uid, select: {m.organization_id, m.role})
197 + |> Repo.all()
198 + |> Map.new()
199 + end
200 +
201 + @doc "Member count per organization, keyed by org id."
202 + def member_counts(org_ids) when is_list(org_ids) do
203 + from(m in Membership,
204 + where: m.organization_id in ^org_ids,
205 + group_by: m.organization_id,
206 + select: {m.organization_id, count(m.id)}
207 + )
208 + |> Repo.all()
209 + |> Map.new()
210 + end
211 +
184 212 @doc """
185 213 List memberships for an org with `:user` preloaded. Ordered by role
186 214 (admins first) then by user handle / email.
modified lib/git_gud/profiles.ex
+18 −1
@@ -34,6 +34,20 @@ defmodule GitGud.Profiles do
34 34 |> Repo.all()
35 35 end
36 36
37 + @doc """
38 + Pins for several organizations at once, keyed by org id and ordered
39 + by `position` within each. Avoids a query per org on the `/orgs`
40 + listing.
41 + """
42 + def list_pins_for_organizations(org_ids) when is_list(org_ids) do
43 + Pin
44 + |> where([p], p.organization_id in ^org_ids)
45 + |> order_by([p], asc: p.position, asc: p.id)
46 + |> preload(repository: [:owner, :organization])
47 + |> Repo.all()
48 + |> Enum.group_by(& &1.organization_id)
49 + end
50 +
37 51 @doc """
38 52 Pin a repository for `owner`. Returns `{:ok, pin}` or
39 53 `{:error, :max_reached | %Ecto.Changeset{}}`.
@@ -162,7 +176,10 @@ defmodule GitGud.Profiles do
162 176
163 177 defp compact_positions(owner_key, owner_id) do
164 178 pins =
165 from(p in Pin, where: field(p, ^owner_key) == ^owner_id, order_by: [asc: p.position, asc: p.id])
179 + from(p in Pin,
180 + where: field(p, ^owner_key) == ^owner_id,
181 + order_by: [asc: p.position, asc: p.id]
182 + )
166 183 |> Repo.all()
167 184
168 185 Repo.transaction(fn ->
modified lib/git_gud/repositories.ex
+17 −0
@@ -79,6 +79,23 @@ defmodule GitGud.Repositories do
79 79 |> Repo.all()
80 80 end
81 81
82 + @doc """
83 + Repository count per organization, keyed by org id.
84 +
85 + Unfiltered by visibility — the only caller is the `/orgs` listing,
86 + which shows orgs the viewer is a member of, and members see every
87 + repo the org owns.
88 + """
89 + def repository_counts_for_orgs(org_ids) when is_list(org_ids) do
90 + from(r in Repository,
91 + where: r.organization_id in ^org_ids,
92 + group_by: r.organization_id,
93 + select: {r.organization_id, count(r.id)}
94 + )
95 + |> Repo.all()
96 + |> Map.new()
97 + end
98 +
82 99 @doc """
83 100 Ids of repos in `org` that `viewer` may see. Mirrors the org page's
84 101 visibility policy:
modified lib/git_gud_web/live/org_live/index.ex
+111 −53
@@ -3,77 +3,135 @@ defmodule GitGudWeb.OrgLive.Index do
3 3
4 4 alias GitGud.Organizations
5 5 alias GitGud.Organizations.Organization
6 + alias GitGud.Profiles
7 + alias GitGud.Repositories
8 +
9 + # Cards show one tidy row of pins; the org page has the rest. Also
10 + # bounds the git work — `PinnedReposGrid` resolves a top language per
11 + # card, which is a ref lookup each.
12 + @pins_per_card 3
6 13
7 14 @impl true
8 15 def mount(_params, _session, socket) do
9 16 user = socket.assigns.current_scope.user
17 + orgs = Organizations.list_organizations_for(user)
18 + ids = Enum.map(orgs, & &1.id)
10 19
11 20 {:ok,
12 21 socket
13 |> stream(:orgs, Organizations.list_organizations_for(user))
14 |> assign_form(Organization.changeset(%Organization{}, %{}))
22 + |> assign(:orgs, orgs)
23 + |> assign(:roles, Organizations.member_roles_for(user))
24 + |> assign(:member_counts, Organizations.member_counts(ids))
25 + |> assign(:repo_counts, Repositories.repository_counts_for_orgs(ids))
26 + |> assign(:pins, Profiles.list_pins_for_organizations(ids))
15 27 |> assign(:page_title, "Organizations")}
16 28 end
17 29
18 defp assign_form(socket, cs), do: assign(socket, :form, to_form(cs))
30 + defp pins_for(pins, org), do: pins |> Map.get(org.id, []) |> Enum.take(@pins_per_card)
19 31
20 @impl true
21 def handle_event("validate", %{"organization" => attrs}, socket) do
22 cs = %Organization{} |> Organization.changeset(attrs) |> Map.put(:action, :validate)
23 {:noreply, assign_form(socket, cs)}
24 end
32 + defp count(counts, org), do: Map.get(counts, org.id, 0)
25 33
26 def handle_event("save", %{"organization" => attrs}, socket) do
27 case Organizations.create_organization(socket.assigns.current_scope.user, attrs) do
28 {:ok, org} ->
29 {:noreply,
30 socket
31 |> stream_insert(:orgs, org, at: 0)
32 |> assign_form(Organization.changeset(%Organization{}, %{}))
33 |> put_flash(:info, "Organization created.")}
34
35 {:error, cs} ->
36 {:noreply, assign_form(socket, cs)}
37 end
38 end
34 + defp pluralize(1, singular, _plural), do: "1 #{singular}"
35 + defp pluralize(n, _singular, plural), do: "#{n} #{plural}"
36 +
37 + # daisyUI badge class for an org's visibility.
38 + defp visibility_badge("public"), do: "badge-ghost"
39 + defp visibility_badge("internal"), do: "badge-info"
40 + defp visibility_badge(_), do: "badge-warning"
39 41
40 42 @impl true
41 43 def render(assigns) do
42 44 ~H"""
43 45 <Layouts.app flash={@flash} current_scope={@current_scope}>
44 <div class="space-y-6 max-w-xl">
45 <h1 class="text-xl font-semibold">Organizations</h1>
46
47 <.form for={@form} id="new-org-form" phx-change="validate" phx-submit="save" class="space-y-3">
48 <.input field={@form[:handle]} label="Handle" required placeholder="acme" />
49 <.input field={@form[:name]} label="Display name" />
50 <.input field={@form[:description]} label="Description" />
51 <.input
52 field={@form[:visibility]}
53 type="select"
54 label="Visibility"
55 options={[
56 {"Public — anyone, no login required", "public"},
57 {"Internal — any signed-in user", "internal"},
58 {"Private — members only", "private"}
59 ]}
60 />
61 <p class="text-xs opacity-70">
62 Repos in this org can't be more open than the org itself.
63 </p>
64 <button type="submit" class="btn btn-sm btn-primary">Create organization</button>
65 </.form>
66
67 <ul id="orgs" phx-update="stream" class="divide-y divide-base-300">
68 <li class="hidden only:block py-6 opacity-60 text-sm">Not in any organizations yet.</li>
69 <li :for={{id, org} <- @streams.orgs} id={id} class="py-3">
70 <.link
71 navigate={~p"/orgs/#{org.handle}"}
72 class="font-mono link link-hover"
46 + <div class="space-y-6">
47 + <header class="flex items-baseline justify-between gap-4">
48 + <div>
49 + <h1 class="text-2xl font-semibold">Organizations</h1>
50 + <p class="text-sm opacity-70 mt-1">Orgs you belong to.</p>
51 + </div>
52 + <.link navigate={~p"/orgs/new"} class="btn btn-sm btn-primary">
53 + New organization
54 + </.link>
55 + </header>
56 +
57 + <p :if={@orgs == []} class="py-12 text-center text-sm opacity-60">
58 + You're not in any organizations yet.
59 + <.link navigate={~p"/orgs/new"} class="link">Create one</.link>
60 + to share repos with a team.
61 + </p>
62 +
63 + <ul class="space-y-4">
64 + <li
65 + :for={org <- @orgs}
66 + id={"org-#{org.id}"}
67 + class="rounded-lg border border-base-300 p-4 space-y-3 transition-colors hover:border-primary/50"
68 + >
69 + <header class="flex items-start gap-4 min-w-0">
70 + <.avatar name={org.handle} size="lg" alt={org.handle} />
71 +
72 + <div class="min-w-0 flex-1">
73 + <div class="flex items-center gap-2 flex-wrap">
74 + <.link
75 + navigate={~p"/orgs/#{org.handle}"}
76 + class="text-lg font-semibold link link-hover truncate"
77 + >
78 + {Organization.display(org)}
79 + </.link>
80 + <span class={["badge badge-xs", visibility_badge(org.visibility)]}>
81 + {org.visibility}
82 + </span>
83 + <span :if={@roles[org.id] == "admin"} class="badge badge-xs badge-outline">
84 + admin
85 + </span>
86 + </div>
87 +
88 + <p class="text-sm font-mono opacity-60">@{org.handle}</p>
89 +
90 + <p :if={org.description} class="text-sm opacity-80 mt-1 line-clamp-2">
91 + {org.description}
92 + </p>
93 +
94 + <p class="text-xs opacity-60 mt-1">
95 + {pluralize(count(@repo_counts, org), "repo", "repos")} · {pluralize(
96 + count(@member_counts, org),
97 + "member",
98 + "members"
99 + )}
100 + </p>
101 + </div>
102 +
103 + <nav class="text-xs flex gap-3 opacity-80 shrink-0">
104 + <.link navigate={~p"/orgs/#{org.handle}/actions"} class="link link-hover">
105 + CI
106 + </.link>
107 + <.link
108 + :if={@roles[org.id] == "admin"}
109 + navigate={~p"/orgs/#{org.handle}/settings/profile"}
110 + class="link link-hover"
111 + >
112 + Settings
113 + </.link>
114 + </nav>
115 + </header>
116 +
117 + <GitGudWeb.PinnedReposGrid.grid
118 + id={"pinned-repos-#{org.id}"}
119 + pins={pins_for(@pins, org)}
120 + />
121 +
122 + <p
123 + :if={pins_for(@pins, org) == [] and @roles[org.id] == "admin"}
124 + class="text-xs opacity-50"
73 125 >
74 {org.handle}
75 </.link>
76 <p :if={org.name} class="text-sm">{org.name}</p>
126 + No pinned repositories.
127 + <.link
128 + navigate={~p"/orgs/#{org.handle}/settings/profile"}
129 + class="link link-hover"
130 + >
131 + Pin a few
132 + </.link>
133 + to show them here.
134 + </p>
77 135 </li>
78 136 </ul>
79 137 </div>
added lib/git_gud_web/live/org_live/new.ex
+83 −0
@@ -0,0 +1,83 @@
1 +defmodule GitGudWeb.OrgLive.New do
2 + use GitGudWeb, :live_view
3 +
4 + alias GitGud.Organizations
5 + alias GitGud.Organizations.Organization
6 +
7 + @impl true
8 + def mount(_params, _session, socket) do
9 + {:ok,
10 + socket
11 + |> assign(:page_title, "New organization")
12 + |> assign_form(Organization.changeset(%Organization{}, %{}))}
13 + end
14 +
15 + defp assign_form(socket, cs), do: assign(socket, :form, to_form(cs))
16 +
17 + @impl true
18 + def handle_event("validate", %{"organization" => attrs}, socket) do
19 + cs = %Organization{} |> Organization.changeset(attrs) |> Map.put(:action, :validate)
20 + {:noreply, assign_form(socket, cs)}
21 + end
22 +
23 + def handle_event("save", %{"organization" => attrs}, socket) do
24 + case Organizations.create_organization(socket.assigns.current_scope.user, attrs) do
25 + {:ok, org} ->
26 + {:noreply,
27 + socket
28 + |> put_flash(:info, "Organization created.")
29 + |> push_navigate(to: ~p"/orgs/#{org.handle}")}
30 +
31 + {:error, cs} ->
32 + {:noreply, assign_form(socket, cs)}
33 + end
34 + end
35 +
36 + @impl true
37 + def render(assigns) do
38 + ~H"""
39 + <Layouts.app flash={@flash} current_scope={@current_scope}>
40 + <div class="space-y-4 max-w-lg">
41 + <div>
42 + <.link navigate={~p"/orgs"} class="text-xs link link-hover opacity-60">
43 + ← Organizations
44 + </.link>
45 + <h1 class="text-2xl font-semibold mt-1">New organization</h1>
46 + <p class="text-sm opacity-70 mt-1">
47 + You'll be its first admin. A profile repo is created alongside it, so the
48 + org page can carry a README.
49 + </p>
50 + </div>
51 +
52 + <.form
53 + for={@form}
54 + id="new-org-form"
55 + phx-change="validate"
56 + phx-submit="save"
57 + class="space-y-3"
58 + >
59 + <.input field={@form[:handle]} label="Handle" required placeholder="acme" />
60 + <.input field={@form[:name]} label="Display name" placeholder="Acme Corp" />
61 + <.input field={@form[:description]} label="Description" />
62 + <.input
63 + field={@form[:visibility]}
64 + type="select"
65 + label="Visibility"
66 + options={[
67 + {"Public — anyone, no login required", "public"},
68 + {"Internal — any signed-in user", "internal"},
69 + {"Private — members only", "private"}
70 + ]}
71 + />
72 + <p class="text-xs opacity-70">
73 + Repos in this org can't be more open than the org itself. Tightening this
74 + later pulls its repos down to match.
75 + </p>
76 +
77 + <button type="submit" class="btn btn-primary">Create organization</button>
78 + </.form>
79 + </div>
80 + </Layouts.app>
81 + """
82 + end
83 +end
modified lib/git_gud_web/live/pinned_repos_grid.ex
+9 −2
@@ -18,14 +18,21 @@ defmodule GitGudWeb.PinnedReposGrid do
18 18 alias GitGud.Repositories
19 19 alias GitGud.Repositories.Storage
20 20
21 attr :pins, :list, required: true, doc: "List of `%GitGud.Profiles.Pin{}` with :repository preloaded"
21 + attr :pins, :list,
22 + required: true,
23 + doc: "List of `%GitGud.Profiles.Pin{}` with :repository preloaded"
24 +
22 25 attr :reorderable?, :boolean, default: false
23 26
27 + attr :id, :string,
28 + default: "pinned-repos-grid",
29 + doc: "Override when more than one grid renders on the same page (the /orgs listing)"
30 +
24 31 def grid(assigns) do
25 32 ~H"""
26 33 <ul
27 34 :if={@pins != []}
28 id="pinned-repos-grid"
35 + id={@id}
29 36 phx-hook={@reorderable? && "PinReorder"}
30 37 class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"
31 38 >
modified lib/git_gud_web/router.ex
+1 −0
@@ -180,6 +180,7 @@ defmodule GitGudWeb.Router do
180 180 live "/users/ci", UserLive.Ci, :index
181 181 live "/r/:owner/:name/fork", RepoLive.Fork, :new
182 182 live "/orgs", OrgLive.Index, :index
183 + live "/orgs/new", OrgLive.New, :new
183 184 end
184 185
185 186 live_session :admin,
added test/git_gud_web/live/org_live/index_test.exs
+109 −0
@@ -0,0 +1,109 @@
1 +defmodule GitGudWeb.OrgLive.IndexTest do
2 + use GitGudWeb.ConnCase, async: false
3 +
4 + import Phoenix.LiveViewTest
5 + import GitGud.AccountsFixtures
6 +
7 + alias GitGud.Organizations
8 + alias GitGud.Profiles
9 + alias GitGud.Repositories
10 +
11 + defp org_with_admin(handle, attrs \\ %{}) do
12 + admin = user_fixture()
13 + {:ok, org} = Organizations.create_organization(admin, Map.merge(%{"handle" => handle}, attrs))
14 + {admin, org}
15 + end
16 +
17 + test "lists the viewer's orgs with counts and role", %{conn: conn} do
18 + {admin, org} =
19 + org_with_admin("idx-counts", %{"name" => "Index Counts", "description" => "Widgets"})
20 +
21 + {:ok, _repo} =
22 + Repositories.create_repository_for_org(org, admin, %{
23 + "name" => "api",
24 + "visibility" => "private"
25 + })
26 +
27 + member = user_fixture()
28 + _ = Organizations.add_member(org, member, "member")
29 +
30 + {:ok, _lv, html} = live(log_in_user(conn, admin), ~p"/orgs")
31 +
32 + assert html =~ "Index Counts"
33 + assert html =~ "@idx-counts"
34 + assert html =~ "Widgets"
35 + assert html =~ "2 members"
36 + # The org's own profile repo counts alongside `api`.
37 + assert html =~ "repos"
38 + assert html =~ "admin"
39 + end
40 +
41 + test "renders an org's pinned repos on its card", %{conn: conn} do
42 + {admin, org} = org_with_admin("idx-pins")
43 +
44 + {:ok, repo} =
45 + Repositories.create_repository_for_org(org, admin, %{
46 + "name" => "pinned-one",
47 + "visibility" => "private"
48 + })
49 +
50 + {:ok, _pin} = Profiles.pin(org, repo)
51 +
52 + {:ok, _lv, html} = live(log_in_user(conn, admin), ~p"/orgs")
53 +
54 + assert html =~ "idx-pins/pinned-one"
55 + assert html =~ ~s(id="pinned-repos-#{org.id}")
56 + end
57 +
58 + test "non-admin members see no settings link and no pin hint", %{conn: conn} do
59 + {_admin, org} = org_with_admin("idx-plain")
60 +
61 + member = user_fixture()
62 + _ = Organizations.add_member(org, member, "member")
63 +
64 + {:ok, _lv, html} = live(log_in_user(conn, member), ~p"/orgs")
65 +
66 + assert html =~ "@idx-plain"
67 + refute html =~ "/orgs/idx-plain/settings/profile"
68 + refute html =~ "No pinned repositories."
69 + end
70 +
71 + test "empty state points at the new-org page", %{conn: conn} do
72 + {:ok, _lv, html} = live(log_in_user(conn, user_fixture()), ~p"/orgs")
73 +
74 + assert html =~ "not in any organizations yet"
75 + assert html =~ ~p"/orgs/new"
76 + end
77 +
78 + test "the create form no longer lives on the listing", %{conn: conn} do
79 + {:ok, _lv, html} = live(log_in_user(conn, user_fixture()), ~p"/orgs")
80 +
81 + refute html =~ ~s(id="new-org-form")
82 + end
83 +
84 + test "/orgs/new creates an org and lands on its page", %{conn: conn} do
85 + {:ok, lv, _html} = live(log_in_user(conn, user_fixture()), ~p"/orgs/new")
86 +
87 + assert {:error, {:live_redirect, %{to: "/orgs/idx-created"}}} =
88 + lv
89 + |> form("#new-org-form", organization: %{handle: "idx-created", name: "Created"})
90 + |> render_submit()
91 + end
92 +
93 + test "/orgs/new surfaces validation errors", %{conn: conn} do
94 + {:ok, lv, _html} = live(log_in_user(conn, user_fixture()), ~p"/orgs/new")
95 +
96 + html =
97 + lv
98 + |> form("#new-org-form", organization: %{handle: "Not A Handle"})
99 + |> render_submit()
100 +
101 + assert html =~ "lowercase letters, digits, dashes"
102 + end
103 +
104 + test "/orgs/new is not swallowed by the :handle route", %{conn: conn} do
105 + {:ok, _lv, html} = live(log_in_user(conn, user_fixture()), ~p"/orgs/new")
106 +
107 + assert html =~ "New organization"
108 + end
109 +end

Parents: 719deae