empowered.house ↗

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.

Use it
# in the module that renders the template
use EHVisuals

<div class="h-96 rounded-xl border border-border">
  <.git_pull_request animated />
</div>
default
isometric
one check passed · the second running
custom copy · hover
light surface
isometric · fade out · still

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
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
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; }