From 968d1281dd0bc03fb43810c927a01f050c4a0c5d Mon Sep 17 00:00:00 2001 From: Bashar Qassis <23612682+bashar-qassis@users.noreply.github.com> Date: Sat, 4 Apr 2026 01:56:17 +0300 Subject: [PATCH 1/4] fix: resolve contact page redesign bugs and add inline editing - Fix edit page save crash: use :contact_updated atom instead of invalid "Contact updated" string for audit log event - Replace photo upload redirect with inline upload form in timeline modal using LiveView file uploads - Add full CRUD to RemindersComponent with inline form for creating and deleting reminders from the sidebar - Add AboutComponent with inline editing for description and "how we met" fields, including searchable contact picker with avatars - Fix dialyzer opaque type warning in photos_only? helper - Add automated tests for edit save, birthday badge, reminders, and photo upload modal --- .../live/contact_live/about_component.ex | 321 ++++++ .../contact_live/activity_stream_component.ex | 1022 +++++++++++++++++ lib/kith_web/live/contact_live/edit.ex | 2 +- .../live/contact_live/reminders_component.ex | 194 +++- lib/kith_web/live/contact_live/show.ex | 72 +- lib/kith_web/live/contact_live/show.html.heex | 764 ++++++------ test/kith_web/live/contact_live/edit_test.exs | 50 + test/kith_web/live/contact_live/show_test.exs | 192 +++- 8 files changed, 2171 insertions(+), 446 deletions(-) create mode 100644 lib/kith_web/live/contact_live/about_component.ex create mode 100644 lib/kith_web/live/contact_live/activity_stream_component.ex create mode 100644 test/kith_web/live/contact_live/edit_test.exs diff --git a/lib/kith_web/live/contact_live/about_component.ex b/lib/kith_web/live/contact_live/about_component.ex new file mode 100644 index 0000000..267ce77 --- /dev/null +++ b/lib/kith_web/live/contact_live/about_component.ex @@ -0,0 +1,321 @@ +defmodule KithWeb.ContactLive.AboutComponent do + use KithWeb, :live_component + + alias Kith.Contacts + + @impl true + def mount(socket) do + {:ok, + socket + |> assign(:editing, false) + |> assign(:contact_search, "") + |> assign(:contact_results, []) + |> assign(:selected_contact_id, nil)} + end + + @impl true + def update(assigns, socket) do + {:ok, socket |> assign(assigns)} + end + + @impl true + def handle_event("edit", _params, socket) do + selected_id = socket.assigns.contact.first_met_through_id + + {:noreply, + socket + |> assign(:editing, true) + |> assign(:contact_search, "") + |> assign(:contact_results, []) + |> assign(:selected_contact_id, selected_id && to_string(selected_id))} + end + + def handle_event("cancel", _params, socket) do + {:noreply, + socket + |> assign(:editing, false) + |> assign(:contact_search, "") + |> assign(:contact_results, [])} + end + + def handle_event("search-contacts", %{"value" => query}, socket) do + results = + if String.length(query) >= 2 do + socket.assigns.account_id + |> Contacts.search_contacts(query) + |> Enum.reject(&(&1.id == socket.assigns.contact.id)) + |> Enum.take(8) + else + [] + end + + {:noreply, + socket + |> assign(:contact_search, query) + |> assign(:contact_results, results)} + end + + def handle_event("select-contact", %{"id" => id}, socket) do + {:noreply, + socket + |> assign(:selected_contact_id, id) + |> assign(:contact_search, "") + |> assign(:contact_results, [])} + end + + def handle_event("clear-contact", _params, socket) do + {:noreply, assign(socket, :selected_contact_id, nil)} + end + + def handle_event("save", %{"contact" => params}, socket) do + contact = socket.assigns.contact + + params = + Map.put(params, "first_met_through_id", socket.assigns.selected_contact_id || "") + + case Contacts.update_contact(contact, params) do + {:ok, updated} -> + updated = Kith.Repo.preload(updated, [:first_met_through], force: true) + send(self(), {:contact_updated, updated}) + + {:noreply, + socket + |> assign(:contact, updated) + |> assign(:editing, false) + |> put_flash(:info, "Updated.")} + + {:error, _changeset} -> + {:noreply, put_flash(socket, :error, "Could not save changes.")} + end + end + + @impl true + def render(assigns) do + ~H""" +
+
+ About + +
+ + <%= if @editing do %> + <.form for={%{}} phx-submit="save" phx-target={@myself} class="mt-2 space-y-2.5"> +
+ + +
+ +
+ + How we met + +
+ +
+ + +
+ +
+ + +
+ +
+ + <%= if @selected_contact_id do %> + <.selected_contact_badge + contact={find_selected_contact(@contact, @selected_contact_id)} + myself={@myself} + /> + <% else %> + + <%= if @contact_results != [] do %> +
+ <%= for c <- @contact_results do %> + + <% end %> +
+ <% end %> + <% end %> +
+ +
+ + +
+ +
+ + +
+ + <% else %> +

+ {@contact.description} +

+

+ No description added. +

+
+ How we met + <%= if has_first_met_data?(@contact) do %> +
+
+
When
+
+ +
+
+
+
Where
+
{@contact.first_met_where}
+
+
+
Through
+
+ <.link + navigate={~p"/contacts/#{@contact.first_met_through.id}"} + class="text-[var(--color-accent)] hover:underline" + > + {@contact.first_met_through.display_name} + +
+
+
+

+ {@contact.first_met_additional_info} +

+ <% else %> +

Not recorded yet.

+ <% end %> +
+ <% end %> +
+ """ + end + + defp selected_contact_badge(assigns) do + ~H""" +
+ <%= if @contact do %> + + + {@contact.display_name} + + <% else %> + Unknown contact + <% end %> + +
+ """ + end + + defp find_selected_contact(contact, selected_id) do + id = if is_binary(selected_id), do: String.to_integer(selected_id), else: selected_id + + if contact.first_met_through && contact.first_met_through.id == id do + contact.first_met_through + else + try do + Contacts.get_contact!(contact.account_id, id) + rescue + Ecto.NoResultsError -> nil + end + end + end + + defp has_first_met_data?(contact) do + contact.first_met_at != nil or + contact.first_met_where not in [nil, ""] or + contact.first_met_through_id != nil or + contact.first_met_additional_info not in [nil, ""] + end +end diff --git a/lib/kith_web/live/contact_live/activity_stream_component.ex b/lib/kith_web/live/contact_live/activity_stream_component.ex new file mode 100644 index 0000000..8a786f9 --- /dev/null +++ b/lib/kith_web/live/contact_live/activity_stream_component.ex @@ -0,0 +1,1022 @@ +defmodule KithWeb.ContactLive.ActivityStreamComponent do + @moduledoc """ + Unified activity timeline component that merges all entry types + (notes, calls, life events, activities, tasks, gifts, conversations, photos) + into a single chronological stream with multi-select filtering. + """ + use KithWeb, :live_component + + alias Kith.Activities + alias Kith.Contacts + alias Kith.Contacts.ActivityStream + alias Kith.Conversations + alias Kith.Gifts + alias Kith.Storage + alias Kith.Tasks + + @impl true + def mount(socket) do + {:ok, + socket + |> assign(:entries, []) + |> assign(:active_filters, MapSet.new()) + |> assign(:all_types, ActivityStream.all_types()) + |> assign(:modal_type, nil) + |> assign(:modal_entry_id, nil) + |> assign(:modal_error, nil) + |> allow_upload(:photo, + accept: ~w(.jpg .jpeg .png .gif .webp), + max_entries: 5, + max_file_size: 10_000_000 + )} + end + + @impl true + def update(assigns, socket) do + socket = assign(socket, assigns) + {:ok, load_entries(socket)} + end + + # --- Filter events --- + + @impl true + def handle_event("filter-toggle", %{"type" => type_str}, socket) do + type = String.to_existing_atom(type_str) + filters = socket.assigns.active_filters + + filters = + if MapSet.member?(filters, type), + do: MapSet.delete(filters, type), + else: MapSet.put(filters, type) + + {:noreply, socket |> assign(:active_filters, filters) |> load_entries()} + end + + def handle_event("filter-clear", _params, socket) do + {:noreply, socket |> assign(:active_filters, MapSet.new()) |> load_entries()} + end + + # --- Modal events --- + + def handle_event("open-entry-modal", %{"type" => type_str}, socket) do + type = String.to_existing_atom(type_str) + + {:noreply, + socket + |> assign(:modal_type, type) + |> assign(:modal_entry_id, nil) + |> assign(:modal_error, nil)} + end + + def handle_event("edit-entry", %{"type" => type_str, "id" => id_str}, socket) do + type = String.to_existing_atom(type_str) + id = String.to_integer(id_str) + + {:noreply, + socket + |> assign(:modal_type, type) + |> assign(:modal_entry_id, id) + |> assign(:modal_error, nil)} + end + + def handle_event("close-modal", _params, socket) do + {:noreply, + socket + |> assign(:modal_type, nil) + |> assign(:modal_entry_id, nil) + |> assign(:modal_error, nil)} + end + + def handle_event("save-entry", params, socket) do + %{modal_type: type, modal_entry_id: entry_id} = socket.assigns + + result = + if entry_id, + do: update_entry(type, entry_id, params, socket.assigns), + else: create_entry(type, params, socket.assigns) + + case result do + {:ok, _} -> + {:noreply, + socket + |> assign(:modal_type, nil) + |> assign(:modal_entry_id, nil) + |> assign(:modal_error, nil) + |> load_entries()} + + {:error, message} -> + {:noreply, assign(socket, :modal_error, message)} + end + end + + def handle_event("delete-entry", %{"type" => type_str, "id" => id_str}, socket) do + type = String.to_existing_atom(type_str) + id = String.to_integer(id_str) + + case delete_entry(type, id, socket.assigns) do + {:ok, _} -> + {:noreply, + socket + |> assign(:modal_type, nil) + |> assign(:modal_entry_id, nil) + |> load_entries()} + + {:error, _} -> + {:noreply, socket} + end + end + + # --- Photo upload events --- + + def handle_event("validate-photo", _params, socket) do + {:noreply, socket} + end + + def handle_event("upload-photo", _params, socket) do + contact_id = socket.assigns.contact_id + + uploaded_photos = + consume_uploaded_entries(socket, :photo, fn %{path: path}, entry -> + key = "contacts/#{contact_id}/photos/#{entry.uuid}-#{entry.client_name}" + {:ok, _} = Storage.upload(path, key) + + {:ok, photo} = + Contacts.create_photo( + Contacts.get_contact!(socket.assigns.account_id, contact_id), + %{ + file_name: entry.client_name, + storage_key: key, + file_size: entry.client_size, + content_type: entry.client_type + } + ) + + {:ok, photo} + end) + + if uploaded_photos != [] do + {:noreply, + socket + |> assign(:modal_type, nil) + |> load_entries() + |> put_flash(:info, "#{length(uploaded_photos)} photo(s) uploaded.")} + else + {:noreply, socket} + end + end + + def handle_event("cancel-photo-upload", %{"ref" => ref}, socket) do + {:noreply, cancel_upload(socket, :photo, ref)} + end + + # --- CRUD helpers --- + + defp create_entry(:note, %{"entry" => params}, assigns) do + contact = Contacts.get_contact!(assigns.account_id, assigns.contact_id) + + Contacts.create_note(contact, assigns.current_user_id, params) + |> normalize_result() + end + + defp create_entry(:call, %{"entry" => params}, assigns) do + contact = Contacts.get_contact!(assigns.account_id, assigns.contact_id) + Activities.create_call(contact, params) |> normalize_result() + end + + defp create_entry(:life_event, %{"entry" => params}, assigns) do + contact = Contacts.get_contact!(assigns.account_id, assigns.contact_id) + Activities.create_life_event(contact, params) |> normalize_result() + end + + defp create_entry(:task, %{"entry" => params}, assigns) do + params = Map.put(params, "contact_id", assigns.contact_id) + Tasks.create_task(assigns.account_id, assigns.current_user_id, params) |> normalize_result() + end + + defp create_entry(:gift, %{"entry" => params}, assigns) do + params = Map.put(params, "contact_id", assigns.contact_id) + Gifts.create_gift(assigns.account_id, assigns.current_user_id, params) |> normalize_result() + end + + defp create_entry(:conversation, %{"entry" => params}, assigns) do + params = Map.put(params, "contact_id", assigns.contact_id) + + Conversations.create_conversation(assigns.account_id, assigns.current_user_id, params) + |> normalize_result() + end + + defp create_entry(:activity, %{"entry" => params}, assigns) do + Activities.create_activity(assigns.account_id, params, [assigns.contact_id]) + |> normalize_result() + end + + defp create_entry(_, _, _), do: {:error, "Unsupported type"} + + defp update_entry(:note, id, %{"entry" => params}, assigns) do + note = Contacts.get_note!(assigns.account_id, id, assigns.current_user_id) + Contacts.update_note(note, params) |> normalize_result() + end + + defp update_entry(:call, id, %{"entry" => params}, assigns) do + call = Activities.get_call!(assigns.account_id, id) + Activities.update_call(call, params) |> normalize_result() + end + + defp update_entry(:task, id, %{"entry" => params}, assigns) do + task = Tasks.get_task!(assigns.account_id, id) + Tasks.update_task(task, params) |> normalize_result() + end + + defp update_entry(:gift, id, %{"entry" => params}, assigns) do + gift = Gifts.get_gift!(assigns.account_id, id) + Gifts.update_gift(gift, params) |> normalize_result() + end + + defp update_entry(_, _, _, _), do: {:error, "Update not supported for this type"} + + defp delete_entry(:note, id, assigns) do + note = Contacts.get_note!(assigns.account_id, id, assigns.current_user_id) + Contacts.delete_note(note) |> normalize_result() + end + + defp delete_entry(:call, id, assigns) do + call = Activities.get_call!(assigns.account_id, id) + Activities.delete_call(call) |> normalize_result() + end + + defp delete_entry(:task, id, assigns) do + task = Tasks.get_task!(assigns.account_id, id) + Tasks.delete_task(task) |> normalize_result() + end + + defp delete_entry(:gift, id, assigns) do + gift = Gifts.get_gift!(assigns.account_id, id) + Gifts.delete_gift(gift) |> normalize_result() + end + + defp delete_entry(_, _, _), do: {:error, "Delete not supported for this type"} + + defp normalize_result({:ok, result}), do: {:ok, result} + defp normalize_result({:error, %Ecto.Changeset{} = cs}), do: {:error, changeset_error(cs)} + defp normalize_result({:error, _, %Ecto.Changeset{} = cs, _}), do: {:error, changeset_error(cs)} + defp normalize_result({:error, msg}) when is_binary(msg), do: {:error, msg} + defp normalize_result({:error, _}), do: {:error, "Something went wrong"} + + defp changeset_error(changeset) do + Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} -> + Regex.replace(~r"%{(\w+)}", msg, fn _, key -> + opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string() + end) + end) + |> Enum.map_join(", ", fn {field, errors} -> "#{field}: #{Enum.join(errors, ", ")}" end) + end + + # --- Data loading --- + + defp load_entries(socket) do + %{account_id: account_id, contact_id: contact_id, current_user_id: current_user_id} = + socket.assigns + + filters = socket.assigns.active_filters + opts = [current_user_id: current_user_id, limit: 50] + + opts = + if MapSet.size(filters) > 0, + do: Keyword.put(opts, :types, MapSet.to_list(filters)), + else: opts + + entries = ActivityStream.list_activity(account_id, contact_id, opts) + assign(socket, :entries, entries) + end + + defp photos_only?(filters) do + MapSet.size(filters) == 1 and MapSet.member?(filters, :photo) + end + + # --- Render --- + + @impl true + def render(assigns) do + ~H""" +
+ <%!-- Stream Header --%> +
+
+ Activity +
+ <.filter_dropdown + active_filters={@active_filters} + all_types={@all_types} + target={@myself} + /> + + + + <%!-- More types overflow --%> +
+ +
+ +
+
+
+
+
0} class="mt-2.5"> + <.filter_chips active_filters={@active_filters} target={@myself} /> +
+
+ + <%!-- Entry Modal --%> + <.modal + :if={@modal_type} + id="entry-modal" + show + on_cancel={JS.push("close-modal", target: @myself)} + > + <.entry_modal_content + type={@modal_type} + entry_id={@modal_entry_id} + error={@modal_error} + myself={@myself} + account_id={@account_id} + contact_id={@contact_id} + uploads={@uploads} + /> + + + <%!-- Photos Gallery Mode --%> +
0} + class="grid grid-cols-3 sm:grid-cols-4 gap-2" + > +
+
+ <.icon name="hero-photo" class="size-6" /> +
+
+
+ + <%!-- Timeline Mode --%> +
+
+ +
+ <.icon name="hero-clock" class="size-8 text-[var(--color-text-disabled)] mx-auto mb-2" /> +

No activity yet

+

+ Add a note, log a call, or record an event to get started. +

+
+ +
+ <.timeline_dot type={entry.type} /> + +
+
+ + <.relative_time_or_date datetime={entry.occurred_at} /> + + <.type_badge type={entry.type} /> + + {duration_text(entry.record.duration_mins)} + + + {entry.record.platform} + +
+ <%!-- Entry overflow menu --%> +
+ +
+ + +
+
+
+ +
+ + {entry.title} + + + {entry.title} + +
+ +
+ {if entry.type == :note, do: strip_html(entry.body), else: entry.body} +
+ +
+
+ <.icon name="hero-photo" class="size-5" /> +
+
+
+
+
+ """ + end + + # --- Modal form content per type --- + + defp entry_modal_content(%{type: :note} = assigns) do + ~H""" +
+

{if @entry_id, do: "Edit Note", else: "New Note"}

+ <.entry_error error={@error} /> + <.form for={%{}} phx-submit="save-entry" phx-target={@myself}> +
+
+ +