data/query.ex
Query
A visual query builder with conditions as chips, a run state and the matching rows underneath.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.data_query animated />
</div>
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
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
/* 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); }