neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Add an org dashboard at /orgs/:handle/dashboard
8041c34 · Gabriel Morell · 2026-09-11 15:44
Message
{commit_body(@commit)}
Files changed
modified
README.md
+1
−0
@@ -30,6 +30,7 @@ reinvent them.
| 30 | 30 | - Per-user editor theme (curated subset of Lumis themes — Dracula, Solarized, Monokai, Tokyo Night, Catppuccin, etc) — separate from the daisyUI page theme |
| 31 | 31 | - **Discovery** |
| 32 | 32 | - **Dashboard at `/`** for signed-in visitors — PRs waiting on your review (asked of you first, maintainer inference as a fallback), your open PRs and issues, CI, pinned and recently-pushed repos, org invitations. Logged-out visitors get the landing page |
| 33 | + - **Org dashboard at `/orgs/:handle/dashboard`** — the same panels narrowed to one organization, and reframed: the landing page is about *your* work, an org's is about the org's, so everyone's open PRs and issues show alongside yours. Scoped by the org's visibility rules, so a panel never surfaces a repo you couldn't have opened yourself | |
| 33 | 34 | - **Topic tags** — up to seven per repo, plus one derived automatically from its primary language and kept current by the stats worker. Browse at `/tags`, filter the repo listing by them |
| 34 | 35 | - **Syms** — stars, but with a configurable set of symbols; instance-wide rankings at `/syms` |
| 35 | 36 | - Repository and organization listings as cards with filter, sort and scope, paginated and deep-linkable (`/repositories/2`, `/orgs/page/2`) |
modified
lib/git_gud/issues.ex
+27
−0
@@ -69,6 +69,33 @@ defmodule GitGud.Issues do
| 69 | 69 | |> Repo.all() |
| 70 | 70 | end |
| 71 | 71 | |
| 72 | + @doc """ | |
| 73 | + Every open issue in `repo_ids`, newest first. | |
| 74 | + | |
| 75 | + Unlike `list_open_authored_by/3` this does no visibility widening of | |
| 76 | + its own — the caller has already decided which repositories the | |
| 77 | + viewer may see, which is what makes it safe to show other people's | |
| 78 | + work rather than only the viewer's. | |
| 79 | + | |
| 80 | + `:author_id` narrows to one author, for a "your work here" panel. | |
| 81 | + """ | |
| 82 | + def list_open_in_repos(repo_ids, opts \\ []) when is_list(repo_ids) do | |
| 83 | + limit = Keyword.get(opts, :limit, 10) | |
| 84 | + | |
| 85 | + from(i in Issue, | |
| 86 | + join: r in assoc(i, :repository), | |
| 87 | + where: i.repository_id in ^repo_ids and i.state == "open", | |
| 88 | + order_by: [desc: i.inserted_at], | |
| 89 | + limit: ^limit, | |
| 90 | + preload: [:author, repository: {r, [:owner, :organization]}] | |
| 91 | + ) | |
| 92 | + |> filter_author(Keyword.get(opts, :author_id)) | |
| 93 | + |> Repo.all() | |
| 94 | + end | |
| 95 | + | |
| 96 | + defp filter_author(query, nil), do: query | |
| 97 | + defp filter_author(query, author_id), do: where(query, [i], i.author_id == ^author_id) | |
| 98 | + | |
| 72 | 99 | defp filter_state(q, "all"), do: q |
| 73 | 100 | |
| 74 | 101 | defp filter_state(q, state) when state in ["open", "closed"], |
modified
lib/git_gud/pull_requests.ex
+37
−0
@@ -1038,6 +1038,10 @@ defmodule GitGud.PullRequests do
| 1038 | 1038 | |
| 1039 | 1039 | Unlike `list_open_awaiting_review/3` this is not inferred from |
| 1040 | 1040 | maintainer status — someone actually asked. |
| 1041 | + | |
| 1042 | + Pass `:repo_ids` to narrow to a set of repositories; the org | |
| 1043 | + dashboard does, so "waiting on you" there means "waiting on you *in | |
| 1044 | + this org*" rather than everywhere. | |
| 1041 | 1045 | """ |
| 1042 | 1046 | def list_open_review_requests(user_id, opts \\ []) when is_integer(user_id) do |
| 1043 | 1047 | limit = Keyword.get(opts, :limit, 10) |
@@ -1051,9 +1055,42 @@ defmodule GitGud.PullRequests do
| 1051 | 1055 | limit: ^limit, |
| 1052 | 1056 | preload: [:author, repository: {r, [:owner, :organization]}] |
| 1053 | 1057 | ) |
| 1058 | + |> scope_to_repos(Keyword.get(opts, :repo_ids)) | |
| 1054 | 1059 | |> Repo.all() |
| 1055 | 1060 | end |
| 1056 | 1061 | |
| 1062 | + @doc """ | |
| 1063 | + Every open PR in `repo_ids`, newest first. | |
| 1064 | + | |
| 1065 | + Unlike `list_open_authored_by/3` this does no visibility widening of | |
| 1066 | + its own — the caller has already decided which repositories the | |
| 1067 | + viewer may see, which is what makes it safe to show other people's | |
| 1068 | + work rather than only the viewer's. | |
| 1069 | + | |
| 1070 | + `:author_id` narrows to one author, for a "your work here" panel. | |
| 1071 | + """ | |
| 1072 | + def list_open_in_repos(repo_ids, opts \\ []) when is_list(repo_ids) do | |
| 1073 | + limit = Keyword.get(opts, :limit, 10) | |
| 1074 | + | |
| 1075 | + from(p in PullRequest, | |
| 1076 | + join: r in assoc(p, :repository), | |
| 1077 | + where: p.repository_id in ^repo_ids and p.state == "open", | |
| 1078 | + order_by: [desc: p.inserted_at], | |
| 1079 | + limit: ^limit, | |
| 1080 | + preload: [:author, repository: {r, [:owner, :organization]}] | |
| 1081 | + ) | |
| 1082 | + |> filter_author(Keyword.get(opts, :author_id)) | |
| 1083 | + |> Repo.all() | |
| 1084 | + end | |
| 1085 | + | |
| 1086 | + defp filter_author(query, nil), do: query | |
| 1087 | + defp filter_author(query, author_id), do: where(query, [p], p.author_id == ^author_id) | |
| 1088 | + | |
| 1089 | + defp scope_to_repos(query, nil), do: query | |
| 1090 | + | |
| 1091 | + defp scope_to_repos(query, repo_ids) when is_list(repo_ids), | |
| 1092 | + do: where(query, [p], p.repository_id in ^repo_ids) | |
| 1093 | + | |
| 1057 | 1094 | @doc """ |
| 1058 | 1095 | Reviewers whose standing verdict is "changes requested". |
| 1059 | 1096 |
added
lib/git_gud_web/components/dashboard_components.ex
+161
−0
@@ -0,0 +1,161 @@
| 1 | +defmodule GitGudWeb.DashboardComponents do | |
| 2 | + @moduledoc """ | |
| 3 | + The panels a dashboard is made of, and the small display helpers that | |
| 4 | + go with them. | |
| 5 | + | |
| 6 | + Two dashboards use these: `GitGudWeb.PageLive.Home` (everything the | |
| 7 | + viewer can reach) and `GitGudWeb.OrgLive.Dashboard` (the same slice, | |
| 8 | + narrowed to one organization). They differ only in what they query; | |
| 9 | + they should never differ in how a row of work looks, which is why | |
| 10 | + this lives here rather than being written twice. | |
| 11 | + """ | |
| 12 | + use GitGudWeb, :html | |
| 13 | + | |
| 14 | + alias GitGud.Repositories.Storage | |
| 15 | + | |
| 16 | + @doc """ | |
| 17 | + A titled, counted list panel. Renders the empty-state copy instead of | |
| 18 | + the list when `count` is zero, so callers don't each re-invent "or | |
| 19 | + else say nothing's here". | |
| 20 | + """ | |
| 21 | + attr :title, :string, required: true | |
| 22 | + attr :hint, :string, default: nil | |
| 23 | + attr :empty, :string, required: true | |
| 24 | + attr :count, :integer, required: true | |
| 25 | + attr :more_label, :string, default: nil | |
| 26 | + attr :more_path, :string, default: nil | |
| 27 | + slot :inner_block, required: true | |
| 28 | + | |
| 29 | + def panel(assigns) do | |
| 30 | + ~H""" | |
| 31 | + <section class="rounded-lg border border-base-300 p-4"> | |
| 32 | + <header class="flex items-baseline justify-between gap-2 mb-2"> | |
| 33 | + <div class="min-w-0"> | |
| 34 | + <h2 class="font-semibold"> | |
| 35 | + {@title} | |
| 36 | + <span :if={@count > 0} class="badge badge-sm badge-ghost ml-1">{@count}</span> | |
| 37 | + </h2> | |
| 38 | + <p :if={@hint} class="text-xs opacity-60">{@hint}</p> | |
| 39 | + </div> | |
| 40 | + <.link | |
| 41 | + :if={@more_path} | |
| 42 | + navigate={@more_path} | |
| 43 | + class="text-xs link link-hover opacity-60 shrink-0" | |
| 44 | + > | |
| 45 | + {@more_label} → | |
| 46 | + </.link> | |
| 47 | + </header> | |
| 48 | + | |
| 49 | + <p :if={@count == 0} class="py-4 text-sm opacity-50">{@empty}</p> | |
| 50 | + | |
| 51 | + <ul :if={@count > 0} class="divide-y divide-base-300"> | |
| 52 | + {render_slot(@inner_block)} | |
| 53 | + </ul> | |
| 54 | + </section> | |
| 55 | + """ | |
| 56 | + end | |
| 57 | + | |
| 58 | + @doc "One issue or pull request, as a row inside a `panel/1`." | |
| 59 | + attr :path, :string, required: true | |
| 60 | + attr :repo, :string, required: true | |
| 61 | + attr :number, :integer, required: true | |
| 62 | + attr :title, :string, required: true | |
| 63 | + attr :meta, :string, required: true | |
| 64 | + | |
| 65 | + def work_row(assigns) do | |
| 66 | + ~H""" | |
| 67 | + <li class="py-2 min-w-0"> | |
| 68 | + <.link navigate={@path} class="text-sm link link-hover line-clamp-1">{@title}</.link> | |
| 69 | + <p class="text-xs opacity-60 mt-0.5 truncate"> | |
| 70 | + <span class="font-mono">{@repo}#{@number}</span> · {@meta} | |
| 71 | + </p> | |
| 72 | + </li> | |
| 73 | + """ | |
| 74 | + end | |
| 75 | + | |
| 76 | + @doc "One workflow run, as a row inside a `panel/1`." | |
| 77 | + attr :run, :map, required: true | |
| 78 | + | |
| 79 | + def ci_row(assigns) do | |
| 80 | + ~H""" | |
| 81 | + <li class="py-2 flex items-baseline gap-2 min-w-0"> | |
| 82 | + <span class={["badge badge-xs shrink-0", ci_badge(@run.status)]}>{@run.status}</span> | |
| 83 | + <.link | |
| 84 | + navigate={ | |
| 85 | + ~p"/r/#{repo_handle(@run.repository)}/#{@run.repository.name}/actions/#{@run.number}" | |
| 86 | + } | |
| 87 | + class="text-sm link link-hover truncate" | |
| 88 | + > | |
| 89 | + {@run.workflow_name || "run ##{@run.number}"} | |
| 90 | + </.link> | |
| 91 | + <span class="text-xs opacity-60 font-mono truncate">{repo_path(@run.repository)}</span> | |
| 92 | + <span class="text-xs opacity-50 ml-auto shrink-0">{ago(@run.inserted_at)}</span> | |
| 93 | + </li> | |
| 94 | + """ | |
| 95 | + end | |
| 96 | + | |
| 97 | + @doc "One repository, as a row in a recently-pushed list." | |
| 98 | + attr :repo, :map, required: true | |
| 99 | + | |
| 100 | + def repo_row(assigns) do | |
| 101 | + ~H""" | |
| 102 | + <li class="py-2 flex items-baseline gap-2 min-w-0"> | |
| 103 | + <.link | |
| 104 | + navigate={~p"/r/#{repo_handle(@repo)}/#{@repo.name}"} | |
| 105 | + class="font-mono text-sm link link-hover truncate" | |
| 106 | + > | |
| 107 | + {repo_path(@repo)} | |
| 108 | + </.link> | |
| 109 | + <span :if={@repo.description} class="text-xs opacity-60 truncate"> | |
| 110 | + {@repo.description} | |
| 111 | + </span> | |
| 112 | + <span class="text-xs opacity-50 ml-auto shrink-0">{ago(@repo.pushed_at)}</span> | |
| 113 | + </li> | |
| 114 | + """ | |
| 115 | + end | |
| 116 | + | |
| 117 | + # ── display helpers ────────────────────────────────────────────────── | |
| 118 | + | |
| 119 | + @doc "The namespace handle a repository lives under." | |
| 120 | + def repo_handle(%{organization: %{handle: h}}) when is_binary(h), do: h | |
| 121 | + def repo_handle(%{owner: user}), do: Storage.owner_handle(user) | |
| 122 | + | |
| 123 | + @doc "`owner/name`, for the monospace crumbs on every row." | |
| 124 | + def repo_path(repo), do: "#{repo_handle(repo)}/#{repo.name}" | |
| 125 | + | |
| 126 | + @doc "daisyUI badge class for a workflow run status." | |
| 127 | + def ci_badge("success"), do: "badge-success" | |
| 128 | + def ci_badge("failure"), do: "badge-error" | |
| 129 | + def ci_badge(s) when s in ~w(running queued blocked), do: "badge-warning" | |
| 130 | + def ci_badge(_), do: "badge-ghost" | |
| 131 | + | |
| 132 | + @doc """ | |
| 133 | + Who opened something. Federated rows carry a `source_actor` and no | |
| 134 | + local author, so this deliberately doesn't assume one. | |
| 135 | + """ | |
| 136 | + def author_name(%{author: %{handle: h}}) when is_binary(h), do: h | |
| 137 | + def author_name(_), do: "someone" | |
| 138 | + | |
| 139 | + @doc "Coarse relative time. Precision past a day isn't worth a row of chrome." | |
| 140 | + def ago(nil), do: "" | |
| 141 | + | |
| 142 | + def ago(%DateTime{} = dt) do | |
| 143 | + case DateTime.diff(DateTime.utc_now(), dt) do | |
| 144 | + s when s < 60 -> "just now" | |
| 145 | + s when s < 3600 -> "#{div(s, 60)}m ago" | |
| 146 | + s when s < 86_400 -> "#{div(s, 3600)}h ago" | |
| 147 | + s when s < 2_592_000 -> "#{div(s, 86_400)}d ago" | |
| 148 | + s when s < 31_536_000 -> "#{div(s, 2_592_000)}mo ago" | |
| 149 | + s -> "#{div(s, 31_536_000)}y ago" | |
| 150 | + end | |
| 151 | + end | |
| 152 | + | |
| 153 | + @doc """ | |
| 154 | + What to call the review panel's inference. | |
| 155 | + | |
| 156 | + Once someone has actually been asked, say so; otherwise the panel is | |
| 157 | + still guessing from maintainer status and should admit it. | |
| 158 | + """ | |
| 159 | + def review_hint([]), do: "Open in repos you maintain, opened by someone else" | |
| 160 | + def review_hint(_requested), do: "Requested of you, plus repos you maintain" | |
| 161 | +end |
added
lib/git_gud_web/live/org_live/dashboard.ex
+288
−0
@@ -0,0 +1,288 @@
| 1 | +defmodule GitGudWeb.OrgLive.Dashboard do | |
| 2 | + @moduledoc """ | |
| 3 | + `/orgs/:handle/dashboard` — the signed-in dashboard, narrowed to one | |
| 4 | + organization. | |
| 5 | + | |
| 6 | + Same panels as `GitGudWeb.PageLive.Home`, same components, one | |
| 7 | + difference in framing: the landing dashboard is about *you*, so it | |
| 8 | + only ever shows work you opened. An org dashboard is about the | |
| 9 | + organization, so it shows everyone's open work alongside yours — | |
| 10 | + otherwise it would be the landing page with fewer rows on it, which | |
| 11 | + is not worth a route. | |
| 12 | + | |
| 13 | + Scoped by `Repositories.visible_repo_ids_for_org/2`, so every panel | |
| 14 | + inherits the org's visibility rules and a member of a private org | |
| 15 | + sees what they may see and nothing else. Non-members can reach a | |
| 16 | + public org's dashboard; everything on it is already public. | |
| 17 | + """ | |
| 18 | + | |
| 19 | + use GitGudWeb, :live_view | |
| 20 | + | |
| 21 | + import GitGudWeb.DashboardComponents | |
| 22 | + | |
| 23 | + alias GitGud.Issues | |
| 24 | + alias GitGud.Organizations | |
| 25 | + alias GitGud.Organizations.Organization | |
| 26 | + alias GitGud.Profiles | |
| 27 | + alias GitGud.PullRequests | |
| 28 | + alias GitGud.Repositories | |
| 29 | + alias GitGud.Workflows | |
| 30 | + | |
| 31 | + @panel_limit 6 | |
| 32 | + | |
| 33 | + @impl true | |
| 34 | + def mount(%{"handle" => handle}, _session, socket) do | |
| 35 | + org = Organizations.get_organization_by_handle!(handle) | |
| 36 | + user = socket.assigns.current_scope.user | |
| 37 | + | |
| 38 | + if can_view?(org, user) do | |
| 39 | + if connected?(socket), | |
| 40 | + do: Phoenix.PubSub.subscribe(GitGud.PubSub, Workflows.ci_topic()) | |
| 41 | + | |
| 42 | + {:ok, | |
| 43 | + socket | |
| 44 | + |> assign(:org, org) | |
| 45 | + |> assign(:user, user) | |
| 46 | + |> assign(:is_admin?, Organizations.org_admin?(org, user)) | |
| 47 | + |> assign(:repo_ids, Repositories.visible_repo_ids_for_org(org, user)) | |
| 48 | + |> assign(:pins, Profiles.list_pins(org)) | |
| 49 | + |> assign(:page_title, "Dashboard — @#{org.handle}") | |
| 50 | + |> load_work() | |
| 51 | + |> load_repos() | |
| 52 | + |> load_ci()} | |
| 53 | + else | |
| 54 | + {:ok, | |
| 55 | + socket | |
| 56 | + |> put_flash(:error, "You're not authorized to view this organization.") | |
| 57 | + |> push_navigate(to: ~p"/")} | |
| 58 | + end | |
| 59 | + end | |
| 60 | + | |
| 61 | + # Mirrors `GitGudWeb.OrgLive.Actions` — the two org-wide views should | |
| 62 | + # never disagree about who may look. | |
| 63 | + defp can_view?(%{visibility: "public"}, _user), do: true | |
| 64 | + defp can_view?(%{visibility: "internal"}, %{}), do: true | |
| 65 | + defp can_view?(%{visibility: "private"} = org, %{} = user), do: Organizations.member?(org, user) | |
| 66 | + defp can_view?(_, _), do: false | |
| 67 | + | |
| 68 | + defp load_work(socket) do | |
| 69 | + %{user: user, repo_ids: ids} = socket.assigns | |
| 70 | + | |
| 71 | + # `maintained ∩ org` rather than all maintained: the point of this | |
| 72 | + # page is that everything on it is about this organization. | |
| 73 | + maintained = | |
| 74 | + user | |
| 75 | + |> Repositories.maintained_repo_ids_for_user() | |
| 76 | + |> intersect(ids) | |
| 77 | + | |
| 78 | + socket | |
| 79 | + |> assign( | |
| 80 | + :requested_reviews, | |
| 81 | + PullRequests.list_open_review_requests(user.id, repo_ids: ids, limit: @panel_limit) | |
| 82 | + ) | |
| 83 | + |> assign( | |
| 84 | + :review_queue, | |
| 85 | + PullRequests.list_open_awaiting_review(user.id, maintained, limit: @panel_limit) | |
| 86 | + ) | |
| 87 | + |> assign( | |
| 88 | + :my_prs, | |
| 89 | + PullRequests.list_open_in_repos(ids, author_id: user.id, limit: @panel_limit) | |
| 90 | + ) | |
| 91 | + |> assign(:my_issues, Issues.list_open_in_repos(ids, author_id: user.id, limit: @panel_limit)) | |
| 92 | + |> assign(:open_prs, PullRequests.list_open_in_repos(ids, limit: @panel_limit)) | |
| 93 | + |> assign(:open_issues, Issues.list_open_in_repos(ids, limit: @panel_limit)) | |
| 94 | + end | |
| 95 | + | |
| 96 | + defp intersect(a, b) do | |
| 97 | + set = MapSet.new(b) | |
| 98 | + Enum.filter(a, &MapSet.member?(set, &1)) | |
| 99 | + end | |
| 100 | + | |
| 101 | + defp load_repos(socket) do | |
| 102 | + assign( | |
| 103 | + socket, | |
| 104 | + :recent_repos, | |
| 105 | + Repositories.list_recently_pushed(socket.assigns.repo_ids, limit: @panel_limit) | |
| 106 | + ) | |
| 107 | + end | |
| 108 | + | |
| 109 | + defp load_ci(socket) do | |
| 110 | + ids = socket.assigns.repo_ids | |
| 111 | + | |
| 112 | + socket | |
| 113 | + |> assign( | |
| 114 | + :active_runs, | |
| 115 | + Workflows.list_runs_for_repos(ids, statuses: Workflows.active_statuses()) | |
| 116 | + ) | |
| 117 | + |> assign(:recent_runs, Workflows.list_runs_for_repos(ids, limit: @panel_limit)) | |
| 118 | + end | |
| 119 | + | |
| 120 | + @impl true | |
| 121 | + def handle_info({:ci_run_changed, _run_id}, socket), do: {:noreply, load_ci(socket)} | |
| 122 | + def handle_info(_msg, socket), do: {:noreply, socket} | |
| 123 | + | |
| 124 | + @impl true | |
| 125 | + def render(assigns) do | |
| 126 | + ~H""" | |
| 127 | + <Layouts.app flash={@flash} current_scope={@current_scope}> | |
| 128 | + <div class="space-y-6 pt-4"> | |
| 129 | + <header class="flex items-baseline justify-between gap-4 flex-wrap"> | |
| 130 | + <div class="flex items-center gap-3 min-w-0"> | |
| 131 | + <.avatar name={@org.handle} size="md" alt={@org.handle} /> | |
| 132 | + <div class="min-w-0"> | |
| 133 | + <h1 class="text-2xl font-semibold truncate"> | |
| 134 | + {Organization.display(@org)} | |
| 135 | + </h1> | |
| 136 | + <nav class="text-xs flex gap-3 opacity-80 mt-1"> | |
| 137 | + <.link navigate={~p"/orgs/#{@org.handle}"} class="link link-hover">Overview</.link> | |
| 138 | + <.link navigate={~p"/orgs/#{@org.handle}/actions"} class="link link-hover">CI</.link> | |
| 139 | + <.link | |
| 140 | + :if={@is_admin?} | |
| 141 | + navigate={~p"/orgs/#{@org.handle}/settings/members"} | |
| 142 | + class="link link-hover" | |
| 143 | + > | |
| 144 | + Members | |
| 145 | + </.link> | |
| 146 | + </nav> | |
| 147 | + </div> | |
| 148 | + </div> | |
| 149 | + <div class="flex items-center gap-2 shrink-0"> | |
| 150 | + <.link | |
| 151 | + :if={@is_admin?} | |
| 152 | + id="org-new-repo" | |
| 153 | + navigate={~p"/repositories/new"} | |
| 154 | + class="btn btn-sm btn-primary" | |
| 155 | + > | |
| 156 | + <.icon name="hero-plus-micro" class="size-4" /> New repository | |
| 157 | + </.link> | |
| 158 | + <.link | |
| 159 | + navigate={~p"/orgs/#{@org.handle}"} | |
| 160 | + class="btn btn-sm btn-ghost border border-base-300" | |
| 161 | + > | |
| 162 | + Overview | |
| 163 | + </.link> | |
| 164 | + </div> | |
| 165 | + </header> | |
| 166 | + | |
| 167 | + <div class="grid lg:grid-cols-2 gap-4"> | |
| 168 | + <.panel | |
| 169 | + title="Needs your review" | |
| 170 | + hint={review_hint(@requested_reviews)} | |
| 171 | + empty="Nothing waiting on you here." | |
| 172 | + count={length(@requested_reviews) + length(@review_queue)} | |
| 173 | + > | |
| 174 | + <.work_row | |
| 175 | + :for={pr <- @requested_reviews} | |
| 176 | + path={~p"/r/#{repo_handle(pr.repository)}/#{pr.repository.name}/pulls/#{pr.number}"} | |
| 177 | + repo={repo_path(pr.repository)} | |
| 178 | + number={pr.number} | |
| 179 | + title={pr.title} | |
| 180 | + meta={"you were asked to review · #{ago(pr.inserted_at)}"} | |
| 181 | + /> | |
| 182 | + <.work_row | |
| 183 | + :for={pr <- @review_queue} | |
| 184 | + path={~p"/r/#{repo_handle(pr.repository)}/#{pr.repository.name}/pulls/#{pr.number}"} | |
| 185 | + repo={repo_path(pr.repository)} | |
| 186 | + number={pr.number} | |
| 187 | + title={pr.title} | |
| 188 | + meta={"by #{author_name(pr)} · #{ago(pr.inserted_at)}"} | |
| 189 | + /> | |
| 190 | + </.panel> | |
| 191 | + | |
| 192 | + <.panel | |
| 193 | + title="Your work here" | |
| 194 | + hint="Open PRs and issues you opened in this organization" | |
| 195 | + empty="Nothing open from you here." | |
| 196 | + count={length(@my_prs) + length(@my_issues)} | |
| 197 | + > | |
| 198 | + <.work_row | |
| 199 | + :for={pr <- @my_prs} | |
| 200 | + path={~p"/r/#{repo_handle(pr.repository)}/#{pr.repository.name}/pulls/#{pr.number}"} | |
| 201 | + repo={repo_path(pr.repository)} | |
| 202 | + number={pr.number} | |
| 203 | + title={pr.title} | |
| 204 | + meta={"pull request · #{ago(pr.inserted_at)}"} | |
| 205 | + /> | |
| 206 | + <.work_row | |
| 207 | + :for={issue <- @my_issues} | |
| 208 | + path={ | |
| 209 | + ~p"/r/#{repo_handle(issue.repository)}/#{issue.repository.name}/issues/#{issue.number}" | |
| 210 | + } | |
| 211 | + repo={repo_path(issue.repository)} | |
| 212 | + number={issue.number} | |
| 213 | + title={issue.title} | |
| 214 | + meta={"issue · #{ago(issue.inserted_at)}"} | |
| 215 | + /> | |
| 216 | + </.panel> | |
| 217 | + | |
| 218 | + <.panel | |
| 219 | + title="Open pull requests" | |
| 220 | + hint="Everyone's, across this organization" | |
| 221 | + empty="No open pull requests." | |
| 222 | + count={length(@open_prs)} | |
| 223 | + > | |
| 224 | + <.work_row | |
| 225 | + :for={pr <- @open_prs} | |
| 226 | + path={~p"/r/#{repo_handle(pr.repository)}/#{pr.repository.name}/pulls/#{pr.number}"} | |
| 227 | + repo={repo_path(pr.repository)} | |
| 228 | + number={pr.number} | |
| 229 | + title={pr.title} | |
| 230 | + meta={"by #{author_name(pr)} · #{ago(pr.inserted_at)}"} | |
| 231 | + /> | |
| 232 | + </.panel> | |
| 233 | + | |
| 234 | + <.panel | |
| 235 | + title="Open issues" | |
| 236 | + hint="Everyone's, across this organization" | |
| 237 | + empty="No open issues." | |
| 238 | + count={length(@open_issues)} | |
| 239 | + > | |
| 240 | + <.work_row | |
| 241 | + :for={issue <- @open_issues} | |
| 242 | + path={ | |
| 243 | + ~p"/r/#{repo_handle(issue.repository)}/#{issue.repository.name}/issues/#{issue.number}" | |
| 244 | + } | |
| 245 | + repo={repo_path(issue.repository)} | |
| 246 | + number={issue.number} | |
| 247 | + title={issue.title} | |
| 248 | + meta={"by #{author_name(issue)} · #{ago(issue.inserted_at)}"} | |
| 249 | + /> | |
| 250 | + </.panel> | |
| 251 | + | |
| 252 | + <.panel | |
| 253 | + title="CI" | |
| 254 | + hint={ | |
| 255 | + if @active_runs == [], | |
| 256 | + do: "Latest runs across this organization", | |
| 257 | + else: "#{length(@active_runs)} running now" | |
| 258 | + } | |
| 259 | + empty="No runs yet." | |
| 260 | + count={length(@recent_runs)} | |
| 261 | + more_label="CI dashboard" | |
| 262 | + more_path={~p"/orgs/#{@org.handle}/actions"} | |
| 263 | + > | |
| 264 | + <.ci_row :for={run <- @recent_runs} run={run} /> | |
| 265 | + </.panel> | |
| 266 | + </div> | |
| 267 | + | |
| 268 | + <section :if={@pins != []}> | |
| 269 | + <h2 class="text-lg font-semibold mb-2">Pinned</h2> | |
| 270 | + <GitGudWeb.PinnedReposGrid.grid pins={@pins} /> | |
| 271 | + </section> | |
| 272 | + | |
| 273 | + <section :if={@recent_repos != []}> | |
| 274 | + <header class="flex items-baseline justify-between mb-2"> | |
| 275 | + <h2 class="text-lg font-semibold">Recently pushed</h2> | |
| 276 | + <.link navigate={~p"/orgs/#{@org.handle}"} class="text-xs link link-hover opacity-60"> | |
| 277 | + All repositories → | |
| 278 | + </.link> | |
| 279 | + </header> | |
| 280 | + <ul class="divide-y divide-base-300"> | |
| 281 | + <.repo_row :for={repo <- @recent_repos} repo={repo} /> | |
| 282 | + </ul> | |
| 283 | + </section> | |
| 284 | + </div> | |
| 285 | + </Layouts.app> | |
| 286 | + """ | |
| 287 | + end | |
| 288 | +end |
modified
lib/git_gud_web/live/org_live/show.ex
+7
−0
@@ -160,6 +160,13 @@ defmodule GitGudWeb.OrgLive.Show do
| 160 | 160 | </.link> |
| 161 | 161 | </nav> |
| 162 | 162 | <nav class="text-xs flex gap-3 opacity-80 mt-2"> |
| 163 | + <.link | |
| 164 | + :if={@current_scope && @current_scope.user} | |
| 165 | + navigate={~p"/orgs/#{@org.handle}/dashboard"} | |
| 166 | + class="link link-hover" | |
| 167 | + > | |
| 168 | + Dashboard | |
| 169 | + </.link> | |
| 163 | 170 | <.link navigate={~p"/orgs/#{@org.handle}/actions"} class="link link-hover"> |
| 164 | 171 | CI dashboard |
| 165 | 172 | </.link> |
modified
lib/git_gud_web/live/page_live/home.ex
+6
−111
@@ -14,6 +14,7 @@ defmodule GitGudWeb.PageLive.Home do
| 14 | 14 | |
| 15 | 15 | use GitGudWeb, :live_view |
| 16 | 16 | |
| 17 | + import GitGudWeb.DashboardComponents | |
| 17 | 18 | import GitGudWeb.LandingComponents |
| 18 | 19 | |
| 19 | 20 | alias GitGud.Issues |
@@ -22,7 +23,6 @@ defmodule GitGudWeb.PageLive.Home do
| 22 | 23 | alias GitGud.Profiles |
| 23 | 24 | alias GitGud.PullRequests |
| 24 | 25 | alias GitGud.Repositories |
| 25 | − alias GitGud.Repositories.Storage | |
| 26 | 26 | alias GitGud.Workflows |
| 27 | 27 | |
| 28 | 28 | @panel_limit 6 |
@@ -138,11 +138,9 @@ defmodule GitGudWeb.PageLive.Home do
| 138 | 138 | def handle_info(_msg, socket), do: {:noreply, socket} |
| 139 | 139 | |
| 140 | 140 | # ── view helpers ───────────────────────────────────────────────────── |
| 141 | − | |
| 142 | − # Once someone has actually been asked, say so; otherwise the panel is | |
| 143 | − # still inferring from maintainer status and should admit it. | |
| 144 | − defp review_hint([]), do: "Open in repos you maintain, opened by someone else" | |
| 145 | − defp review_hint(_requested), do: "Requested of you, plus repos you maintain" | |
| 141 | + # | |
| 142 | + # The rest — panels, rows, `ago/1`, `ci_badge/1` — live in | |
| 143 | + # `GitGudWeb.DashboardComponents`, shared with the org dashboard. | |
| 146 | 144 | |
| 147 | 145 | defp inviter_name(%{invited_by: %{handle: h}}) when is_binary(h), do: h |
| 148 | 146 | defp inviter_name(_), do: "An admin" |
@@ -150,32 +148,6 @@ defmodule GitGudWeb.PageLive.Home do
| 150 | 148 | defp signed_in?(%{user: %{}}), do: true |
| 151 | 149 | defp signed_in?(_), do: false |
| 152 | 150 | |
| 153 | − defp repo_handle(%{organization: %{handle: h}}) when is_binary(h), do: h | |
| 154 | − defp repo_handle(%{owner: user}), do: Storage.owner_handle(user) | |
| 155 | − | |
| 156 | − defp repo_path(repo), do: "#{repo_handle(repo)}/#{repo.name}" | |
| 157 | − | |
| 158 | − defp ci_badge("success"), do: "badge-success" | |
| 159 | − defp ci_badge("failure"), do: "badge-error" | |
| 160 | − defp ci_badge(s) when s in ~w(running queued blocked), do: "badge-warning" | |
| 161 | − defp ci_badge(_), do: "badge-ghost" | |
| 162 | − | |
| 163 | − defp author_name(%{author: %{handle: h}}) when is_binary(h), do: h | |
| 164 | − defp author_name(_), do: "someone" | |
| 165 | − | |
| 166 | − defp ago(nil), do: "" | |
| 167 | − | |
| 168 | − defp ago(%DateTime{} = dt) do | |
| 169 | − case DateTime.diff(DateTime.utc_now(), dt) do | |
| 170 | − s when s < 60 -> "just now" | |
| 171 | − s when s < 3600 -> "#{div(s, 60)}m ago" | |
| 172 | − s when s < 86_400 -> "#{div(s, 3600)}h ago" | |
| 173 | − s when s < 2_592_000 -> "#{div(s, 86_400)}d ago" | |
| 174 | − s when s < 31_536_000 -> "#{div(s, 2_592_000)}mo ago" | |
| 175 | − s -> "#{div(s, 31_536_000)}y ago" | |
| 176 | − end | |
| 177 | − end | |
| 178 | − | |
| 179 | 151 | @impl true |
| 180 | 152 | def render(assigns) do |
| 181 | 153 | ~H""" |
@@ -345,19 +317,7 @@ defmodule GitGudWeb.PageLive.Home do
| 345 | 317 | more_label="My CI" |
| 346 | 318 | more_path={~p"/users/ci"} |
| 347 | 319 | > |
| 348 | − <li :for={run <- @recent_runs} class="py-2 flex items-baseline gap-2 min-w-0"> | |
| 349 | − <span class={["badge badge-xs shrink-0", ci_badge(run.status)]}>{run.status}</span> | |
| 350 | − <.link | |
| 351 | − navigate={ | |
| 352 | − ~p"/r/#{repo_handle(run.repository)}/#{run.repository.name}/actions/#{run.number}" | |
| 353 | − } | |
| 354 | − class="text-sm link link-hover truncate" | |
| 355 | − > | |
| 356 | − {run.workflow_name || "run ##{run.number}"} | |
| 357 | − </.link> | |
| 358 | − <span class="text-xs opacity-60 font-mono truncate">{repo_path(run.repository)}</span> | |
| 359 | − <span class="text-xs opacity-50 ml-auto shrink-0">{ago(run.inserted_at)}</span> | |
| 360 | − </li> | |
| 320 | + <.ci_row :for={run <- @recent_runs} run={run} /> | |
| 361 | 321 | </.panel> |
| 362 | 322 | </div> |
| 363 | 323 |
@@ -374,18 +334,7 @@ defmodule GitGudWeb.PageLive.Home do
| 374 | 334 | </.link> |
| 375 | 335 | </header> |
| 376 | 336 | <ul class="divide-y divide-base-300"> |
| 377 | − <li :for={repo <- @recent_repos} class="py-2 flex items-baseline gap-2 min-w-0"> | |
| 378 | − <.link | |
| 379 | − navigate={~p"/r/#{repo_handle(repo)}/#{repo.name}"} | |
| 380 | − class="font-mono text-sm link link-hover truncate" | |
| 381 | − > | |
| 382 | − {repo_path(repo)} | |
| 383 | − </.link> | |
| 384 | − <span :if={repo.description} class="text-xs opacity-60 truncate"> | |
| 385 | − {repo.description} | |
| 386 | − </span> | |
| 387 | − <span class="text-xs opacity-50 ml-auto shrink-0">{ago(repo.pushed_at)}</span> | |
| 388 | − </li> | |
| 337 | + <.repo_row :for={repo <- @recent_repos} repo={repo} /> | |
| 389 | 338 | </ul> |
| 390 | 339 | </section> |
| 391 | 340 |
@@ -412,58 +361,4 @@ defmodule GitGudWeb.PageLive.Home do
| 412 | 361 | </div> |
| 413 | 362 | """ |
| 414 | 363 | end |
| 415 | − | |
| 416 | − attr :title, :string, required: true | |
| 417 | − attr :hint, :string, default: nil | |
| 418 | − attr :empty, :string, required: true | |
| 419 | − attr :count, :integer, required: true | |
| 420 | − attr :more_label, :string, default: nil | |
| 421 | − attr :more_path, :string, default: nil | |
| 422 | − slot :inner_block, required: true | |
| 423 | − | |
| 424 | − defp panel(assigns) do | |
| 425 | − ~H""" | |
| 426 | − <section class="rounded-lg border border-base-300 p-4"> | |
| 427 | − <header class="flex items-baseline justify-between gap-2 mb-2"> | |
| 428 | − <div class="min-w-0"> | |
| 429 | − <h2 class="font-semibold"> | |
| 430 | − {@title} | |
| 431 | − <span :if={@count > 0} class="badge badge-sm badge-ghost ml-1">{@count}</span> | |
| 432 | − </h2> | |
| 433 | − <p :if={@hint} class="text-xs opacity-60">{@hint}</p> | |
| 434 | − </div> | |
| 435 | − <.link | |
| 436 | − :if={@more_path} | |
| 437 | − navigate={@more_path} | |
| 438 | − class="text-xs link link-hover opacity-60 shrink-0" | |
| 439 | − > | |
| 440 | − {@more_label} → | |
| 441 | − </.link> | |
| 442 | − </header> | |
| 443 | − | |
| 444 | − <p :if={@count == 0} class="py-4 text-sm opacity-50">{@empty}</p> | |
| 445 | − | |
| 446 | − <ul :if={@count > 0} class="divide-y divide-base-300"> | |
| 447 | − {render_slot(@inner_block)} | |
| 448 | − </ul> | |
| 449 | − </section> | |
| 450 | − """ | |
| 451 | − end | |
| 452 | − | |
| 453 | − attr :path, :string, required: true | |
| 454 | − attr :repo, :string, required: true | |
| 455 | − attr :number, :integer, required: true | |
| 456 | − attr :title, :string, required: true | |
| 457 | − attr :meta, :string, required: true | |
| 458 | − | |
| 459 | − defp work_row(assigns) do | |
| 460 | − ~H""" | |
| 461 | − <li class="py-2 min-w-0"> | |
| 462 | − <.link navigate={@path} class="text-sm link link-hover line-clamp-1">{@title}</.link> | |
| 463 | − <p class="text-xs opacity-60 mt-0.5 truncate"> | |
| 464 | − <span class="font-mono">{@repo}#{@number}</span> · {@meta} | |
| 465 | − </p> | |
| 466 | − </li> | |
| 467 | − """ | |
| 468 | − end | |
| 469 | 364 | end |
modified
lib/git_gud_web/router.ex
+1
−0
@@ -195,6 +195,7 @@ defmodule GitGudWeb.Router do
| 195 | 195 | live "/r/:owner/:name/settings/runners", RepoLive.SettingsRunners, :index |
| 196 | 196 | live "/r/:owner/:name/settings/moderation", RepoLive.Moderation, :index |
| 197 | 197 | live "/r/:owner/:name/settings", RepoLive.Settings, :edit |
| 198 | + live "/orgs/:handle/dashboard", OrgLive.Dashboard, :index | |
| 198 | 199 | live "/orgs/:handle/settings/profile", OrgLive.SettingsProfile, :index |
| 199 | 200 | live "/orgs/:handle/settings/members", OrgLive.Members, :index |
| 200 | 201 | live "/orgs/:handle/settings/secrets", SecretsLive.Org, :index |
added
test/git_gud_web/live/org_dashboard_test.exs
+204
−0
@@ -0,0 +1,204 @@
| 1 | +defmodule GitGudWeb.OrgDashboardTest do | |
| 2 | + @moduledoc """ | |
| 3 | + `/orgs/:handle/dashboard` — the landing dashboard, narrowed to one | |
| 4 | + organization. | |
| 5 | + | |
| 6 | + The assertions that matter are the two ways it could be wrong: work | |
| 7 | + from *outside* the org leaking in, and work inside the org the viewer | |
| 8 | + isn't allowed to see leaking in. | |
| 9 | + """ | |
| 10 | + use GitGudWeb.ConnCase, async: false | |
| 11 | + | |
| 12 | + import GitGud.AccountsFixtures | |
| 13 | + import GitGud.ForgeFixtures | |
| 14 | + import Phoenix.LiveViewTest | |
| 15 | + | |
| 16 | + alias GitGud.Organizations | |
| 17 | + alias GitGud.Repositories | |
| 18 | + | |
| 19 | + setup do | |
| 20 | + owner = user_fixture() |> set_password() | |
| 21 | + | |
| 22 | + {:ok, org} = | |
| 23 | + Organizations.create_organization(owner, %{ | |
| 24 | + "handle" => "org#{System.unique_integer([:positive])}", | |
| 25 | + "name" => "Test Org" | |
| 26 | + }) | |
| 27 | + | |
| 28 | + {:ok, repo} = | |
| 29 | + Repositories.create_repository_for_org(org, owner, %{ | |
| 30 | + "name" => unique_repo_name(), | |
| 31 | + "visibility" => "public" | |
| 32 | + }) | |
| 33 | + | |
| 34 | + {:ok, owner: owner, org: org, repo: GitGud.Repo.preload(repo, [:owner, :organization])} | |
| 35 | + end | |
| 36 | + | |
| 37 | + defp sign_in(conn, user), do: GitGudWeb.ConnCase.log_in_user(conn, user) | |
| 38 | + | |
| 39 | + defp path(org), do: "/orgs/#{org.handle}/dashboard" | |
| 40 | + | |
| 41 | + describe "scoping" do | |
| 42 | + test "shows the org's open work", %{conn: conn, owner: owner, org: org, repo: repo} do | |
| 43 | + issue_fixture(repo, owner, %{"title" => "an org issue"}) | |
| 44 | + | |
| 45 | + {:ok, _view, html} = conn |> sign_in(owner) |> live(path(org)) | |
| 46 | + | |
| 47 | + assert html =~ "an org issue" | |
| 48 | + assert html =~ "Open issues" | |
| 49 | + assert html =~ "Open pull requests" | |
| 50 | + assert html =~ "Needs your review" | |
| 51 | + end | |
| 52 | + | |
| 53 | + test "does not show work from outside the org", %{conn: conn, owner: owner, org: org} do | |
| 54 | + {_other_user, personal} = repository_fixture(%{owner: owner, visibility: "public"}) | |
| 55 | + issue_fixture(personal, owner, %{"title" => "a personal issue"}) | |
| 56 | + | |
| 57 | + {:ok, _view, html} = conn |> sign_in(owner) |> live(path(org)) | |
| 58 | + | |
| 59 | + refute html =~ "a personal issue" | |
| 60 | + end | |
| 61 | + | |
| 62 | + test "someone else's work shows too — an org dashboard is not the landing page", %{ | |
| 63 | + conn: conn, | |
| 64 | + owner: owner, | |
| 65 | + org: org, | |
| 66 | + repo: repo | |
| 67 | + } do | |
| 68 | + other = user_fixture() | |
| 69 | + issue_fixture(repo, other, %{"title" => "opened by someone else"}) | |
| 70 | + | |
| 71 | + {:ok, _view, html} = conn |> sign_in(owner) |> live(path(org)) | |
| 72 | + | |
| 73 | + assert html =~ "opened by someone else" | |
| 74 | + end | |
| 75 | + | |
| 76 | + test "your own work is separated out", %{conn: conn, owner: owner, org: org, repo: repo} do | |
| 77 | + issue_fixture(repo, owner, %{"title" => "mine here"}) | |
| 78 | + | |
| 79 | + {:ok, _view, html} = conn |> sign_in(owner) |> live(path(org)) | |
| 80 | + | |
| 81 | + assert html =~ "Your work here" | |
| 82 | + assert html =~ "mine here" | |
| 83 | + end | |
| 84 | + | |
| 85 | + test "a brand-new org renders the panels with their own empty copy", %{ | |
| 86 | + conn: conn, | |
| 87 | + owner: owner | |
| 88 | + } do | |
| 89 | + {:ok, fresh} = | |
| 90 | + Organizations.create_organization(owner, %{ | |
| 91 | + "handle" => "fresh#{System.unique_integer([:positive])}", | |
| 92 | + "name" => "Fresh" | |
| 93 | + }) | |
| 94 | + | |
| 95 | + {:ok, _view, html} = conn |> sign_in(owner) |> live(path(fresh)) | |
| 96 | + | |
| 97 | + # Every org gets an auto-provisioned profile repo, so "no | |
| 98 | + # repositories at all" isn't a state a viewer reaches; the panels | |
| 99 | + # say what's empty, the same way the landing dashboard does. | |
| 100 | + assert html =~ "No open pull requests." | |
| 101 | + assert html =~ "No open issues." | |
| 102 | + assert html =~ "Nothing waiting on you here." | |
| 103 | + end | |
| 104 | + end | |
| 105 | + | |
| 106 | + describe "visibility" do | |
| 107 | + test "a private repo's issues are hidden from a non-member", %{ | |
| 108 | + conn: conn, | |
| 109 | + owner: owner, | |
| 110 | + org: org | |
| 111 | + } do | |
| 112 | + {:ok, private} = | |
| 113 | + Repositories.create_repository_for_org(org, owner, %{ | |
| 114 | + "name" => unique_repo_name(), | |
| 115 | + "visibility" => "private" | |
| 116 | + }) | |
| 117 | + | |
| 118 | + issue_fixture(private, owner, %{"title" => "a private issue"}) | |
| 119 | + | |
| 120 | + outsider = user_fixture() |> set_password() | |
| 121 | + | |
| 122 | + {:ok, _view, html} = conn |> sign_in(outsider) |> live(path(org)) | |
| 123 | + | |
| 124 | + refute html =~ "a private issue" | |
| 125 | + end | |
| 126 | + | |
| 127 | + test "…and visible to a member", %{conn: conn, owner: owner, org: org} do | |
| 128 | + {:ok, private} = | |
| 129 | + Repositories.create_repository_for_org(org, owner, %{ | |
| 130 | + "name" => unique_repo_name(), | |
| 131 | + "visibility" => "private" | |
| 132 | + }) | |
| 133 | + | |
| 134 | + issue_fixture(private, owner, %{"title" => "a private issue"}) | |
| 135 | + | |
| 136 | + {:ok, _view, html} = conn |> sign_in(owner) |> live(path(org)) | |
| 137 | + | |
| 138 | + assert html =~ "a private issue" | |
| 139 | + end | |
| 140 | + | |
| 141 | + test "a private org's dashboard redirects a non-member", %{conn: conn, owner: owner} do | |
| 142 | + {:ok, secret} = | |
| 143 | + Organizations.create_organization(owner, %{ | |
| 144 | + "handle" => "secret#{System.unique_integer([:positive])}", | |
| 145 | + "name" => "Secret", | |
| 146 | + "visibility" => "private" | |
| 147 | + }) | |
| 148 | + | |
| 149 | + outsider = user_fixture() |> set_password() | |
| 150 | + | |
| 151 | + assert {:error, {:live_redirect, %{to: "/"}}} = | |
| 152 | + conn |> sign_in(outsider) |> live(path(secret)) | |
| 153 | + end | |
| 154 | + | |
| 155 | + test "signing in is required", %{conn: conn, org: org} do | |
| 156 | + assert {:error, {:redirect, %{to: redirect}}} = live(conn, path(org)) | |
| 157 | + assert redirect =~ "/users/log-in" | |
| 158 | + end | |
| 159 | + | |
| 160 | + test "404 for an org that doesn't exist", %{conn: conn, owner: owner} do | |
| 161 | + assert_raise Ecto.NoResultsError, fn -> | |
| 162 | + conn |> sign_in(owner) |> live("/orgs/nobody-at-all/dashboard") | |
| 163 | + end | |
| 164 | + end | |
| 165 | + end | |
| 166 | + | |
| 167 | + describe "chrome" do | |
| 168 | + test "links back to the org and its CI", %{conn: conn, owner: owner, org: org} do | |
| 169 | + {:ok, _view, html} = conn |> sign_in(owner) |> live(path(org)) | |
| 170 | + | |
| 171 | + assert html =~ ~s(href="/orgs/#{org.handle}") | |
| 172 | + assert html =~ ~s(href="/orgs/#{org.handle}/actions") | |
| 173 | + end | |
| 174 | + | |
| 175 | + test "an admin gets the New repository button; a plain member doesn't", %{ | |
| 176 | + conn: conn, | |
| 177 | + owner: owner, | |
| 178 | + org: org | |
| 179 | + } do | |
| 180 | + {:ok, admin_view, _html} = conn |> sign_in(owner) |> live(path(org)) | |
| 181 | + assert has_element?(admin_view, "#org-new-repo") | |
| 182 | + | |
| 183 | + member = user_fixture() |> set_password() | |
| 184 | + {:ok, _} = Organizations.add_member(org, member, "member") | |
| 185 | + | |
| 186 | + {:ok, member_view, _html} = build_conn() |> sign_in(member) |> live(path(org)) | |
| 187 | + # Matched by id, not by text: the app layout has its own "New | |
| 188 | + # repository" link for every signed-in visitor. | |
| 189 | + refute has_element?(member_view, "#org-new-repo") | |
| 190 | + end | |
| 191 | + | |
| 192 | + test "the org page offers the dashboard to signed-in visitors", %{ | |
| 193 | + conn: conn, | |
| 194 | + owner: owner, | |
| 195 | + org: org | |
| 196 | + } do | |
| 197 | + {:ok, _view, html} = conn |> sign_in(owner) |> live("/orgs/#{org.handle}") | |
| 198 | + assert html =~ "/orgs/#{org.handle}/dashboard" | |
| 199 | + | |
| 200 | + {:ok, _view, anon} = live(build_conn(), "/orgs/#{org.handle}") | |
| 201 | + refute anon =~ "/orgs/#{org.handle}/dashboard" | |
| 202 | + end | |
| 203 | + end | |
| 204 | +end |
Parents: 8e8d69e