git/pull-request.ex
Pull request
A pull request card with its branch, the checks that run one by one and a ready to merge state.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.git_pull_request animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
number |
string | "#214" |
|
title |
string | "Route large orders to a manager" |
Up to 40 characters. |
branch |
string | "feature/approval-limit" |
Up to 26 characters. |
base |
string | "main" |
Up to 10 characters. |
author |
string | "Marta Nowak" |
Shown as initials. |
checks |
list | [%{name: "Tests", detail: "412 passed"}, %{name: "Lint", detail: "No issues"}, ...] |
Up to five `%{name, detail}`. The detail shows once the check has passed. |
passed |
integer | 3 |
How many checks have passed, in order. The next one is running, the rest are queued. All of them passed turns the footer into the merge button. |
ready |
string | "Ready to merge" |
Frame props, shared by every composition
| Prop | Type | Default | What it does |
|---|---|---|---|
animated |
boolean | false |
Turns motion on. Without it the composition renders in its final, still state. |
trigger |
"mount" | "in-view" | "in-view-repeat" | "in-view" |
When the entrance plays: on page load, once when scrolled into view, or every time it comes back into view. |
hover |
boolean | false |
Enables the hover micro-interactions the composition supports. |
glow |
boolean | true |
The warm incandescent halo behind the composition. |
fade_out |
boolean | false |
Fades the bottom edge into the surface underneath. |
isometric |
boolean | false |
Tilts the composition into the isometric plane of the platform stack. |
theme |
"dark" | "light" | "dark" |
`dark` for the site canvas, `light` for the cream reversed surface. |
class |
string | nil |
Extra classes for the frame. |
rest |
global | — |
Any other HTML attribute, set on the frame. |
Source
Two files. Copy them into your app if you would rather own the code than depend on the library.
lib/eh_visuals/git/pull-request.ex
defmodule EHVisuals.Git.PullRequest do
@moduledoc """
A pull request: its title, the branch going into main, the checks running
one by one and the merge button that turns on when they have all passed.
"""
use EHVisuals.Composition,
name: "git/pull-request",
title: "Pull request",
description:
"A pull request card with its branch, the checks that run one by one and a ready to merge state."
@checks [
%{name: "Tests", detail: "412 passed"},
%{name: "Lint", detail: "No issues"},
%{name: "Build", detail: "38 s"}
]
attr(:number, :string, default: "#214")
attr(:title, :string, default: "Route large orders to a manager", doc: "Up to 40 characters.")
attr(:branch, :string, default: "feature/approval-limit", doc: "Up to 26 characters.")
attr(:base, :string, default: "main", doc: "Up to 10 characters.")
attr(:author, :string, default: "Marta Nowak", doc: "Shown as initials.")
attr(:checks, :list,
default: @checks,
doc: "Up to five `%{name, detail}`. The detail shows once the check has passed."
)
attr(:passed, :integer,
default: 3,
doc:
"How many checks have passed, in order. The next one is running, the rest are queued. All of them passed turns the footer into the merge button."
)
attr(:ready, :string, default: "Ready to merge")
attr(:label, :string, default: "A pull request with three passed checks, ready to merge")
frame_attrs()
@spec git_pull_request(map()) :: Phoenix.LiveView.Rendered.t()
def git_pull_request(assigns) do
list = Enum.take(assigns.checks, 5)
count = length(list)
passed = assigns.passed |> max(0) |> min(count)
rows =
for {check, index} <- Enum.with_index(list) do
state =
cond do
index < passed -> "done"
index == passed -> "running"
true -> "queued"
end
%{
index: index,
name: check.name,
state: state,
detail: if(state == "done", do: Map.get(check, :detail, ""), else: state),
y: 132 + index * 32
}
end
branch_w = Float.round(16 + String.length(assigns.branch) * 5.4, 1)
base_w = Float.round(16 + String.length(assigns.base) * 5.4, 1)
footer_y = 132 + count * 32 - 8
assigns =
assigns
|> assign(:rows, rows)
|> assign(:done, passed == count)
|> assign(:progress, "Checks running · #{passed} of #{count}")
|> assign(:branch_w, branch_w)
|> assign(:base_w, base_w)
|> assign(:base_x, 20.5 + branch_w + 34)
|> assign(:initials, initials(assigns.author))
|> assign(:footer_y, footer_y)
|> assign(:footer_step, min(count * 2 + 4, 24))
|> assign(:height, footer_y + 34 + 20)
~H"""
<.frame name="git/pull-request" label={@label} {frame_opts(assigns)}>
<svg viewBox={"0 0 360 #{@height}"}>
<g class="ehv-h-lift">
<rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="359" height={@height - 1} rx="12" />
<g class="ehv-a-fade ehv-i-1">
<circle class="ehv-line-ink" cx="24" cy="21.5" r="2.8" />
<circle class="ehv-line-ink" cx="24" cy="34.5" r="2.8" />
<circle class="ehv-line-ink" cx="35" cy="34.5" r="2.8" />
<path
class="ehv-line-ink ehv-round"
d="M24 24.3 v7.4 M35 31.7 v-4.2 a4 4 0 0 0 -4 -4 h-2.5"
/>
<text class="ehv-mono" x="48" y="31.5" font-size="9">
{String.upcase("#{@number} · open")}
</text>
<circle class="ehv-card-3" cx="328" cy="28" r="12" />
<text class="ehv-mono ehv-t-2" x="328" y="31" font-size="8" text-anchor="middle">
{@initials}
</text>
</g>
<text class="ehv-t-strong ehv-a-fade ehv-i-2" x="20" y="64" font-size="14.5">{@title}</text>
<g class="ehv-a-fade ehv-i-3">
<rect class="ehv-card-3" x="20.5" y="76.5" width={@branch_w} height="21" rx="6" />
<text class="ehv-code" x="28.5" y="90.5" font-size="9">{@branch}</text>
<line
class="ehv-line-ink"
x1={@base_x - 26}
x2={@base_x - 14}
y1="87"
y2="87"
/>
<path class="ehv-fill-2" d={"M#{@base_x - 14} 83.5 l6 3.5 l-6 3.5 z"} />
<rect class="ehv-well" x={@base_x} y="76.5" width={@base_w} height="21" rx="6" />
<text class="ehv-code ehv-t-2" x={@base_x + 8} y="90.5" font-size="9">{@base}</text>
</g>
<line class="ehv-line-faint" x1="20" x2="340" y1="110.5" y2="110.5" />
<g :for={row <- @rows} class={"ehv-a-rise ehv-i-#{min(row.index * 2 + 4, 24)}"}>
<g :if={row.state == "done"}>
<circle class="ehv-fill-lime-soft" cx="30" cy={row.y} r="9" />
<path
class={"ehv-line-lime ehv-round ehv-a-draw ehv-i-#{min(row.index * 2 + 5, 24)}"}
d={"M26 #{row.y} l2.8 2.8 l5 -5.6"}
pathLength="1"
/>
</g>
<g :if={row.state == "running"} class="ehv-git-pull-request__spin">
<circle class="ehv-line-soft" cx="30" cy={row.y} r="8" />
<path class="ehv-line-lime ehv-round" d={"M30 #{row.y - 8} a8 8 0 0 1 8 8"} />
</g>
<circle :if={row.state == "queued"} class="ehv-node-soft" cx="30" cy={row.y} r="8" />
<text class={[row.state == "queued" && "ehv-t-3"]} x="50" y={row.y + 4.5} font-size="12.5">
{row.name}
</text>
<text
class={["ehv-mono", row.state == "running" && "ehv-t-lime"]}
x="340"
y={row.y + 3.5}
font-size="8.5"
text-anchor="end"
>
{String.upcase(row.detail)}
</text>
</g>
<g class={"ehv-a-rise ehv-i-#{@footer_step}"}>
<rect
class={
if @done,
do: "ehv-git-pull-request__merge ehv-git-pull-request__merge--ready",
else: "ehv-git-pull-request__merge"
}
x="20.5"
y={@footer_y}
width="319"
height="34"
rx="8"
/>
<text
:if={@done}
class="ehv-t-strong ehv-t-on-lime"
x="180"
y={@footer_y + 21.5}
font-size="12.5"
text-anchor="middle"
>
{@ready}
</text>
<text
:if={not @done}
class="ehv-mono ehv-t-2"
x="180"
y={@footer_y + 20.5}
font-size="9"
text-anchor="middle"
>
{String.upcase(@progress)}
</text>
</g>
</g>
</svg>
</.frame>
"""
end
defp initials(name) do
name |> String.split() |> Enum.take(2) |> Enum.map_join(&String.first/1) |> String.upcase()
end
end
assets/css/git/pull-request.css
/* git/pull-request */
.ehv-git-pull-request { --ehv-max-w: 380px; --ehv-step: 150ms; }
.ehv-git-pull-request__merge { fill: var(--ehv-canvas); stroke: var(--ehv-border-soft); stroke-width: 1; }
.ehv-git-pull-request__merge--ready { fill: var(--ehv-lime-fill); stroke: none; }
/* The spinner turns around the centre of its own track circle. */
@keyframes ehv-git-pull-request-spin { to { transform: rotate(360deg); } }
.ehv-git-pull-request__spin { transform-box: fill-box; transform-origin: center; }
.ehv--animated .ehv-git-pull-request__spin { animation: ehv-git-pull-request-spin 1.1s linear infinite; }
.ehv--hover .ehv-git-pull-request__merge--ready { transition: fill-opacity 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-git-pull-request__merge--ready { fill-opacity: 0.86; }