Add preview step to import with missing category detection
All checks were successful
CI - Test, Build, and Push / test (push) Successful in 2m46s
CI - Test, Build, and Push / build-and-push (push) Successful in 46s

The import process now shows a preview before importing, listing any
missing categories. Users can create missing categories with one click
before proceeding with the import.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Kevin Sivic 2025-12-01 14:46:16 -05:00
parent 48f9322f61
commit 2cf13e2cbb
2 changed files with 285 additions and 53 deletions

View file

@ -38,6 +38,8 @@ defmodule Localspot.Businesses.Import do
"""
alias Localspot.Businesses
alias Localspot.Businesses.Category
alias Localspot.Repo
require Logger
@ -45,6 +47,89 @@ defmodule Localspot.Businesses.Import do
{:ok, %{imported: non_neg_integer(), skipped: non_neg_integer(), errors: list()}}
| {:error, term()}
@type preview_result ::
{:ok, %{total: non_neg_integer(), missing_categories: list(String.t())}}
| {:error, term()}
@doc """
Previews a JSON file and returns information about missing categories.
"""
@spec preview_file(String.t()) :: preview_result()
def preview_file(path) do
case File.read(path) do
{:ok, content} -> preview_json(content)
{:error, reason} -> {:error, {:file_error, reason}}
end
end
@doc """
Previews a JSON string and returns information about missing categories.
"""
@spec preview_json(String.t()) :: preview_result()
def preview_json(json_string) do
case Jason.decode(json_string) do
{:ok, %{"businesses" => businesses}} when is_list(businesses) ->
analyze_businesses(businesses)
{:ok, _} ->
{:error, {:invalid_format, "JSON must contain a 'businesses' array"}}
{:error, reason} ->
{:error, {:json_error, reason}}
end
end
defp analyze_businesses(businesses) do
categories = load_categories()
# Extract all unique category keys from businesses
category_keys =
businesses
|> Enum.map(&(&1["category"] || ""))
|> Enum.reject(&(&1 == ""))
|> Enum.uniq()
# Find which ones are missing
missing_categories =
category_keys
|> Enum.reject(fn key -> Map.has_key?(categories, String.downcase(key)) end)
|> Enum.sort()
{:ok, %{total: length(businesses), missing_categories: missing_categories}}
end
@doc """
Creates categories from a list of category names/slugs.
Returns {:ok, created_count} or {:error, reason}.
"""
@spec create_categories(list(String.t())) :: {:ok, non_neg_integer()} | {:error, term()}
def create_categories(category_names) do
results =
Enum.map(category_names, fn name ->
slug = Businesses.generate_slug(name)
display_name =
name
|> String.replace("-", " ")
|> String.split()
|> Enum.map(&String.capitalize/1)
|> Enum.join(" ")
attrs = %{
name: display_name,
slug: slug,
description: "#{display_name} category"
}
%Category{}
|> Category.changeset(attrs)
|> Repo.insert()
end)
created = Enum.count(results, &match?({:ok, _}, &1))
{:ok, created}
end
@doc """
Imports businesses from a JSON file path.
"""

View file

@ -27,7 +27,11 @@ defmodule LocalspotWeb.AdminLive.Import do
|> assign(:page_title, "Import Businesses")
|> assign(:uploaded_files, [])
|> assign(:import_result, nil)
|> assign(:preview_result, nil)
|> assign(:importing, false)
|> assign(:previewing, false)
|> assign(:creating_categories, false)
|> assign(:pending_file_content, nil)
|> assign(:json_example, @json_example)
|> allow_upload(:import_file,
accept: ~w(.json),
@ -41,31 +45,97 @@ defmodule LocalspotWeb.AdminLive.Import do
{:noreply, socket}
end
@impl true
def handle_event("preview", _params, socket) do
socket = assign(socket, :previewing, true)
# Read and store file content for later import
{file_content, preview_result} =
consume_uploaded_entries(socket, :import_file, fn %{path: path}, _entry ->
content = File.read!(path)
{:ok, {content, Import.preview_json(content)}}
end)
|> case do
[{content, result}] -> {content, result}
[] -> {nil, {:error, :no_file}}
end
{:noreply,
socket
|> assign(:preview_result, preview_result)
|> assign(:pending_file_content, file_content)
|> assign(:previewing, false)}
end
@impl true
def handle_event("create_categories", _params, socket) do
socket = assign(socket, :creating_categories, true)
case socket.assigns.preview_result do
{:ok, %{missing_categories: missing_categories}} when missing_categories != [] ->
{:ok, created} = Import.create_categories(missing_categories)
# Re-preview to confirm categories were created
new_preview =
case socket.assigns.pending_file_content do
nil -> {:error, :no_file}
content -> Import.preview_json(content)
end
{:noreply,
socket
|> assign(:preview_result, new_preview)
|> assign(:creating_categories, false)
|> put_flash(:info, "Created #{created} category(ies)")}
_ ->
{:noreply, assign(socket, :creating_categories, false)}
end
end
@impl true
def handle_event("import", _params, socket) do
socket = assign(socket, :importing, true)
result =
consume_uploaded_entries(socket, :import_file, fn %{path: path}, _entry ->
{:ok, Import.from_file(path)}
end)
import_result =
case result do
[{:ok, result}] -> result
[{:error, reason}] -> {:error, reason}
[] -> {:error, :no_file}
case socket.assigns.pending_file_content do
nil -> {:error, :no_file}
content -> Import.from_json(content)
end
{:noreply,
socket
|> assign(:import_result, import_result)
|> assign(:preview_result, nil)
|> assign(:pending_file_content, nil)
|> assign(:importing, false)}
end
@impl true
def handle_event("clear_result", _params, socket) do
{:noreply, assign(socket, :import_result, nil)}
{:noreply,
socket
|> assign(:import_result, nil)
|> assign(:preview_result, nil)
|> assign(:pending_file_content, nil)
|> allow_upload(:import_file,
accept: ~w(.json),
max_entries: 1,
max_file_size: 10_000_000
)}
end
@impl true
def handle_event("cancel_preview", _params, socket) do
{:noreply,
socket
|> assign(:preview_result, nil)
|> assign(:pending_file_content, nil)
|> allow_upload(:import_file,
accept: ~w(.json),
max_entries: 1,
max_file_size: 10_000_000
)}
end
@impl true
@ -86,63 +156,140 @@ defmodule LocalspotWeb.AdminLive.Import do
</div>
</div>
<.form for={%{}} phx-change="validate" phx-submit="import" class="space-y-4">
<div
class="border-2 border-dashed border-base-300 rounded-lg p-8 text-center"
phx-drop-target={@uploads.import_file.ref}
>
<.live_file_input upload={@uploads.import_file} class="hidden" />
<%!-- File upload form (shown when no preview/result) --%>
<div :if={is_nil(@preview_result) and is_nil(@import_result)}>
<.form for={%{}} phx-change="validate" phx-submit="preview" class="space-y-4">
<div
class="border-2 border-dashed border-base-300 rounded-lg p-8 text-center"
phx-drop-target={@uploads.import_file.ref}
>
<.live_file_input upload={@uploads.import_file} class="hidden" />
<div :if={@uploads.import_file.entries == []}>
<.icon name="hero-document-arrow-up" class="w-12 h-12 mx-auto text-base-content/40" />
<p class="mt-2 text-base-content/60">
Drag and drop a JSON file here, or
<label for={@uploads.import_file.ref} class="link link-primary cursor-pointer">
browse
</label>
</p>
<p class="text-sm text-base-content/40 mt-1">Max file size: 10MB</p>
<div :if={@uploads.import_file.entries == []}>
<.icon name="hero-document-arrow-up" class="w-12 h-12 mx-auto text-base-content/40" />
<p class="mt-2 text-base-content/60">
Drag and drop a JSON file here, or
<label for={@uploads.import_file.ref} class="link link-primary cursor-pointer">
browse
</label>
</p>
<p class="text-sm text-base-content/40 mt-1">Max file size: 10MB</p>
</div>
<div
:for={entry <- @uploads.import_file.entries}
class="flex items-center justify-between"
>
<div class="flex items-center gap-2">
<.icon name="hero-document" class="w-6 h-6 text-primary" />
<span>{entry.client_name}</span>
<span class="text-sm text-base-content/60">
({format_bytes(entry.client_size)})
</span>
</div>
<button
type="button"
class="btn btn-ghost btn-sm"
phx-click="cancel-upload"
phx-value-ref={entry.ref}
>
<.icon name="hero-x-mark" class="w-4 h-4" />
</button>
</div>
<div :for={err <- upload_errors(@uploads.import_file)} class="text-error text-sm mt-2">
{error_to_string(err)}
</div>
</div>
<div
:for={entry <- @uploads.import_file.entries}
class="flex items-center justify-between"
<button
type="submit"
class="btn btn-primary w-full"
disabled={@uploads.import_file.entries == [] or @previewing}
>
<div class="flex items-center gap-2">
<.icon name="hero-document" class="w-6 h-6 text-primary" />
<span>{entry.client_name}</span>
<span class="text-sm text-base-content/60">
({format_bytes(entry.client_size)})
</span>
<span :if={@previewing} class="loading loading-spinner loading-sm"></span>
<span :if={!@previewing}>
<.icon name="hero-eye" class="w-5 h-5" /> Preview Import
</span>
<span :if={@previewing}>Analyzing...</span>
</button>
</.form>
</div>
<%!-- Preview result (shown after file is analyzed) --%>
<div :if={@preview_result && is_nil(@import_result)} class="space-y-4">
<div :if={match?({:ok, _}, @preview_result)}>
<% {:ok, preview} = @preview_result %>
<div class="alert alert-info">
<.icon name="hero-document-text" class="w-5 h-5" />
<span>Found {preview.total} business(es) to import</span>
</div>
<%!-- Missing categories warning --%>
<div :if={length(preview.missing_categories) > 0} class="alert alert-warning">
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
<div class="flex-1">
<p class="font-semibold">Missing Categories Detected</p>
<p class="text-sm mt-1">
The following categories don't exist and need to be created:
</p>
<ul class="list-disc list-inside mt-2 text-sm">
<li :for={category <- preview.missing_categories}>
{category}
</li>
</ul>
</div>
</div>
<div class="flex gap-2 mt-4">
<%!-- Create categories button (if missing) --%>
<button
:if={length(preview.missing_categories) > 0}
type="button"
class="btn btn-warning"
phx-click="create_categories"
disabled={@creating_categories}
>
<span :if={@creating_categories} class="loading loading-spinner loading-sm"></span>
<span :if={!@creating_categories}>
<.icon name="hero-plus-circle" class="w-5 h-5" />
Create {length(preview.missing_categories)} Category(ies)
</span>
<span :if={@creating_categories}>Creating...</span>
</button>
<%!-- Import button (enabled only if no missing categories) --%>
<button
type="button"
class="btn btn-ghost btn-sm"
phx-click="cancel-upload"
phx-value-ref={entry.ref}
class="btn btn-primary"
phx-click="import"
disabled={@importing or length(preview.missing_categories) > 0}
>
<.icon name="hero-x-mark" class="w-4 h-4" />
<span :if={@importing} class="loading loading-spinner loading-sm"></span>
<span :if={!@importing}>
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Import Businesses
</span>
<span :if={@importing}>Importing...</span>
</button>
<button type="button" class="btn btn-ghost" phx-click="cancel_preview">
Cancel
</button>
</div>
<div :for={err <- upload_errors(@uploads.import_file)} class="text-error text-sm mt-2">
{error_to_string(err)}
</div>
<p :if={length(preview.missing_categories) > 0} class="text-sm text-base-content/60 mt-2">
You must create the missing categories before importing.
</p>
</div>
<button
type="submit"
class="btn btn-primary w-full"
disabled={@uploads.import_file.entries == [] or @importing}
>
<span :if={@importing} class="loading loading-spinner loading-sm"></span>
<span :if={!@importing}>
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Import Businesses
</span>
<span :if={@importing}>Importing...</span>
</button>
</.form>
<div :if={match?({:error, _}, @preview_result)} class="alert alert-error">
<.icon name="hero-x-circle" class="w-5 h-5" />
<span>Preview failed: {format_error(@preview_result)}</span>
</div>
</div>
<%!-- Import result --%>
<div :if={@import_result} class="mt-6">
<div :if={match?(%{imported: _, skipped: _, errors: _}, @import_result)} class="space-y-4">
<div class="alert alert-success">