empowered.house ↗

data/query.ex

Query

A visual query builder with conditions as chips, a run state and the matching rows underneath.

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

<div class="h-96 rounded-xl border border-border">
  <.data_query animated />
</div>
default
isometric
running
not run yet · custom copy · hover
light surface · four rows
isometric · fade out · still

Props

Prop Type Default What it does
source string "Invoices" Up to 16 characters.
conditions list [%{value: "overdue", op: "=", field: "status"}, %{value: "1,000", ...}, ...] Up to six `%{field, op, value}`. They wrap onto a new line. Each one up to 30 characters in total.
state "idle" | "running" | "done" "done" `idle` before the query has run, `running` while it does, `done` with the rows.
count string "42 invoices" Shown when done. Up to 24 characters.
time string "38 ms"
rows list [%{id: "INV-1042", name: "Northwind", value: "€4,200"}, %{id: "INV-1038", ...}, ...] Up to five `%{id, name, value}`, the first rows of the result.
run string "Run"
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/data/query.ex
query.ex
defmodule EHVisuals.Data.Query do
  @moduledoc """
  A visual query builder: a source, conditions as chips, a run button and,
  once it has run, the number of matching rows with the first few of them.
  """

  use EHVisuals.Composition,
    name: "data/query",
    title: "Query",
    description:
      "A visual query builder with conditions as chips, a run state and the matching rows underneath."

  @conditions [
    %{field: "status", op: "=", value: "overdue"},
    %{field: "amount", op: ">", value: "1,000"},
    %{field: "owner", op: "=", value: "Finance"}
  ]

  @rows [
    %{id: "INV-1042", name: "Northwind", value: "€4,200"},
    %{id: "INV-1038", name: "Acme Tools", value: "€2,750"},
    %{id: "INV-1031", name: "Globex", value: "€1,980"}
  ]

  attr(:source, :string, default: "Invoices", doc: "Up to 16 characters.")

  attr(:conditions, :list,
    default: @conditions,
    doc:
      "Up to six `%{field, op, value}`. They wrap onto a new line. Each one up to 30 characters in total."
  )

  attr(:state, :string,
    default: "done",
    values: ~w(idle running done),
    doc: "`idle` before the query has run, `running` while it does, `done` with the rows."
  )

  attr(:count, :string, default: "42 invoices", doc: "Shown when done. Up to 24 characters.")
  attr(:time, :string, default: "38 ms")

  attr(:rows, :list,
    default: @rows,
    doc: "Up to five `%{id, name, value}`, the first rows of the result."
  )

  attr(:run, :string, default: "Run")

  attr(:label, :string, default: "A query on invoices with three conditions that returns 42 rows")

  frame_attrs()

  @spec data_query(map()) :: Phoenix.LiveView.Rendered.t()
  def data_query(assigns) do
    {chips, lines} = flow(Enum.take(assigns.conditions, 6))
    list = Enum.take(assigns.rows, 5)
    shown = if assigns.state == "done", do: length(list), else: max(min(length(list), 3), 1)
    divider = 52 + lines * 30 + 4
    table_y = divider + 40
    button = if assigns.state == "running", do: "Running", else: assigns.run
    button_w = Float.round(32 + String.length(button) * 6.2, 1)
    source_w = Float.round(30 + String.length(assigns.source) * 6.3, 1)

    assigns =
      assigns
      |> assign(:chips, chips)
      |> assign(:items, Enum.with_index(list))
      |> assign(:ghosts, Enum.to_list(0..(shown - 1)))
      |> assign(:divider, divider)
      |> assign(:table_y, table_y)
      |> assign(:button, button)
      |> assign(:button_w, button_w)
      |> assign(:button_x, Float.round(380 - button_w, 1))
      |> assign(:source_w, source_w)
      |> assign(:result_step, min(length(chips) + 4, 20))
      |> assign(:height, table_y + shown * 28 + 6)

    ~H"""
    <.frame name="data/query" label={@label} {frame_opts(assigns)}>
      <svg viewBox={"0 0 400 #{@height}"}>
        <g class="ehv-h-lift">
          <rect class="ehv-card ehv-a-rise" x="0.5" y="0.5" width="399" height={@height - 1} rx="12" />
          <g class="ehv-a-fade ehv-i-1">
            <text class="ehv-mono" x="20" y="31.5" font-size="7.5">FROM</text>
            <rect class="ehv-card-3" x="62.5" y="16.5" width={@source_w} height="23" rx="6" />
            <rect class="ehv-node-soft" x="70.5" y="24.5" width="7" height="7" rx="2" />
            <text class="ehv-t-strong" x="83" y="32" font-size="11.5">{@source}</text>
            <text class="ehv-mono" x="20" y="67.5" font-size="7.5">WHERE</text>
          </g>

          <g class="ehv-a-fade ehv-i-2">
            <rect
              class={["ehv-data-query__run", "ehv-data-query__run--#{@state}"]}
              x={@button_x}
              y="16.5"
              width={@button_w}
              height="23"
              rx="6"
            />
            <g :if={@state == "running"} class="ehv-data-query__spin">
              <circle class="ehv-line-soft" cx={@button_x + 14} cy="28" r="5" />
              <path class="ehv-line-lime ehv-round" d={"M#{@button_x + 14} 23 a5 5 0 0 1 5 5"} />
            </g>
            <path
              :if={@state != "running"}
              class={if @state == "idle", do: "ehv-data-query__play", else: "ehv-fill-2"}
              d={"M#{@button_x + 11} 23.5 l8 4.5 l-8 4.5 z"}
            />
            <text
              class={["ehv-t-strong", if(@state == "idle", do: "ehv-t-on-lime", else: "ehv-t-2")]}
              x={@button_x + 25}
              y="32"
              font-size="11"
            >
              {@button}
            </text>
          </g>

          <g :for={chip <- @chips} class={"ehv-a-pop ehv-i-#{min(chip.index + 3, 24)}"}>
            <text
              :if={chip.index > 0}
              class="ehv-mono ehv-t-4"
              x={chip.x - 24}
              y={chip.y + 15.5}
              font-size="7"
            >
              AND
            </text>
            <rect
              class="ehv-well"
              x={chip.x + 0.5}
              y={chip.y + 0.5}
              width={chip.w}
              height="23"
              rx="6"
            />
            <text class="ehv-code" x={chip.x + 8} y={chip.y + 15.5} font-size="9.5">
              {chip.field}
            </text>
            <text class="ehv-code ehv-t-3" x={chip.op_x} y={chip.y + 15.5} font-size="9.5">
              {chip.op}
            </text>
            <text class="ehv-code ehv-t-strong" x={chip.value_x} y={chip.y + 15.5} font-size="9.5">
              {chip.value}
            </text>
          </g>

          <line class="ehv-line-faint" x1="20" x2="380" y1={@divider + 0.5} y2={@divider + 0.5} />

          <g :if={@state == "done"}>
            <g class={"ehv-a-fade ehv-i-#{@result_step}"}>
              <circle class="ehv-fill-lime ehv-a-pulse" cx="24" cy={@divider + 20} r="3.5" />
              <text class="ehv-t-strong" x="36" y={@divider + 24.5} font-size="12.5">{@count}</text>
              <text class="ehv-mono" x="380" y={@divider + 24} font-size="8" text-anchor="end">
                {String.upcase("in #{@time}")}
              </text>
            </g>
            <g
              :for={{row, index} <- @items}
              class={"ehv-a-rise ehv-i-#{min(@result_step + index + 1, 24)}"}
            >
              <line
                class="ehv-line-faint"
                x1="20"
                x2="380"
                y1={@table_y - 3.5 + index * 28}
                y2={@table_y - 3.5 + index * 28}
              />
              <text class="ehv-mono ehv-t-2" x="20" y={@table_y + 14.5 + index * 28} font-size="9">
                {String.upcase(row.id)}
              </text>
              <text x="112" y={@table_y + 15 + index * 28} font-size="12">{row.name}</text>
              <text
                class="ehv-mono ehv-t-ink"
                x="380"
                y={@table_y + 14.5 + index * 28}
                font-size="9"
                text-anchor="end"
              >
                {row.value}
              </text>
            </g>
          </g>

          <g :if={@state != "done"} class="ehv-a-fade ehv-i-6">
            <circle class="ehv-node-soft" cx="24" cy={@divider + 20} r="3.5" />
            <text class="ehv-t-3" x="36" y={@divider + 24.5} font-size="12.5">
              {if @state == "running", do: "Looking for rows", else: "Not run yet"}
            </text>
            <g
              :for={index <- @ghosts}
              class={[@state == "running" && "ehv-a-pulse", "ehv-i-#{index * 2}"]}
            >
              <line
                class="ehv-line-faint"
                x1="20"
                x2="380"
                y1={@table_y - 3.5 + index * 28}
                y2={@table_y - 3.5 + index * 28}
              />
              <rect
                class="ehv-fill-soft"
                x="20"
                y={@table_y + 7 + index * 28}
                width="56"
                height="8"
                rx="4"
              />
              <rect
                class="ehv-fill-soft"
                x="112"
                y={@table_y + 7 + index * 28}
                width={96 - index * 14}
                height="8"
                rx="4"
              />
              <rect
                class="ehv-fill-soft"
                x="340"
                y={@table_y + 7 + index * 28}
                width="40"
                height="8"
                rx="4"
              />
            </g>
          </g>
        </g>
      </svg>
    </.frame>
    """
  end

  # Lays the condition chips out left to right from x = 62 and wraps them at
  # x = 380. Every chip after the first is led by the word AND.
  defp flow(conditions) do
    {chips, {_x, line}} =
      conditions
      |> Enum.with_index()
      |> Enum.map_reduce({62.0, 0}, fn {condition, index}, {x, line} ->
        field = String.length(condition.field)
        op = String.length(condition.op)
        width = Float.round(16 + (field + op + String.length(condition.value) + 2) * 5.7, 1)
        lead = if index > 0, do: 28, else: 0
        {x, line} = if x + lead + width > 380, do: {62.0, line + 1}, else: {x, line}
        left = Float.round(x + lead, 1)

        chip = %{
          index: index,
          field: condition.field,
          op: condition.op,
          value: condition.value,
          x: left,
          y: 52 + line * 30,
          w: width,
          op_x: Float.round(left + 8 + (field + 1) * 5.7, 1),
          value_x: Float.round(left + 8 + (field + op + 2) * 5.7, 1)
        }

        {chip, {left + width + 6, line}}
      end)

    {chips, line + 1}
  end
end
assets/css/data/query.css
query.css
/* data/query */
.ehv-data-query { --ehv-max-w: 430px; --ehv-step: 110ms; }

.ehv-data-query__run { fill: var(--ehv-canvas); stroke: var(--ehv-border-strong); stroke-width: 1; }
.ehv-data-query__run--idle { fill: var(--ehv-lime-fill); stroke: none; }
.ehv-data-query__run--running { fill: var(--ehv-surface-3); stroke: var(--ehv-border-soft); }
.ehv-data-query__run--done { fill: var(--ehv-canvas); }
.ehv-data-query__play { fill: var(--ehv-on-lime); }

/* The spinner turns around the centre of its own track circle. */
@keyframes ehv-data-query-spin { to { transform: rotate(360deg); } }

.ehv-data-query__spin { transform-box: fill-box; transform-origin: center; }
.ehv--animated .ehv-data-query__spin { animation: ehv-data-query-spin 1.1s linear infinite; }

.ehv--hover .ehv-data-query__run { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-data-query__run { stroke: var(--ehv-lime); }