neiam /gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Packages
⭐
Log in to mark this repository.
Give /orgs its own new-org page and rich org cards
7eeae4a · Gabriel Morell · 2026-09-09 21:08
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