data/import.ex
Import
A CSV import mapper with source columns wired across to destination fields and one column left unmatched.
# in the module that renders the template
use EHVisuals
<div class="h-96 rounded-xl border border-border">
<.data_import animated />
</div>
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
file |
string | "orders.csv" |
Up to 12 characters. |
rows |
string | "1,284 rows" |
Up to 10 characters. |
target |
string | "ERP · Orders" |
Up to 22 characters. |
columns |
list | [%{name: "customer", to: "Customer"}, %{name: "order_no", to: "Order number"}, ...] |
Up to seven `%{name, to}`. `to` is the name of a destination field, or `nil` for a column without a match. Names up to 15 characters. |
fields |
list | [%{name: "Order number", type: "Text"}, %{name: "Customer", type: "Link"}, ...] |
Up to seven `%{name, type}`. Names up to 14 characters, types up to 6. |
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/import.ex
defmodule EHVisuals.Data.Import do
@moduledoc """
A CSV import mapper: the columns of the file on the left, the fields they
fill on the right, a wire for every match and a loose end for the column
that has none.
"""
use EHVisuals.Composition,
name: "data/import",
title: "Import",
description:
"A CSV import mapper with source columns wired across to destination fields and one column left unmatched."
@columns [
%{name: "customer", to: "Customer"},
%{name: "order_no", to: "Order number"},
%{name: "due", to: "Due date"},
%{name: "amount_eur", to: "Total"},
%{name: "notes_2", to: nil}
]
@fields [
%{name: "Order number", type: "Text"},
%{name: "Customer", type: "Link"},
%{name: "Total", type: "Number"},
%{name: "Due date", type: "Date"},
%{name: "Owner", type: "Person"}
]
attr(:file, :string, default: "orders.csv", doc: "Up to 12 characters.")
attr(:rows, :string, default: "1,284 rows", doc: "Up to 10 characters.")
attr(:target, :string, default: "ERP · Orders", doc: "Up to 22 characters.")
attr(:columns, :list,
default: @columns,
doc:
"Up to seven `%{name, to}`. `to` is the name of a destination field, or `nil` for a column without a match. Names up to 15 characters."
)
attr(:fields, :list,
default: @fields,
doc: "Up to seven `%{name, type}`. Names up to 14 characters, types up to 6."
)
attr(:label, :string,
default: "Five CSV columns mapped to the fields of an order, one of them without a match"
)
frame_attrs()
@spec data_import(map()) :: Phoenix.LiveView.Rendered.t()
def data_import(assigns) do
fields =
for {field, index} <- assigns.fields |> Enum.take(7) |> Enum.with_index() do
%{name: field.name, type: Map.get(field, :type, ""), index: index, y: 62 + index * 32}
end
targets = Map.new(fields, &{&1.name, &1.y})
columns =
for {column, index} <- assigns.columns |> Enum.take(7) |> Enum.with_index() do
y = 62 + index * 32
to = Map.get(targets, Map.get(column, :to))
%{
name: column.name,
letter: <<?A + index>>,
index: index,
y: y,
matched: to != nil,
path: to && "M170.5 #{y} C212 #{y} 208 #{to} 249.5 #{to}"
}
end
used =
for column <- assigns.columns, name = Map.get(column, :to), into: MapSet.new(), do: name
matched = Enum.count(columns, & &1.matched)
loose = length(columns) - matched
panel_h = 46 + max(max(length(columns), length(fields)), 1) * 32 + 2
assigns =
assigns
|> assign(:column_items, columns)
|> assign(
:field_items,
Enum.map(fields, &Map.put(&1, :filled, MapSet.member?(used, &1.name)))
)
|> assign(:panel_h, panel_h)
|> assign(:matched_text, "#{matched} of #{length(columns)} columns matched")
|> assign(:loose, loose)
|> assign(:loose_text, "#{loose} without a field")
|> assign(:height, panel_h + 30)
~H"""
<.frame name="data/import" label={@label} {frame_opts(assigns)}>
<svg viewBox={"0 0 420 #{@height}"}>
<g class="ehv-h-lift">
<g :for={column <- @column_items}>
<path
:if={column.matched}
class={"ehv-line-ink ehv-data-import__wire ehv-a-draw ehv-i-#{min(column.index + 4, 24)}"}
d={column.path}
pathLength="1"
/>
<g :if={not column.matched} class={"ehv-a-fade ehv-i-#{min(column.index + 5, 24)}"}>
<line
class="ehv-line-coral ehv-dash"
x1="170.5"
x2="192"
y1={column.y}
y2={column.y}
/>
<path
class="ehv-line-coral ehv-round"
d={"M196 #{column.y - 3.5} l7 7 m0 -7 l-7 7"}
/>
</g>
</g>
<g :if={@animated} class="ehv-smil ehv-a-fade ehv-i-12">
<circle
:for={column <- @column_items}
:if={column.matched}
class="ehv-fill-lime"
r="2.5"
opacity="0"
>
<animateMotion
path={column.path}
dur="2.8s"
begin={"#{column.index * 0.5}s"}
repeatCount="indefinite"
/>
<animate
attributeName="opacity"
values="0;1;1;0"
keyTimes="0;0.2;0.8;1"
dur="2.8s"
begin={"#{column.index * 0.5}s"}
repeatCount="indefinite"
/>
</circle>
</g>
<g class="ehv-a-rise ehv-i-0">
<rect class="ehv-card" x="10.5" y="0.5" width="160" height={@panel_h} rx="10" />
<path class="ehv-line ehv-round" d="M23.5 13.5 h6 l4 4 v9 h-10 z m6 0 v4 h4" />
<text class="ehv-code ehv-t-2" x="41" y="24" font-size="9.5">{@file}</text>
<text class="ehv-mono" x="158" y="24" font-size="7" text-anchor="end">
{String.upcase(@rows)}
</text>
<line class="ehv-line-soft" x1="10.5" x2="170.5" y1="36.5" y2="36.5" />
</g>
<g class="ehv-a-rise ehv-i-1">
<rect class="ehv-card" x="249.5" y="0.5" width="160" height={@panel_h} rx="10" />
<text class="ehv-mono ehv-t-2" x="264" y="24" font-size="8">
{String.upcase(@target)}
</text>
<line class="ehv-line-soft" x1="249.5" x2="409.5" y1="36.5" y2="36.5" />
</g>
<g :for={column <- @column_items} class={"ehv-a-slide ehv-i-#{min(column.index + 2, 24)}"}>
<rect
class={
if column.matched,
do: "ehv-card-3",
else: "ehv-data-import__cell--loose"
}
x="20.5"
y={column.y - 10.5}
width="16"
height="21"
rx="5"
/>
<text
class={["ehv-mono", if(column.matched, do: "ehv-t-2", else: "ehv-t-coral")]}
x="28.5"
y={column.y + 3}
font-size="7.5"
text-anchor="middle"
>
{column.letter}
</text>
<text
class={["ehv-code", not column.matched && "ehv-t-coral"]}
x="46"
y={column.y + 3.5}
font-size="10"
>
{column.name}
</text>
<circle
class={if column.matched, do: "ehv-node", else: "ehv-data-import__port--loose"}
cx="170.5"
cy={column.y}
r="3.5"
/>
</g>
<g :for={field <- @field_items} class={"ehv-a-fade ehv-i-#{min(field.index + 3, 24)}"}>
<rect
:if={field.filled}
class="ehv-fill-lime"
x="245.5"
y={field.y - 4}
width="8"
height="8"
rx="2.5"
/>
<circle :if={not field.filled} class="ehv-node-soft" cx="249.5" cy={field.y} r="3.5" />
<text class={[not field.filled && "ehv-t-3"]} x="266" y={field.y + 4} font-size="11.5">
{field.name}
</text>
<text class="ehv-mono" x="398" y={field.y + 3} font-size="7" text-anchor="end">
{String.upcase(field.type)}
</text>
</g>
<g class="ehv-a-fade ehv-i-10">
<rect class="ehv-fill-lime" x="10" y={@height - 14} width="7" height="7" rx="2" />
<text class="ehv-mono ehv-t-2" x="24" y={@height - 7.5} font-size="8">
{String.upcase(@matched_text)}
</text>
<text
:if={@loose > 0}
class="ehv-mono ehv-t-coral"
x="410"
y={@height - 7.5}
font-size="8"
text-anchor="end"
>
{String.upcase(@loose_text)}
</text>
</g>
</g>
</svg>
</.frame>
"""
end
end
assets/css/data/import.css
/* data/import */
.ehv-data-import { --ehv-max-w: 460px; --ehv-iso-scale: 0.82; --ehv-step: 110ms; }
.ehv-data-import__cell--loose { fill: var(--ehv-coral-soft); stroke: var(--ehv-coral); stroke-width: 1; stroke-dasharray: 2 3; }
.ehv-data-import__port--loose { fill: var(--ehv-canvas); stroke: var(--ehv-coral); stroke-width: 1.25; }
.ehv--hover .ehv-data-import__wire { transition: stroke 200ms var(--ehv-ease); }
.ehv--hover:hover .ehv-data-import__wire { stroke: var(--ehv-ink); }