Add preview step to import with missing category detection
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:
parent
48f9322f61
commit
2cf13e2cbb
2 changed files with 285 additions and 53 deletions
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue