From 2938d7b448041920b448de5ef69df1a10b34c9e7 Mon Sep 17 00:00:00 2001 From: panoramix360 Date: Mon, 15 Jan 2024 11:13:55 -0300 Subject: [PATCH 1/4] chore: update the stats docs based on last changes --- src/mvp/20-stats.md | 132 +++++++++++++++++++++++++++++++------------- 1 file changed, 94 insertions(+), 38 deletions(-) diff --git a/src/mvp/20-stats.md b/src/mvp/20-stats.md index 0d48ab9..b22b123 100644 --- a/src/mvp/20-stats.md +++ b/src/mvp/20-stats.md @@ -416,7 +416,7 @@ With that in place, we can update our Stats page to use this LiveComponent in a highlight={&is_highlighted_person?(&1, @person_id)} > <:column :let={metric} label="Id" key="person_id"> - + <%= metric.person_id %> @@ -424,25 +424,25 @@ With that in place, we can update our Stats page to use this LiveComponent in a <:column :let={metric} label="Items" key="num_items"> - + <%= metric.num_items %> <:column :let={metric} label="Timers" key="num_timers"> - + <%= metric.num_timers %> <:column :let={metric} label="First Joined" key="first_inserted_at"> - + <%= format_date(metric.first_inserted_at) %> <:column :let={metric} label="Last Item Inserted" key="last_inserted_at"> - + <%= format_date(metric.last_inserted_at) %> @@ -452,7 +452,7 @@ With that in place, we can update our Stats page to use this LiveComponent in a label="Total Elapsed Time" key="total_timers_in_seconds" > - + <%= format_seconds(metric.total_timers_in_seconds) %> @@ -1036,83 +1036,138 @@ end `test/app_web/live/stats_live_test.exs` ```elixir defmodule AppWeb.StatsLiveTest do - alias App.DateTimeHelper - - ... + # alias App.DateTimeHelper + use AppWeb.ConnCase, async: true + alias App.{Item, Timer, DateTimeHelper} + import Phoenix.LiveViewTest + + @person_id 55 + + test "disconnected and connected render", %{conn: conn} do + {:ok, page_live, disconnected_html} = live(conn, "/stats") + assert disconnected_html =~ "Stats" + assert render(page_live) =~ "Stats" + end test "display metrics on mount", %{conn: conn} do - ... - # Creating one timer + # Creating two items + {:ok, %{model: item, version: _version}} = + Item.create_item(%{text: "Learn Elixir", status: 2, person_id: @person_id}) + + {:ok, %{model: _item2, version: _version}} = + Item.create_item(%{text: "Learn Elixir", status: 4, person_id: @person_id}) + + assert item.status == 2 + + # Creating one timer started = NaiveDateTime.utc_now() {:ok, timer} = Timer.start(%{item_id: item.id, start: started}) {:ok, _} = Timer.stop(%{id: timer.id}) - ... + {:ok, page_live, _html} = live(conn, "/stats") + + assert render(page_live) =~ "Stats" # two items and one timer expected - assert page_live |> element("td[data-test-id=person_id]") |> render() =~ + assert page_live |> element("td[data-test-id=person_id_55]") |> render() =~ "55" - assert page_live |> element("td[data-test-id=num_items]") |> render() =~ "2" + assert page_live |> element("td[data-test-id=num_items_55]") |> render() =~ "2" - assert page_live |> element("td[data-test-id=num_timers]") |> render() =~ - "1" + assert page_live |> render() =~ "1" assert page_live - |> element("td[data-test-id=first_inserted_at]") + |> element("td[data-test-id=first_inserted_at_55]") |> render() =~ DateTimeHelper.format_date(started) assert page_live - |> element("td[data-test-id=last_inserted_at]") + |> element("td[data-test-id=last_inserted_at_55]") |> render() =~ DateTimeHelper.format_date(started) assert page_live - |> element("td[data-test-id=total_timers_in_seconds]") + |> element("td[data-test-id=total_timers_in_seconds_55]") |> render() =~ "" end test "handle broadcast when item is created", %{conn: conn} do - ... + # Creating an item + {:ok, %{model: _item, version: _version}} = + Item.create_item(%{text: "Learn Elixir", status: 2, person_id: @person_id}) + + {:ok, page_live, _html} = live(conn, "/stats") assert render(page_live) =~ "Stats" - # num of items - assert page_live |> element("td[data-test-id=num_items]") |> render() =~ "1" - ... + assert page_live |> element("td[data-test-id=num_items_55]") |> render() =~ "5" + + # Creating another item. + AppWeb.Endpoint.broadcast( + "stats", + "item", + {:create, payload: %{person_id: @person_id}} + ) # num of items - assert page_live |> element("td[data-test-id=num_items]") |> render() =~ "2" + assert page_live |> element("td[data-test-id=num_items_55]") |> render() =~ "2" - ... + # Broadcasting update. Shouldn't effect anything in the page + AppWeb.Endpoint.broadcast( + "stats", + "item", + {:update, payload: %{person_id: @person_id}} + ) # num of items - assert page_live |> element("td[data-test-id=num_items]") |> render() =~ "2" + assert page_live |> element("td[data-test-id=num_items_55]") |> render() =~ "2" end test "handle broadcast when timer is created", %{conn: conn} do - ... + # Creating an item + {:ok, %{model: _item, version: _version}} = + Item.create_item(%{text: "Learn Elixir", status: 2, person_id: @person_id}) + + {:ok, page_live, _html} = live(conn, "/stats") assert render(page_live) =~ "Stats" - # num of timers - assert page_live |> element("td[data-test-id=num_timers]") |> render() =~ + assert page_live |> element("td[data-test-id=num_timers_55]") |> render() =~ "0" - ... - # num of timers - assert page_live |> element("td[data-test-id=num_timers]") |> render() =~ + # Creating a timer. + AppWeb.Endpoint.broadcast( + "stats", + "timer", + {:create, payload: %{person_id: @person_id}} + ) + + assert page_live |> element("td[data-test-id=num_timers_55]") |> render() =~ "1" - ... + # Broadcasting update. Shouldn't effect anything in the page + AppWeb.Endpoint.broadcast( + "stats", + "timer", + {:update, payload: %{person_id: @person_id}} + ) # num of timers - assert page_live |> element("td[data-test-id=num_timers]") |> render() =~ - "1" + assert page_live |> render() =~ "1" end - ... + test "add_row/3 adds 1 to row.num_timers" do + row = %{person_id: 1, num_items: 1, num_timers: 1} + payload = %{person_id: 1} + + # expect row.num_timers to be incremented by 1: + row_updated = AppWeb.StatsLive.add_row(row, payload, :num_timers) + assert row_updated == %{person_id: 1, num_items: 1, num_timers: 2} + + # no change expected: + row2 = %{person_id: 2, num_items: 1, num_timers: 42} + assert row2 == AppWeb.StatsLive.add_row(row2, payload, :num_timers) + end test "sorting column when clicked", %{conn: conn} do {:ok, %{model: _, version: _version}} = @@ -1127,7 +1182,7 @@ defmodule AppWeb.StatsLiveTest do result = page_live |> element("th[phx-value-key=person_id]") |> render_click() - [first_element | _] = Floki.find(result, "td[data-test-id=person_id]") + [first_element | _] = Floki.find(result, "td[data-test-id=person_id_2]") assert first_element |> Floki.text() =~ "2" @@ -1135,11 +1190,12 @@ defmodule AppWeb.StatsLiveTest do result = page_live |> element("th[phx-value-key=person_id]") |> render_click() - [first_element | _] = Floki.find(result, "td[data-test-id=person_id]") + [first_element | _] = Floki.find(result, "td[data-test-id=person_id_1]") assert first_element |> Floki.text() =~ "1" end end + ``` Let's run the tests: From 8aadbdd4dfb9990dca8d72093aed400a8fa9709e Mon Sep 17 00:00:00 2001 From: panoramix360 Date: Mon, 15 Jan 2024 11:14:08 -0300 Subject: [PATCH 2/4] chore: create the new section to enchance the tags page --- src/mvp/22-enhance-tags-page.md | 554 ++++++++++++++++++++++++++++++++ 1 file changed, 554 insertions(+) create mode 100644 src/mvp/22-enhance-tags-page.md diff --git a/src/mvp/22-enhance-tags-page.md b/src/mvp/22-enhance-tags-page.md new file mode 100644 index 0000000..42413bc --- /dev/null +++ b/src/mvp/22-enhance-tags-page.md @@ -0,0 +1,554 @@ +# Implementing Enhanced Tag Details and Sorting + +These modifications are designed to enhance functionality and improve user experience. We'll cover updates made to the Repo module, changes in the Tag schema, alterations in the TagController and StatsLive modules, and updates to LiveView files. + +## Implementing the toggle_sort_order on the Repo.ex + +The `toggle_sort_order` function in the Repo module allows us to dynamically change the sorting order of our database queries. This is useful for features where the user can sort items in ascending or descending order that will be used throughout the whole app where we need to sort it. + +`lib/app/repo.ex` +```elixir +def toggle_sort_order(:asc), do: :desc +def toggle_sort_order(:desc), do: :asc +``` +If the current order is :asc (ascending), it changes to :desc (descending), and vice versa​​. + +## Extending the Tag Model + +Open `lib/app/tag.ex` and add new fields to the Tag schema. + +```elixir +field :last_used_at, :naive_datetime, virtual: true +field :items_count, :integer, virtual: true +field :total_time_logged, :integer, virtual: true +``` +These fields are 'virtual', meaning they're not stored in the database but calculated on-the-fly. + +The purposes of the fields are: +`last_used_at`: the date a Tag was last used +`items_count`: how many items are using the Tag +`total_time_logged`: the total time that was logged with this particular Tag being used by a Item + +We will add a new method that will query with this new fields on the same file. + +Define `list_person_tags_complete/3`: +```elixir +def list_person_tags_complete( + person_id, + sort_column \\ :text, + sort_order \\ :asc + ) do + sort_column = + if validate_sort_column(sort_column), do: sort_column, else: :text + + Tag + |> where(person_id: ^person_id) + |> join(:left, [t], it in ItemTag, on: t.id == it.tag_id) + |> join(:left, [t, it], i in Item, on: i.id == it.item_id) + |> join(:left, [t, it, i], tm in Timer, on: tm.item_id == i.id) + |> group_by([t], t.id) + |> select([t, it, i, tm], %{ + t + | last_used_at: max(it.inserted_at), + items_count: fragment("count(DISTINCT ?)", i.id), + total_time_logged: + sum( + coalesce( + fragment( + "EXTRACT(EPOCH FROM (? - ?))", + tm.stop, + tm.start + ), + 0 + ) + ) + }) + |> order_by(^get_order_by_keyword(sort_column, sort_order)) + |> Repo.all() +end +``` + +And add these new methods at the end of the file: +```elixir +defp validate_sort_column(column) do + Enum.member?( + [ + :text, + :color, + :created_at, + :last_used_at, + :items_count, + :total_time_logged + ], + column + ) +end + +defp get_order_by_keyword(sort_column, :asc) do + [asc: sort_column] +end + +defp get_order_by_keyword(sort_column, :desc) do + [desc: sort_column] +end +``` + +These methods are using in the previous method to validate the columns that can be searched and to transform into key words [asc: column] to work on the query. + +## Adding the new columns on the Tags Page + +First, we need to remove the index page from the `tag_controller.ex` because we are going to include it on a new LiveView for Tags. + +This is needed because of the sorting events of the table. + +So **remove** these next lines of code from the `lib/app_web/controllers/tag_controller.ex` +```elixir +def index(conn, _params) do + person_id = conn.assigns[:person][:id] || 0 + tags = Tag.list_person_tags(person_id) + + render(conn, "index.html", + tags: tags, + lists: App.List.get_lists_for_person(person_id), + custom_list: false + ) +end +``` + +Now, let's create the LiveView that will have the table for tags and the redirections to all other pages on the TagController. + +Create a new file on `lib/app_web/live/tags_live.ex` with the following content. +```elixir +defmodule AppWeb.TagsLive do + use AppWeb, :live_view + alias App.{DateTimeHelper, Person, Tag, Repo} + + # run authentication on mount + on_mount(AppWeb.AuthController) + + @tags_topic "tags" + + @impl true + def mount(_params, _session, socket) do + if connected?(socket), do: AppWeb.Endpoint.subscribe(@tags_topic) + + person_id = Person.get_person_id(socket.assigns) + + tags = Tag.list_person_tags_complete(person_id) + + {:ok, + assign(socket, + tags: tags, + lists: App.List.get_lists_for_person(person_id), + custom_list: false, + sort_column: :text, + sort_order: :asc + )} + end + + @impl true + def handle_event("sort", %{"key" => key}, socket) do + sort_column = + key + |> String.to_atom() + + sort_order = + if socket.assigns.sort_column == sort_column do + Repo.toggle_sort_order(socket.assigns.sort_order) + else + :asc + end + + person_id = Person.get_person_id(socket.assigns) + + tags = Tag.list_person_tags_complete(person_id, sort_column, sort_order) + + {:noreply, + assign(socket, + tags: tags, + sort_column: sort_column, + sort_order: sort_order + )} + end + + def format_date(date) do + DateTimeHelper.format_date(date) + end + + def format_seconds(seconds) do + DateTimeHelper.format_duration(seconds) + end +end +``` + +The whole code is similar to other LiveViews created on the project. + +`mount` + +This function is invoked when the LiveView component is mounted. It initializes the state of the LiveView. +- `on_mount(AppWeb.AuthController)`: This line ensures that authentication is run when the LiveView component mounts. +- The `if connected?(socket)` block subscribes to a topic (@tags_topic) if the user is connected, enabling real-time updates. +- `person_id` is retrieved to identify the current user. +- tags are fetched using `Tag.list_person_tags_complete(person_id)`, which retrieves all tags associated with the `person_id` and is the method that we created previously. +- The socket is assigned various values, such as tags, lists, custom_list, sort_column, and sort_order, setting up the initial state of the LiveView. + +`handle_event` + +This function is called when a "sort" event is triggered by user interaction on the UI. +- `sort_column` is set based on the event's key, determining which column to sort by. +- `sort_order` is determined by the current state of sort_column and sort_order. If the sort_column is the same as the one already in the socket's assigns, the order is toggled using `Repo.toggle_sort_order`. Otherwise, it defaults to ascending (:asc). +- Tags are then re-fetched with the new sort order and column, and the socket is updated with these new values. +- This dynamic sorting mechanism allows the user interface to update the display order of tags based on user interaction. + +`format_date(date)` + +Uses DateTimeHelper.format_date to format a given date. + +`format_seconds(seconds)` + +Uses DateTimeHelper.format_duration to format a duration in seconds into a more human-readable format. + +### Creating the LiveView html + +Create a new file on `lib/app_web/live/tags_live.html.heex` that will handle the LiveView created in the previous section: +```html +
+
+

+ Listing Tags +

+ + <.live_component + module={AppWeb.TableComponent} + id="tags_table_component" + rows={@tags} + sort_column={@sort_column} + sort_order={@sort_order} + highlight={fn _ -> false end} + > + <:column :let={tag} label="Name" key="text"> + + <%= tag.text %> + + + + <:column :let={tag} label="Color" key="color"> + + + <%= tag.color %> + + + + + <:column :let={tag} label="Created At" key="inserted_at"> + + <%= format_date(tag.inserted_at) %> + + + + <:column :let={tag} label="Latest" key="last_used_at"> + + <%= if tag.last_used_at do %> + <%= format_date(tag.last_used_at) %> + <% else %> + - + <% end %> + + + + <:column :let={tag} label="Items Count" key="items_count"> + + + <%= tag.items_count %> + + + + + <:column :let={tag} label="Total Time Logged" key="total_time_logged"> + + <%= format_seconds(tag.total_time_logged) %> + + + + <:column :let={tag} label="Actions" key="actions"> + + <%= link("Edit", to: Routes.tag_path(@socket, :edit, tag)) %> + + + <%= link("Delete", + to: Routes.tag_path(@socket, :delete, tag), + method: :delete, + data: [confirm: "Are you sure you want to delete this tag?"] + ) %> + + + + + + <.button + link_type="a" + to={Routes.tag_path(@socket, :new)} + label="Create Tag" + class="text-2xl text-center float-left rounded-md bg-green-600 hover:bg-green-700 + my-2 mt-2 ml-2 px-4 py-2 font-semibold text-white shadow-sm" + /> +
+
+``` + +The structure is similar to the `stats_live.html.heex` with the new columns for tags and the events to sorting. And it's using the TableComponent as well. + +## Adding the new page to the router + +Open the `lib/app_web/router.ex` and change the following line: +```elixir +... + + scope "/", AppWeb do + pipe_through [:browser, :authOptional] + live "/", AppLive + resources "/lists", ListController, except: [:show] + get "/logout", AuthController, :logout + live "/stats", StatsLive ++ live "/tags", TagsLive + resources "/tags", TagController, except: [:show] +end + +... +``` + +After that, you can remove the `lib/app_web/templates/tag/index.html.heex` file, since we will use the Tags LiveView for the tags page now. + +Done! The Tags Page has the new columns and everything to be enhanced, congratulations! + +It's just missing tests, let's add it: + +`test/app/repo_test.exs` +```elixir +defmodule App.RepoTest do + use ExUnit.Case + alias App.Repo + + describe "toggle_sort_order/1" do + test "toggles :asc to :desc" do + assert Repo.toggle_sort_order(:asc) == :desc + end + + test "toggles :desc to :asc" do + assert Repo.toggle_sort_order(:desc) == :asc + end + end +end +``` + +Add new test cases to the `test/app/tag_test.exs` +```elixir +describe "list_person_tags/1" do + test "returns an empty list for a person with no tags" do + assert [] == Tag.list_person_tags(-1) + end + + test "returns a single tag for a person with one tag" do + tag = add_test_tag(%{text: "TestTag", person_id: 1, color: "#FCA5A5"}) + assert [tag] == Tag.list_person_tags(1) + end + + test "returns tags in alphabetical order for a person with multiple tags" do + add_test_tag(%{text: "BTag", person_id: 2, color: "#FCA5A5"}) + add_test_tag(%{text: "ATag", person_id: 2, color: "#FCA5A5"}) + + tags = Tag.list_person_tags(2) + assert length(tags) == 2 + assert tags |> Enum.map(& &1.text) == ["ATag", "BTag"] + end +end + +describe "list_person_tags_complete/3" do + test "returns detailed tag information for a given person" do + add_test_tag_with_details(%{person_id: 3, text: "DetailedTag", color: "#FCA5A5"}) + + tags = Tag.list_person_tags_complete(3) + assert length(tags) > 0 + assert tags |> Enum.all?(&is_map(&1)) + assert tags |> Enum.all?(&Map.has_key?(&1, :last_used_at)) + assert tags |> Enum.all?(&Map.has_key?(&1, :items_count)) + assert tags |> Enum.all?(&Map.has_key?(&1, :total_time_logged)) + end + + test "sorts tags based on specified sort_column and sort_order" do + add_test_tag_with_details(%{person_id: 4, text: "CTag", color: "#FCA5A5"}) + add_test_tag_with_details(%{person_id: 4, text: "ATag", color: "#FCA5A5"}) + add_test_tag_with_details(%{person_id: 4, text: "BTag", color: "#FCA5A5"}) + + tags = Tag.list_person_tags_complete(4, :text, :asc) + assert tags |> Enum.map(& &1.text) == ["ATag", "BTag", "CTag"] + end + + test "sorts tags with desc sort_order" do + add_test_tag_with_details(%{person_id: 4, text: "CTag", color: "#FCA5A5"}) + add_test_tag_with_details(%{person_id: 4, text: "ATag", color: "#FCA5A5"}) + add_test_tag_with_details(%{person_id: 4, text: "BTag", color: "#FCA5A5"}) + + tags = Tag.list_person_tags_complete(4, :text, :desc) + assert tags |> Enum.map(& &1.text) == ["CTag", "BTag", "ATag"] + end + + test "uses default sort_order when none are provided" do + add_test_tag_with_details(%{person_id: 5, text: "SingleTag", color: "#FCA5A5"}) + + tags = Tag.list_person_tags_complete(5, :text) + assert length(tags) == 1 + end + + test "uses default parameters when none are provided" do + add_test_tag_with_details(%{person_id: 5, text: "SingleTag", color: "#FCA5A5"}) + + tags = Tag.list_person_tags_complete(5) + assert length(tags) == 1 + end + + test "handles invalid column" do + add_test_tag_with_details(%{person_id: 6, text: "BTag", color: "#FCA5A5"}) + add_test_tag_with_details(%{person_id: 6, text: "AnotherTag", color: "#FCA5A5"}) + + tags = Tag.list_person_tags_complete(6, :invalid_column) + assert length(tags) == 2 + assert tags |> Enum.map(& &1.text) == ["AnotherTag", "BTag"] + end +end + +defp add_test_tag(attrs) do + {:ok, tag} = Tag.create_tag(attrs) + tag +end + +defp add_test_tag_with_details(attrs) do + tag = add_test_tag(attrs) + + {:ok, %{model: item}} = Item.create_item(%{ + person_id: tag.person_id, + status: 0, + text: "some item", + tags: [tag] + }) + + seconds_ago_date = NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), -10)) + Timer.start(%{item_id: item.id, person_id: tag.person_id, start: seconds_ago_date}) + Timer.stop_timer_for_item_id(item.id) + + tag +end +``` + +**Remove** these next lines from the `tag_controller_test.exs` since we don't have this page anymore: +```elixir +describe "index" do + test "lists all tags", %{conn: conn} do + conn = get(conn, Routes.tag_path(conn, :index)) + assert html_response(conn, 200) =~ "Listing Tags" + end + + test "lists all tags and display logout button", %{conn: conn} do + conn = + conn + |> assign(:jwt, AuthPlug.Token.generate_jwt!(%{id: 1, picture: ""})) + |> get(Routes.tag_path(conn, :index)) + + assert html_response(conn, 200) =~ "logout" + end +end +``` + +`test/app_web/live/tags_live_test.exs` +```elixir +defmodule AppWeb.TagsLiveTest do + use AppWeb.ConnCase, async: true + alias App.{Item, Timer, Tag} + import Phoenix.LiveViewTest + + @person_id 0 + + test "disconnected and connected render", %{conn: conn} do + {:ok, page_live, disconnected_html} = live(conn, "/tags") + assert disconnected_html =~ "Tags" + assert render(page_live) =~ "Tags" + end + + test "display tags on table", %{conn: conn} do + tag1 = add_test_tag_with_details(%{person_id: @person_id, text: "Tag1", color: "#000000"}) + tag2 = add_test_tag_with_details(%{person_id: @person_id, text: "Tag2", color: "#000000"}) + tag3 = add_test_tag_with_details(%{person_id: @person_id, text: "Tag3", color: "#000000"}) + + {:ok, page_live, _html} = live(conn, "/tags") + + assert render(page_live) =~ "Tags" + + assert page_live + |> element("td[data-test-id=text_#{tag1.id}") + |> render() =~ + "Tag1" + + assert page_live + |> element("td[data-test-id=text_#{tag2.id}") + |> render() =~ + "Tag2" + + assert page_live + |> element("td[data-test-id=text_#{tag3.id}") + |> render() =~ + "Tag3" + end + + @tag tags: true + test "sorting column when clicked", %{conn: conn} do + add_test_tag_with_details(%{person_id: @person_id, text: "a", color: "#000000"}) + add_test_tag_with_details(%{person_id: @person_id, text: "z", color: "#000000"}) + + {:ok, page_live, _html} = live(conn, "/tags") + + # sort first time + result = + page_live |> element("th[phx-value-key=text]") |> render_click() + + [first_element | _] = Floki.find(result, "td[data-test-id^=text_]") + assert first_element |> Floki.text() =~ "z" + + # sort second time + result = + page_live |> element("th[phx-value-key=text]") |> render_click() + + [first_element | _] = Floki.find(result, "td[data-test-id^=text_]") + + assert first_element |> Floki.text() =~ "a" + end + + defp add_test_tag_with_details(attrs) do + {:ok, tag} = Tag.create_tag(attrs) + + {:ok, %{model: item}} = Item.create_item(%{ + person_id: tag.person_id, + status: 0, + text: "some item", + tags: [tag] + }) + + seconds_ago_date = NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), -10)) + Timer.start(%{item_id: item.id, person_id: tag.person_id, start: seconds_ago_date}) + Timer.stop_timer_for_item_id(item.id) + + tag + end +end +``` + +After these tests, you are ready to run the application and see your new changes! \ No newline at end of file From b722d829eb6e48f9acbe5c14dabe74e0163f751b Mon Sep 17 00:00:00 2001 From: panoramix360 Date: Mon, 15 Jan 2024 22:07:04 -0300 Subject: [PATCH 3/4] feat: adding on the summary --- src/SUMMARY.md | 1 + 1 file changed, 1 insertion(+) diff --git a/src/SUMMARY.md b/src/SUMMARY.md index ef956f3..bd184b1 100644 --- a/src/SUMMARY.md +++ b/src/SUMMARY.md @@ -25,6 +25,7 @@ - [Reordering](mvp/18-reordering.md) - [Add `item` to `list`](mvp/19-add-item-to-list.md) - [Stats](mvp/20-stats.md) + - [Tags Page Enhanced](mvp/22-enhance-tags-page.md) - [Tidy](tidy/README.md) - [Setup](tidy/01-setup.md) - [Schema](tidy/02-schema.md) \ No newline at end of file From 9df4573f72a8791e2518572156756c13f9b4bdf4 Mon Sep 17 00:00:00 2001 From: LuchoTurtle Date: Tue, 16 Jan 2024 03:58:48 +0000 Subject: [PATCH 4/4] fix: Fixing some typos --- src/mvp/22-enhance-tags-page.md | 60 +++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/src/mvp/22-enhance-tags-page.md b/src/mvp/22-enhance-tags-page.md index 42413bc..3cf300a 100644 --- a/src/mvp/22-enhance-tags-page.md +++ b/src/mvp/22-enhance-tags-page.md @@ -1,10 +1,14 @@ # Implementing Enhanced Tag Details and Sorting -These modifications are designed to enhance functionality and improve user experience. We'll cover updates made to the Repo module, changes in the Tag schema, alterations in the TagController and StatsLive modules, and updates to LiveView files. +These modifications are designed to enhance functionality and improve user experience. +We'll cover updates made to the `Repo` module, changes in the `Tag` schema, +alterations in the `TagController` and `StatsLive` modules, +and updates to LiveView files. -## Implementing the toggle_sort_order on the Repo.ex +## Implementing the `toggle_sort_order` in `Repo.ex` -The `toggle_sort_order` function in the Repo module allows us to dynamically change the sorting order of our database queries. This is useful for features where the user can sort items in ascending or descending order that will be used throughout the whole app where we need to sort it. +The `toggle_sort_order` function in the `Repo` module allows us to dynamically change the sorting order of our database queries. +This is useful for features where the user can sort items in ascending or descending order that will be used throughout the whole app where we need to sort it. `lib/app/repo.ex` ```elixir @@ -13,25 +17,26 @@ def toggle_sort_order(:desc), do: :asc ``` If the current order is :asc (ascending), it changes to :desc (descending), and vice versa​​. -## Extending the Tag Model +## Extending the `Tag` Model -Open `lib/app/tag.ex` and add new fields to the Tag schema. +Open `lib/app/tag.ex` and add new fields to the `Tag` schema. ```elixir field :last_used_at, :naive_datetime, virtual: true field :items_count, :integer, virtual: true field :total_time_logged, :integer, virtual: true ``` -These fields are 'virtual', meaning they're not stored in the database but calculated on-the-fly. +These fields are 'virtual', meaning they're not stored in the database but calculated on the fly. The purposes of the fields are: `last_used_at`: the date a Tag was last used `items_count`: how many items are using the Tag `total_time_logged`: the total time that was logged with this particular Tag being used by a Item -We will add a new method that will query with this new fields on the same file. +We will add a new method that will query with these new fields on the same file. Define `list_person_tags_complete/3`: + ```elixir def list_person_tags_complete( person_id, @@ -69,6 +74,7 @@ end ``` And add these new methods at the end of the file: + ```elixir defp validate_sort_column(column) do Enum.member?( @@ -93,11 +99,13 @@ defp get_order_by_keyword(sort_column, :desc) do end ``` -These methods are using in the previous method to validate the columns that can be searched and to transform into key words [asc: column] to work on the query. +These methods are used in the previous method to validate the columns +that can be searched and to transform into keywords [asc: column] to work on the query. -## Adding the new columns on the Tags Page +## Adding the new columns on the `Tags` Page -First, we need to remove the index page from the `tag_controller.ex` because we are going to include it on a new LiveView for Tags. +First, we need to remove the index page from the `tag_controller.ex` +because we are going to include it on a new LiveView for `Tags`. This is needed because of the sorting events of the table. @@ -115,9 +123,11 @@ def index(conn, _params) do end ``` -Now, let's create the LiveView that will have the table for tags and the redirections to all other pages on the TagController. +Now, let's create the LiveView that will have the table for tags +and the redirections to all other pages on the `TagController`. Create a new file on `lib/app_web/live/tags_live.ex` with the following content. + ```elixir defmodule AppWeb.TagsLive do use AppWeb, :live_view @@ -183,7 +193,7 @@ end The whole code is similar to other LiveViews created on the project. -`mount` +**`mount`** This function is invoked when the LiveView component is mounted. It initializes the state of the LiveView. - `on_mount(AppWeb.AuthController)`: This line ensures that authentication is run when the LiveView component mounts. @@ -192,7 +202,7 @@ This function is invoked when the LiveView component is mounted. It initializes - tags are fetched using `Tag.list_person_tags_complete(person_id)`, which retrieves all tags associated with the `person_id` and is the method that we created previously. - The socket is assigned various values, such as tags, lists, custom_list, sort_column, and sort_order, setting up the initial state of the LiveView. -`handle_event` +**`handle_event`** This function is called when a "sort" event is triggered by user interaction on the UI. - `sort_column` is set based on the event's key, determining which column to sort by. @@ -200,17 +210,19 @@ This function is called when a "sort" event is triggered by user interaction on - Tags are then re-fetched with the new sort order and column, and the socket is updated with these new values. - This dynamic sorting mechanism allows the user interface to update the display order of tags based on user interaction. -`format_date(date)` +**`format_date(date)`** Uses DateTimeHelper.format_date to format a given date. -`format_seconds(seconds)` +**`format_seconds(seconds)`** Uses DateTimeHelper.format_duration to format a duration in seconds into a more human-readable format. -### Creating the LiveView html +### Creating the LiveView HTML template + +Create a new file on `lib/app_web/live/tags_live.html.heex` +that will handle the LiveView created in the previous section: -Create a new file on `lib/app_web/live/tags_live.html.heex` that will handle the LiveView created in the previous section: ```html
@@ -306,11 +318,14 @@ Create a new file on `lib/app_web/live/tags_live.html.heex` that will handle the
``` -The structure is similar to the `stats_live.html.heex` with the new columns for tags and the events to sorting. And it's using the TableComponent as well. +The structure is similar to the `stats_live.html.heex` +with the new columns for tags and the events for sorting. +And it's using the `TableComponent` as well. ## Adding the new page to the router Open the `lib/app_web/router.ex` and change the following line: + ```elixir ... @@ -327,11 +342,12 @@ end ... ``` -After that, you can remove the `lib/app_web/templates/tag/index.html.heex` file, since we will use the Tags LiveView for the tags page now. +After that, you can remove the `lib/app_web/templates/tag/index.html.heex` file, +since we will use the `Tags` LiveView for the tags page now. Done! The Tags Page has the new columns and everything to be enhanced, congratulations! -It's just missing tests, let's add it: +It's just missing tests, let's add them: `test/app/repo_test.exs` ```elixir @@ -352,6 +368,7 @@ end ``` Add new test cases to the `test/app/tag_test.exs` + ```elixir describe "list_person_tags/1" do test "returns an empty list for a person with no tags" do @@ -470,6 +487,7 @@ end ``` `test/app_web/live/tags_live_test.exs` + ```elixir defmodule AppWeb.TagsLiveTest do use AppWeb.ConnCase, async: true @@ -551,4 +569,4 @@ defmodule AppWeb.TagsLiveTest do end ``` -After these tests, you are ready to run the application and see your new changes! \ No newline at end of file +After these tests, you are ready to run the application and see your new changes!