empowered.house ↗

data/import.ex

Import

A CSV import mapper with source columns wired across to destination fields and one column left unmatched.

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

<div class="h-96 rounded-xl border border-border">
  <.data_import animated />
</div>
default
isometric
everything matched · custom copy
hover · no glow
light surface
isometric · fade out · still

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