diff --git a/CLAUDE.md b/CLAUDE.md index 5c1dbc1..12f446f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -135,3 +135,5 @@ Use the following principles when making changes to this project: 1. Prioritize pheonix framework code generators over coding from scratch. Utilize these tools to bootstrap the core domain models and structure of the app. You should not be creating too much custom code except within the concrete implementation of services that the generators have bootstrapped. 2. When working on front end code, make sure that every new component or UI change conforms do the existing design language. Prioritize reusability instead of customizing every individual piece. + +3. After making any new feature change, add any relevant tests in and make sure that all tests pass. diff --git a/lib/split_app/expenses.ex b/lib/split_app/expenses.ex index d1c731a..9fa7a52 100644 --- a/lib/split_app/expenses.ex +++ b/lib/split_app/expenses.ex @@ -87,6 +87,44 @@ defmodule SplitApp.Expenses do |> Repo.insert() end + @doc """ + Creates an expense with associated groups. + + ## Examples + + iex> create_expense_with_groups(%{field: value}, [1, 2]) + {:ok, %Expense{}} + + iex> create_expense_with_groups(%{field: bad_value}, []) + {:error, %Ecto.Changeset{}} + + """ + def create_expense_with_groups(attrs, group_ids) when is_list(group_ids) do + Repo.transaction(fn -> + with {:ok, expense} <- create_expense(attrs) do + now = DateTime.utc_now() |> DateTime.truncate(:second) + + expense_groups = + Enum.map(group_ids, fn group_id -> + %{ + expense_id: expense.id, + group_id: group_id, + inserted_at: now, + updated_at: now + } + end) + + if expense_groups != [] do + {_count, _} = Repo.insert_all("expense_groups", expense_groups) + end + + get_expense_with_associations!(expense.id) + else + {:error, changeset} -> Repo.rollback(changeset) + end + end) + end + @doc """ Updates a expense. @@ -105,6 +143,28 @@ defmodule SplitApp.Expenses do |> Repo.update() end + @doc """ + Updates an expense with group associations. + + ## Examples + + iex> update_expense_with_groups(expense, %{field: new_value}, [group1, group2]) + {:ok, %Expense{}} + + iex> update_expense_with_groups(expense, %{field: bad_value}, []) + {:error, %Ecto.Changeset{}} + + """ + def update_expense_with_groups(%Expense{} = expense, attrs, group_ids) + when is_list(group_ids) do + groups = SplitApp.Groups.get_groups_by_ids(group_ids) + + expense + |> Repo.preload(:groups) + |> Expense.changeset_with_groups(attrs, groups) + |> Repo.update() + end + @doc """ Deletes a expense. diff --git a/lib/split_app/expenses/expense.ex b/lib/split_app/expenses/expense.ex index 2a7cab4..ddbaa4c 100644 --- a/lib/split_app/expenses/expense.ex +++ b/lib/split_app/expenses/expense.ex @@ -22,6 +22,13 @@ defmodule SplitApp.Expenses.Expense do |> validate_money(:amount) end + @doc false + def changeset(expense, attrs, groups) do + expense + |> changeset(attrs) + |> put_assoc(:groups, groups) + end + defp validate_money(changeset, field) do validate_change(changeset, field, fn _, %Money{amount: amount, currency: currency} when amount > 0 and not is_nil(currency) -> [] diff --git a/lib/split_app/groups.ex b/lib/split_app/groups.ex index e3edf5c..fc334f5 100644 --- a/lib/split_app/groups.ex +++ b/lib/split_app/groups.ex @@ -40,6 +40,23 @@ defmodule SplitApp.Groups do """ def get_group!(id), do: Repo.get!(Group, id) + @doc """ + Gets multiple groups by their IDs. + + ## Examples + + iex> get_groups_by_ids([1, 2, 3]) + [%Group{}, %Group{}, ...] + + iex> get_groups_by_ids([]) + [] + + """ + def get_groups_by_ids(ids) when is_list(ids) do + from(g in Group, where: g.id in ^ids) + |> Repo.all() + end + @doc """ Gets a single group only if the user is a member. diff --git a/lib/split_app_web/live/dashboard_live.ex b/lib/split_app_web/live/dashboard_live.ex index 7914e28..350bf2d 100644 --- a/lib/split_app_web/live/dashboard_live.ex +++ b/lib/split_app_web/live/dashboard_live.ex @@ -1,7 +1,9 @@ defmodule SplitAppWeb.DashboardLive do use SplitAppWeb, :live_view alias SplitApp.Groups + alias SplitApp.Groups.Group alias SplitApp.Expenses + alias SplitApp.Expenses.Expense @impl true def mount(_params, _session, socket) do @@ -13,7 +15,88 @@ defmodule SplitAppWeb.DashboardLive do socket |> assign(:groups, groups) |> assign(:recent_expenses, recent_expenses) - |> assign(:page_title, "Dashboard")} + |> assign(:page_title, "Dashboard") + |> assign(:show_expense_modal, false) + |> assign(:expense, nil) + |> assign(:show_group_modal, false) + |> assign(:group, nil)} + end + + @impl true + def handle_params(_params, _url, socket) do + {:noreply, socket} + end + + @impl true + def handle_event("new_expense", _params, socket) do + {:noreply, + socket + |> assign(:show_expense_modal, true) + |> assign(:expense, %Expense{created_by_id: socket.assigns.current_user.id})} + end + + @impl true + def handle_event("new_group", _params, socket) do + {:noreply, + socket + |> assign(:show_group_modal, true) + |> assign(:group, %Group{})} + end + + @impl true + def handle_event("close_modal", _params, socket) do + {:noreply, + socket + |> assign(:show_expense_modal, false) + |> assign(:expense, nil) + |> assign(:show_group_modal, false) + |> assign(:group, nil)} + end + + @impl true + def handle_event("close_expense_modal", _params, socket) do + {:noreply, + socket + |> assign(:show_expense_modal, false) + |> assign(:expense, nil)} + end + + @impl true + def handle_event("close_group_modal", _params, socket) do + {:noreply, + socket + |> assign(:show_group_modal, false) + |> assign(:group, nil)} + end + + @impl true + def handle_info({SplitAppWeb.ExpenseLive.FormComponent, {:saved, _expense}}, socket) do + user = socket.assigns.current_user + recent_expenses = Expenses.list_expenses_by_user(user.id) |> Enum.take(5) + + {:noreply, + socket + |> assign(:recent_expenses, recent_expenses) + |> assign(:show_expense_modal, false) + |> assign(:expense, nil) + |> put_flash(:info, "Expense created successfully")} + end + + @impl true + def handle_info({SplitAppWeb.GroupLive.FormComponent, {:saved, _group}}, socket) do + user = socket.assigns.current_user + groups = Groups.list_user_groups(user) + + {:noreply, + socket + |> assign(:groups, groups) + |> assign(:show_group_modal, false) + |> assign(:group, nil)} + end + + @impl true + def handle_info({SplitAppWeb.GroupLive.FormComponent, {:put_flash, {type, message}}}, socket) do + {:noreply, put_flash(socket, type, message)} end @impl true @@ -27,7 +110,7 @@ defmodule SplitAppWeb.DashboardLive do
- <.link navigate={~p"/expenses/new"} class="group"> + - <.link navigate={~p"/groups/new"} class="group"> +
@@ -230,6 +313,40 @@ defmodule SplitAppWeb.DashboardLive do + + <.modal + :if={@show_expense_modal} + id="expense-modal" + show + on_cancel={JS.push("close_expense_modal")} + > + <.live_component + module={SplitAppWeb.ExpenseLive.FormComponent} + id={:new} + title="New Expense" + action={:new} + expense={@expense} + current_user={@current_user} + patch={~p"/dashboard"} + /> + + + <.modal + :if={@show_group_modal} + id="group-modal" + show + on_cancel={JS.push("close_group_modal")} + > + <.live_component + module={SplitAppWeb.GroupLive.FormComponent} + id={:new} + title="New Group" + action={:new} + group={@group} + current_user={@current_user} + patch={~p"/dashboard"} + /> + """ end diff --git a/lib/split_app_web/live/expense_live/form_component.ex b/lib/split_app_web/live/expense_live/form_component.ex index 05de34c..cf397a3 100644 --- a/lib/split_app_web/live/expense_live/form_component.ex +++ b/lib/split_app_web/live/expense_live/form_component.ex @@ -3,6 +3,7 @@ defmodule SplitAppWeb.ExpenseLive.FormComponent do alias SplitApp.Expenses alias SplitApp.Expenses.Expense + alias SplitApp.Groups @impl true def render(assigns) do @@ -30,6 +31,13 @@ defmodule SplitAppWeb.ExpenseLive.FormComponent do options={[{"USD", "USD"}, {"EUR", "EUR"}, {"GBP", "GBP"}]} value="USD" /> + <.input + field={@form[:group_ids]} + type="select" + label="Groups" + multiple={true} + options={@group_options} + /> <:actions> <.button phx-disable-with="Saving...">Save Expense @@ -42,11 +50,33 @@ defmodule SplitAppWeb.ExpenseLive.FormComponent do def update(%{expense: expense} = assigns, socket) do changeset = prepare_changeset_for_form(expense) + # Get the current user's groups for the dropdown + group_options = + if assigns.current_user do + Groups.list_user_groups(assigns.current_user) + |> Enum.map(fn group -> {group.name, group.id} end) + else + [] + end + + # Get current expense groups if editing + selected_groups = + if expense.id do + loaded_expense = Expenses.get_expense_with_associations!(expense.id) + Enum.map(loaded_expense.groups, & &1.id) + else + [] + end + {:ok, socket |> assign(assigns) + |> assign(:group_options, group_options) + |> assign(:selected_groups, selected_groups) |> assign_new(:form, fn -> - to_form(changeset) + changeset + |> Map.put(:changes, Map.put(changeset.changes, :group_ids, selected_groups)) + |> to_form() end)} end @@ -63,7 +93,24 @@ defmodule SplitAppWeb.ExpenseLive.FormComponent do end defp save_expense(socket, :edit, expense_params) do - case Expenses.update_expense(socket.assigns.expense, expense_params) do + # Extract group_ids and remove from params + {group_ids, params_without_groups} = Map.pop(expense_params, "group_ids", []) + + # Convert group_ids to integers + group_ids = Enum.map(group_ids || [], &String.to_integer/1) + + result = + if group_ids == [] do + Expenses.update_expense(socket.assigns.expense, params_without_groups) + else + Expenses.update_expense_with_groups( + socket.assigns.expense, + params_without_groups, + group_ids + ) + end + + case result do {:ok, expense} -> notify_parent({:saved, expense}) @@ -81,7 +128,20 @@ defmodule SplitAppWeb.ExpenseLive.FormComponent do params_with_user = Map.put(expense_params, "created_by_id", socket.assigns.expense.created_by_id) - case Expenses.create_expense(params_with_user) do + # Extract group_ids and remove from params + {group_ids, params_without_groups} = Map.pop(params_with_user, "group_ids", []) + + # Convert group_ids to integers + group_ids = Enum.map(group_ids || [], &String.to_integer/1) + + result = + if group_ids == [] do + Expenses.create_expense(params_without_groups) + else + Expenses.create_expense_with_groups(params_without_groups, group_ids) + end + + case result do {:ok, expense} -> notify_parent({:saved, expense}) diff --git a/lib/split_app_web/live/expense_live/index.ex b/lib/split_app_web/live/expense_live/index.ex index 0c325f2..d54f5c9 100644 --- a/lib/split_app_web/live/expense_live/index.ex +++ b/lib/split_app_web/live/expense_live/index.ex @@ -44,11 +44,14 @@ defmodule SplitAppWeb.ExpenseLive.Index do end @impl true - def handle_info({SplitAppWeb.ExpenseLive.FormComponent, {:saved, expense}}, socket) do + def handle_info({SplitAppWeb.ExpenseLive.FormComponent, {:saved, _expense}}, socket) do + current_user = socket.assigns.current_user + expenses = if current_user, do: Expenses.list_expenses_by_user(current_user.id), else: [] + {:noreply, socket - |> stream_insert(:expenses, expense) - |> assign(:expenses_count, socket.assigns.expenses_count + 1)} + |> stream(:expenses, expenses, reset: true) + |> assign(:expenses_count, length(expenses))} end @impl true diff --git a/lib/split_app_web/live/expense_live/index.html.heex b/lib/split_app_web/live/expense_live/index.html.heex index 237e34e..79b66cf 100644 --- a/lib/split_app_web/live/expense_live/index.html.heex +++ b/lib/split_app_web/live/expense_live/index.html.heex @@ -78,6 +78,7 @@ title={@page_title} action={@live_action} expense={@expense} + current_user={@current_user} patch={~p"/expenses"} /> diff --git a/lib/split_app_web/live/expense_live/show.ex b/lib/split_app_web/live/expense_live/show.ex index 007aece..899bb34 100644 --- a/lib/split_app_web/live/expense_live/show.ex +++ b/lib/split_app_web/live/expense_live/show.ex @@ -12,10 +12,12 @@ defmodule SplitAppWeb.ExpenseLive.Show do @impl true def handle_params(%{"id" => id}, _, socket) do + expense = Expenses.get_expense_with_associations!(id) + {:noreply, socket |> assign(:page_title, page_title(socket.assigns.live_action)) - |> assign(:expense, Expenses.get_expense!(id))} + |> assign(:expense, expense)} end defp page_title(:show), do: "Show Expense" diff --git a/lib/split_app_web/live/expense_live/show.html.heex b/lib/split_app_web/live/expense_live/show.html.heex index 068dd5d..0bd16e7 100644 --- a/lib/split_app_web/live/expense_live/show.html.heex +++ b/lib/split_app_web/live/expense_live/show.html.heex @@ -40,6 +40,25 @@
Created
{format_date(@expense.inserted_at)}
+
+
Groups
+
+
0} + class="flex flex-wrap gap-2 mt-1" + > + + {group.name} + +
+ + No groups assigned + +
+
@@ -51,6 +70,7 @@ title={@page_title} action={@live_action} expense={@expense} + current_user={@current_user} patch={~p"/expenses/#{@expense}"} /> diff --git a/test/split_app_web/live/dashboard_live_test.exs b/test/split_app_web/live/dashboard_live_test.exs new file mode 100644 index 0000000..326cd6d --- /dev/null +++ b/test/split_app_web/live/dashboard_live_test.exs @@ -0,0 +1,248 @@ +defmodule SplitAppWeb.DashboardLiveTest do + use SplitAppWeb.ConnCase + + import Phoenix.LiveViewTest + import SplitApp.ExpensesFixtures + import SplitApp.GroupsFixtures + + @create_expense_attrs %{ + description: "Test expense from dashboard", + title: "Dashboard Expense", + amount_display: "35.50", + currency: "USD" + } + + describe "Dashboard" do + setup [:register_and_log_in_user] + + test "displays welcome message and dashboard sections", %{conn: conn, user: user} do + {:ok, _live, html} = live(conn, ~p"/dashboard") + + assert html =~ "Welcome, #{user.email}!" + assert html =~ "Recent Expenses" + assert html =~ "Your Groups" + assert html =~ "Add New Expense" + assert html =~ "Create New Group" + end + + test "opens expense modal when clicking Add New Expense", %{conn: conn} do + {:ok, live, _html} = live(conn, ~p"/dashboard") + + # Click the "Add New Expense" button + assert live + |> element("button", "Add New Expense") + |> render_click() + + # Verify modal is shown + assert has_element?(live, "#expense-modal") + assert render(live) =~ "New Expense" + end + + test "creates expense through modal and updates dashboard", %{conn: conn} do + {:ok, live, _html} = live(conn, ~p"/dashboard") + + # Initially no expenses + assert render(live) =~ "No expenses yet" + + # Open modal + live + |> element("button", "Add New Expense") + |> render_click() + + # Fill and submit form + assert live + |> form("#expense-form", expense: @create_expense_attrs) + |> render_submit() + + # Wait for the update + :timer.sleep(100) + + # Verify we're still on the dashboard (not redirected) + html = render(live) + assert html =~ "Welcome" + + # Verify expense was created and shows in recent expenses + assert html =~ "Dashboard Expense" + assert html =~ "Test expense from dashboard" + + # Verify modal is closed + refute has_element?(live, "#expense-modal") + + # Verify flash message + assert html =~ "Expense created successfully" + end + + test "closes modal when cancel is clicked and stays on dashboard", %{conn: conn} do + {:ok, live, _html} = live(conn, ~p"/dashboard") + + # Open modal + live + |> element("button", "Add New Expense") + |> render_click() + + # Verify modal is shown + assert has_element?(live, "#expense-modal") + + # Close modal by triggering the JS command (simulating the X button click) + # The modal's on_cancel triggers JS.push("close_expense_modal") + assert live |> render_click("close_expense_modal", %{}) + + # Verify modal is closed + refute has_element?(live, "#expense-modal") + + # Verify we're still on the dashboard + assert render(live) =~ "Welcome" + assert render(live) =~ "Recent Expenses" + end + + test "displays existing expenses in recent expenses section", %{conn: conn, user: user} do + # Create some expenses + expense1 = expense_fixture(%{ + created_by_id: user.id, + title: "First Expense", + description: "First Description" + }) + + expense2 = expense_fixture(%{ + created_by_id: user.id, + title: "Second Expense", + description: "Second Description" + }) + + {:ok, _live, html} = live(conn, ~p"/dashboard") + + # Verify expenses are shown + assert html =~ expense1.title + assert html =~ expense1.description + assert html =~ expense2.title + assert html =~ expense2.description + end + + test "displays existing groups in groups section", %{conn: conn, user: user} do + # Create a group and add user to it + group = group_fixture_with_user(user, %{name: "Test Group"}) + + {:ok, _live, html} = live(conn, ~p"/dashboard") + + # Verify group is shown + assert html =~ group.name + end + + test "modal form validation shows errors", %{conn: conn} do + {:ok, live, _html} = live(conn, ~p"/dashboard") + + # Open modal + live + |> element("button", "Add New Expense") + |> render_click() + + # Submit invalid form + assert live + |> form("#expense-form", expense: %{title: nil, amount_display: nil}) + |> render_change() =~ "can't be blank" + end + + test "expense modal allows selecting groups", %{conn: conn, user: user} do + # Create a group first and add user to it + group = group_fixture_with_user(user, %{name: "Test Group for Expense"}) + + {:ok, live, _html} = live(conn, ~p"/dashboard") + + # Open modal + live + |> element("button", "Add New Expense") + |> render_click() + + # Verify group is available in the form + html = render(live) + assert html =~ group.name + assert has_element?(live, "#expense-form select[name='expense[group_ids][]']") + end + + test "opens group modal when clicking Create New Group", %{conn: conn} do + {:ok, live, _html} = live(conn, ~p"/dashboard") + + # Click the "Create New Group" button + assert live + |> element("button", "Create New Group") + |> render_click() + + # Verify modal is shown + assert has_element?(live, "#group-modal") + assert render(live) =~ "New Group" + end + + test "creates group through modal and updates dashboard", %{conn: conn} do + {:ok, live, _html} = live(conn, ~p"/dashboard") + + # Initially no groups + assert render(live) =~ "No groups yet" + + # Open modal + live + |> element("button", "Create New Group") + |> render_click() + + # Fill and submit form + assert live + |> form("#group-form", group: %{ + name: "Dashboard Test Group", + description: "Created from dashboard" + }) + |> render_submit() + + # Wait for the update + :timer.sleep(100) + + # Verify we're still on the dashboard (not redirected) + html = render(live) + assert html =~ "Welcome" + + # Verify group was created and shows in groups section + assert html =~ "Dashboard Test Group" + assert html =~ "Created from dashboard" + + # Verify modal is closed + refute has_element?(live, "#group-modal") + + # Verify flash message + assert html =~ "Group created successfully" + end + + test "closes group modal when cancel is clicked and stays on dashboard", %{conn: conn} do + {:ok, live, _html} = live(conn, ~p"/dashboard") + + # Open modal + live + |> element("button", "Create New Group") + |> render_click() + + # Verify modal is shown + assert has_element?(live, "#group-modal") + + # Close modal by triggering the JS command + assert live |> render_click("close_group_modal", %{}) + + # Verify modal is closed + refute has_element?(live, "#group-modal") + + # Verify we're still on the dashboard + assert render(live) =~ "Welcome" + assert render(live) =~ "Your Groups" + end + + test "group modal form validation shows errors", %{conn: conn} do + {:ok, live, _html} = live(conn, ~p"/dashboard") + + # Open modal + live + |> element("button", "Create New Group") + |> render_click() + + # Submit invalid form + assert live + |> form("#group-form", group: %{name: nil}) + |> render_change() =~ "can't be blank" + end + end +end \ No newline at end of file