From f6422ea2e0c98b6f0e8c971b17c791d1b2fe64da Mon Sep 17 00:00:00 2001 From: panoramix360 Date: Fri, 29 Sep 2023 00:25:27 -0300 Subject: [PATCH 01/21] feat: adding more coluns to the tags --- lib/app_web/templates/tag/index.html.heex | 76 ++++++++++++++--------- lib/app_web/views/tag_view.ex | 5 ++ 2 files changed, 52 insertions(+), 29 deletions(-) diff --git a/lib/app_web/templates/tag/index.html.heex b/lib/app_web/templates/tag/index.html.heex index 4d5d70a9..7a8648ba 100644 --- a/lib/app_web/templates/tag/index.html.heex +++ b/lib/app_web/templates/tag/index.html.heex @@ -1,38 +1,56 @@ <%= render(AppWeb.NavView, "nav.html", assigns) %> <.h2 class="text-center mt-3">Listing Tags -<.container> - <.table class="my-4"> - <.tr> - <.th>Name - - <.th class="w-3"> - <.th class="w-3"> - - <%= for tag <- @tags do %> +<.container class="font-sans container mx-auto"> + <.table class="text-sm text-left text-gray-500 dark:text-gray-400 table-auto"> + <.tr> - <.td> - - <%= tag.text %> - - + <.th scope="col" class="px-6 py-3 text-center cursor-pointer">Name + <.th scope="col" class="px-6 py-3 text-center cursor-pointer"> + Color + + <.th scope="col" class="px-6 py-3 text-center cursor-pointer"> + Created At + - <.td> - <%= link("Edit", to: Routes.tag_path(@conn, :edit, tag)) %> - - <.td class="!text-red-500"> - <%= link("Delete", - to: Routes.tag_path(@conn, :delete, tag), - method: :delete, - data: [confirm: "Are you sure you want to delete this tag?"] - ) %> - + <.th class="w-3 text-center" colspan="2">Actions - <% end %> + + + <%= for tag <- @tags do %> + <.tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700"> + <.td class="px-6 py-4 text-center"> + <%= tag.text %> + + + <.td class="px-6 py-4 text-center"> + + <%= tag.color %> + + + + <.td class="px-6 py-4 text-center"> + <%= format_date(tag.inserted_at) %> + + + <.td class="px-6 py-4 text-center"> + <%= link("Edit", to: Routes.tag_path(@conn, :edit, tag)) %> + + + <.td class="px-6 py-4 text-center text-red-500"> + <%= link("Delete", + to: Routes.tag_path(@conn, :delete, tag), + method: :delete, + data: [confirm: "Are you sure you want to delete this tag?"] + ) %> + + + <% end %> + <.button link_type="a" diff --git a/lib/app_web/views/tag_view.ex b/lib/app_web/views/tag_view.ex index d238df92..38cfcac2 100644 --- a/lib/app_web/views/tag_view.ex +++ b/lib/app_web/views/tag_view.ex @@ -1,3 +1,8 @@ defmodule AppWeb.TagView do use AppWeb, :view + alias App.DateTimeHelper + + def format_date(date) do + DateTimeHelper.format_date(date) + end end From fc66b49c6ab6ee1c7650a5fe1e67cd6c3f51e5c6 Mon Sep 17 00:00:00 2001 From: panoramix360 Date: Mon, 2 Oct 2023 10:51:18 -0300 Subject: [PATCH 02/21] feat: creating the tags live view for the index of the tags page --- lib/app_web/controllers/tag_controller.ex | 11 ------- lib/app_web/live/tags_live.ex | 29 +++++++++++++++++++ .../tags_live.html.heex} | 6 ++-- lib/app_web/router.ex | 1 + 4 files changed, 33 insertions(+), 14 deletions(-) create mode 100644 lib/app_web/live/tags_live.ex rename lib/app_web/{templates/tag/index.html.heex => live/tags_live.html.heex} (91%) diff --git a/lib/app_web/controllers/tag_controller.ex b/lib/app_web/controllers/tag_controller.ex index 0fa43d67..3b10d7d0 100644 --- a/lib/app_web/controllers/tag_controller.ex +++ b/lib/app_web/controllers/tag_controller.ex @@ -3,17 +3,6 @@ defmodule AppWeb.TagController do alias App.{Person, Tag} plug :permission_tag when action in [:edit, :update, :delete] - 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 - def new(conn, _params) do changeset = Tag.changeset(%Tag{}) render(conn, "new.html", changeset: changeset) diff --git a/lib/app_web/live/tags_live.ex b/lib/app_web/live/tags_live.ex new file mode 100644 index 00000000..0ff6bb1a --- /dev/null +++ b/lib/app_web/live/tags_live.ex @@ -0,0 +1,29 @@ +defmodule AppWeb.TagsLive do + use AppWeb, :live_view + alias App.{DateTimeHelper, Person, Tag} + + # 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(person_id) + + {:ok, + assign(socket, + tags: tags, + lists: App.List.get_lists_for_person(person_id), + custom_list: false + )} + end + + def format_date(date) do + DateTimeHelper.format_date(date) + end +end diff --git a/lib/app_web/templates/tag/index.html.heex b/lib/app_web/live/tags_live.html.heex similarity index 91% rename from lib/app_web/templates/tag/index.html.heex rename to lib/app_web/live/tags_live.html.heex index 7a8648ba..3b65173c 100644 --- a/lib/app_web/templates/tag/index.html.heex +++ b/lib/app_web/live/tags_live.html.heex @@ -38,12 +38,12 @@ <.td class="px-6 py-4 text-center"> - <%= link("Edit", to: Routes.tag_path(@conn, :edit, tag)) %> + <%= link("Edit", to: Routes.tag_path(@socket, :edit, tag)) %> <.td class="px-6 py-4 text-center text-red-500"> <%= link("Delete", - to: Routes.tag_path(@conn, :delete, tag), + to: Routes.tag_path(@socket, :delete, tag), method: :delete, data: [confirm: "Are you sure you want to delete this tag?"] ) %> @@ -54,7 +54,7 @@ <.button link_type="a" - to={Routes.tag_path(@conn, :new)} + to={Routes.tag_path(@socket, :new)} label="Create Tag" class="my-2" /> diff --git a/lib/app_web/router.ex b/lib/app_web/router.ex index ceb3149b..5f17b426 100644 --- a/lib/app_web/router.ex +++ b/lib/app_web/router.ex @@ -32,6 +32,7 @@ defmodule AppWeb.Router do resources "/lists", ListController, except: [:show] get "/logout", AuthController, :logout live "/stats", StatsLive + live "/tags", TagsLive resources "/tags", TagController, except: [:show] end From c1b4194727a044cba3fa3e7c09686f6345c5f199 Mon Sep 17 00:00:00 2001 From: panoramix360 Date: Mon, 2 Oct 2023 11:04:06 -0300 Subject: [PATCH 03/21] feat: changing the tags table to use the LiveComponent Table --- lib/app_web/live/tags_live.ex | 4 +- lib/app_web/live/tags_live.html.heex | 94 +++++++++++++--------------- 2 files changed, 48 insertions(+), 50 deletions(-) diff --git a/lib/app_web/live/tags_live.ex b/lib/app_web/live/tags_live.ex index 0ff6bb1a..747ef9b7 100644 --- a/lib/app_web/live/tags_live.ex +++ b/lib/app_web/live/tags_live.ex @@ -19,7 +19,9 @@ defmodule AppWeb.TagsLive do assign(socket, tags: tags, lists: App.List.get_lists_for_person(person_id), - custom_list: false + custom_list: false, + sort_column: :text, + sort_order: :asc )} end diff --git a/lib/app_web/live/tags_live.html.heex b/lib/app_web/live/tags_live.html.heex index 3b65173c..e2f1ed11 100644 --- a/lib/app_web/live/tags_live.html.heex +++ b/lib/app_web/live/tags_live.html.heex @@ -1,61 +1,57 @@ <%= render(AppWeb.NavView, "nav.html", assigns) %> <.h2 class="text-center mt-3">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 %> + + -<.container class="font-sans container mx-auto"> - <.table class="text-sm text-left text-gray-500 dark:text-gray-400 table-auto"> - - <.tr> - <.th scope="col" class="px-6 py-3 text-center cursor-pointer">Name - <.th scope="col" class="px-6 py-3 text-center cursor-pointer"> - Color - - <.th scope="col" class="px-6 py-3 text-center cursor-pointer"> - Created At - - - <.th class="w-3 text-center" colspan="2">Actions - - - - <%= for tag <- @tags do %> - <.tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700"> - <.td class="px-6 py-4 text-center"> - <%= tag.text %> - - - <.td class="px-6 py-4 text-center"> - + + - <%= tag.color %> - - + > + <%= tag.color %> + + + - <.td class="px-6 py-4 text-center"> - <%= format_date(tag.inserted_at) %> - + <:column :let={tag} label="Created At" key="inserted_at"> + + <%= format_date(tag.inserted_at) %> + + - <.td class="px-6 py-4 text-center"> - <%= link("Edit", to: Routes.tag_path(@socket, :edit, tag)) %> - + <:column :let={tag} label="Actions" key="actions"> + + <%= link("Edit", to: Routes.tag_path(@socket, :edit, tag)) %> - <.td class="px-6 py-4 text-center text-red-500"> + <%= link("Delete", to: Routes.tag_path(@socket, :delete, tag), method: :delete, data: [confirm: "Are you sure you want to delete this tag?"] ) %> - - - <% end %> - - - <.button - link_type="a" - to={Routes.tag_path(@socket, :new)} - label="Create Tag" - class="my-2" - /> - + + + + + + <.button + link_type="a" + to={Routes.tag_path(@socket, :new)} + label="Create Tag" + class="my-2" + /> + + From 22b5bff2aa1744d33729d853f4593c68c6f97fc8 Mon Sep 17 00:00:00 2001 From: panoramix360 Date: Tue, 17 Oct 2023 10:41:18 -0300 Subject: [PATCH 04/21] feat: adding the latest time a tag was used --- lib/app/tag.ex | 12 ++++++++++++ lib/app_web/live/tags_live.ex | 2 +- lib/app_web/live/tags_live.html.heex | 6 ++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/lib/app/tag.ex b/lib/app/tag.ex index 547eea00..af0e03ec 100644 --- a/lib/app/tag.ex +++ b/lib/app/tag.ex @@ -11,6 +11,8 @@ defmodule App.Tag do field :person_id, :integer field :text, :string + field :last_used_at, :naive_datetime, virtual: true + many_to_many(:items, Item, join_through: ItemTag) timestamps() end @@ -88,6 +90,16 @@ defmodule App.Tag do |> Repo.all() end + def list_person_tags_complete(person_id) do + Tag + |> where(person_id: ^person_id) + |> join(:left, [t], it in ItemTag, on: t.id == it.tag_id) + |> group_by([t], t.id) + |> select([t, it], %{t | last_used_at: max(it.inserted_at)}) + |> order_by([t], t.text) + |> Repo.all() + end + def list_person_tags_text(person_id) do Tag |> where(person_id: ^person_id) diff --git a/lib/app_web/live/tags_live.ex b/lib/app_web/live/tags_live.ex index 747ef9b7..d589eb36 100644 --- a/lib/app_web/live/tags_live.ex +++ b/lib/app_web/live/tags_live.ex @@ -13,7 +13,7 @@ defmodule AppWeb.TagsLive do person_id = Person.get_person_id(socket.assigns) - tags = Tag.list_person_tags(person_id) + tags = Tag.list_person_tags_complete(person_id) {:ok, assign(socket, diff --git a/lib/app_web/live/tags_live.html.heex b/lib/app_web/live/tags_live.html.heex index e2f1ed11..97851ef5 100644 --- a/lib/app_web/live/tags_live.html.heex +++ b/lib/app_web/live/tags_live.html.heex @@ -32,6 +32,12 @@ + <:column :let={tag} label="Latest" key="tag_id"> + + <%= format_date(tag.last_used_at) %> + + + <:column :let={tag} label="Actions" key="actions"> <%= link("Edit", to: Routes.tag_path(@socket, :edit, tag)) %> From 98bb02820798b603f81b7c1d1e7aaf136a3308a8 Mon Sep 17 00:00:00 2001 From: panoramix360 Date: Tue, 17 Oct 2023 11:26:32 -0300 Subject: [PATCH 05/21] feat: adding items count column --- lib/app/tag.ex | 7 ++++++- lib/app_web/live/tags_live.html.heex | 14 ++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/lib/app/tag.ex b/lib/app/tag.ex index af0e03ec..987bb89e 100644 --- a/lib/app/tag.ex +++ b/lib/app/tag.ex @@ -12,6 +12,7 @@ defmodule App.Tag do field :text, :string field :last_used_at, :naive_datetime, virtual: true + field :items_count, :integer, virtual: true many_to_many(:items, Item, join_through: ItemTag) timestamps() @@ -95,7 +96,11 @@ defmodule App.Tag do |> where(person_id: ^person_id) |> join(:left, [t], it in ItemTag, on: t.id == it.tag_id) |> group_by([t], t.id) - |> select([t, it], %{t | last_used_at: max(it.inserted_at)}) + |> select([t, it], %{ + t + | last_used_at: max(it.inserted_at), + items_count: count(it.tag_id) + }) |> order_by([t], t.text) |> Repo.all() end diff --git a/lib/app_web/live/tags_live.html.heex b/lib/app_web/live/tags_live.html.heex index 97851ef5..19e3b287 100644 --- a/lib/app_web/live/tags_live.html.heex +++ b/lib/app_web/live/tags_live.html.heex @@ -32,9 +32,19 @@ - <:column :let={tag} label="Latest" key="tag_id"> + <:column :let={tag} label="Latest" key="last_used_at"> - <%= format_date(tag.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 %> From 000b4bc54f2d94fddbb1a59de1739f27351a17f4 Mon Sep 17 00:00:00 2001 From: panoramix360 Date: Mon, 23 Oct 2023 19:54:40 -0300 Subject: [PATCH 06/21] feat: add click on the items_count --- lib/app_web/live/tags_live.html.heex | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/app_web/live/tags_live.html.heex b/lib/app_web/live/tags_live.html.heex index 19e3b287..fd0401b8 100644 --- a/lib/app_web/live/tags_live.html.heex +++ b/lib/app_web/live/tags_live.html.heex @@ -44,7 +44,9 @@ <:column :let={tag} label="Items Count" key="items_count"> - <%= tag.items_count %> + + <%= tag.items_count %> + From dd4ac85ed924f46a20adebe8669f492beb4397c2 Mon Sep 17 00:00:00 2001 From: panoramix360 Date: Mon, 23 Oct 2023 20:09:42 -0300 Subject: [PATCH 07/21] feat: add the total time logged column --- lib/app/tag.ex | 19 ++++++++++++++++--- lib/app_web/live/tags_live.ex | 4 ++++ lib/app_web/live/tags_live.html.heex | 6 ++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/lib/app/tag.ex b/lib/app/tag.ex index 987bb89e..1386b72f 100644 --- a/lib/app/tag.ex +++ b/lib/app/tag.ex @@ -2,7 +2,7 @@ defmodule App.Tag do use Ecto.Schema import Ecto.Changeset import Ecto.Query - alias App.{Item, ItemTag, Repo} + alias App.{Item, ItemTag, Repo, Timer} alias __MODULE__ @derive {Jason.Encoder, only: [:id, :text, :person_id, :color]} @@ -13,6 +13,7 @@ defmodule App.Tag do field :last_used_at, :naive_datetime, virtual: true field :items_count, :integer, virtual: true + field :total_time_logged, :integer, virtual: true many_to_many(:items, Item, join_through: ItemTag) timestamps() @@ -95,11 +96,23 @@ defmodule App.Tag do Tag |> where(person_id: ^person_id) |> join(:left, [t], it in ItemTag, on: t.id == it.tag_id) + |> join(:left, [t, it], tm in Timer, on: tm.item_id == it.item_id) |> group_by([t], t.id) - |> select([t, it], %{ + |> select([t, it, tm], %{ t | last_used_at: max(it.inserted_at), - items_count: count(it.tag_id) + items_count: count(it.tag_id), + total_time_logged: + sum( + coalesce( + fragment( + "EXTRACT(EPOCH FROM (? - ?))", + tm.stop, + tm.start + ), + 0 + ) + ) }) |> order_by([t], t.text) |> Repo.all() diff --git a/lib/app_web/live/tags_live.ex b/lib/app_web/live/tags_live.ex index d589eb36..86ef0011 100644 --- a/lib/app_web/live/tags_live.ex +++ b/lib/app_web/live/tags_live.ex @@ -28,4 +28,8 @@ defmodule AppWeb.TagsLive do def format_date(date) do DateTimeHelper.format_date(date) end + + def format_seconds(seconds) do + DateTimeHelper.format_duration(seconds) + end end diff --git a/lib/app_web/live/tags_live.html.heex b/lib/app_web/live/tags_live.html.heex index fd0401b8..706cd819 100644 --- a/lib/app_web/live/tags_live.html.heex +++ b/lib/app_web/live/tags_live.html.heex @@ -50,6 +50,12 @@ + <: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)) %> From daa50aa54eb41c03f3982b3c1d8b0faea22a6920 Mon Sep 17 00:00:00 2001 From: panoramix360 Date: Mon, 23 Oct 2023 20:25:31 -0300 Subject: [PATCH 08/21] feat: distincting by the items count --- lib/app/tag.ex | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/app/tag.ex b/lib/app/tag.ex index 1386b72f..6f6425ac 100644 --- a/lib/app/tag.ex +++ b/lib/app/tag.ex @@ -96,12 +96,13 @@ defmodule App.Tag do Tag |> where(person_id: ^person_id) |> join(:left, [t], it in ItemTag, on: t.id == it.tag_id) - |> join(:left, [t, it], tm in Timer, on: tm.item_id == it.item_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, tm], %{ + |> select([t, it, i, tm], %{ t | last_used_at: max(it.inserted_at), - items_count: count(it.tag_id), + items_count: fragment("count(DISTINCT ?)", i.id), total_time_logged: sum( coalesce( From 967b8b49e9c6d20eaf610e51a561b397b9aa1d7e Mon Sep 17 00:00:00 2001 From: panoramix360 Date: Mon, 23 Oct 2023 21:11:30 -0300 Subject: [PATCH 09/21] feat: sorting columns --- lib/app/repo.ex | 35 ++++++++++++++++++++++++++++++++++ lib/app/stats.ex | 24 +---------------------- lib/app/tag.ex | 35 ++++++++++++++++++++++++++++++++-- lib/app_web/live/stats_live.ex | 7 ++----- lib/app_web/live/tags_live.ex | 27 +++++++++++++++++++++++++- 5 files changed, 97 insertions(+), 31 deletions(-) diff --git a/lib/app/repo.ex b/lib/app/repo.ex index 857bd3f9..b3760533 100644 --- a/lib/app/repo.ex +++ b/lib/app/repo.ex @@ -2,4 +2,39 @@ defmodule App.Repo do use Ecto.Repo, otp_app: :app, adapter: Ecto.Adapters.Postgres + + @doc """ + `validate_order/1` validates the ordering is one of `asc` or `desc` + + ## Examples + + iex> App.Repo.validate_order("asc") + true + + iex> App.Repo.validate_order(:asc) + true + + iex> App.Repo.validate_order(:invalid) + false + + # Avoid common SQL injection attacks: + iex> App.Repo.validate_order("OR 1=1") + false + """ + def validate_order(order) when is_bitstring(order) do + Enum.member?( + ~w(asc desc), + order + ) + end + + def validate_order(order) when is_atom(order) do + Enum.member?( + [:asc, :desc], + order + ) + end + + def toggle_sort_order(:asc), do: :desc + def toggle_sort_order(:desc), do: :asc end diff --git a/lib/app/stats.ex b/lib/app/stats.ex index 2a7813c6..f1e34d67 100644 --- a/lib/app/stats.ex +++ b/lib/app/stats.ex @@ -31,7 +31,7 @@ defmodule App.Stats do sort_column = if validate_sort_column(sort_column), do: sort_column, else: "person_id" - sort_order = if validate_order(sort_order), do: sort_order, else: "asc" + sort_order = if Repo.validate_order(sort_order), do: sort_order, else: "asc" sql = """ SELECT i.person_id, @@ -88,26 +88,4 @@ defmodule App.Stats do column ) end - - @doc """ - `validate_order/1` validates the ordering is one of `asc` or `desc` - - ## Examples - - iex> App.Stats.validate_order("asc") - true - - iex> App.Stats.validate_order(:invalid) - false - - # Avoid common SQL injection attacks: - iex> App.Stats.validate_order("OR 1=1") - false - """ - def validate_order(order) do - Enum.member?( - ~w(asc desc), - order - ) - end end diff --git a/lib/app/tag.ex b/lib/app/tag.ex index 6f6425ac..6c0a2fdb 100644 --- a/lib/app/tag.ex +++ b/lib/app/tag.ex @@ -92,7 +92,16 @@ defmodule App.Tag do |> Repo.all() end - def list_person_tags_complete(person_id) do + 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 + + sort_order = if Repo.validate_order(sort_order), do: sort_order, else: :asc + Tag |> where(person_id: ^person_id) |> join(:left, [t], it in ItemTag, on: t.id == it.tag_id) @@ -115,7 +124,7 @@ defmodule App.Tag do ) ) }) - |> order_by([t], t.text) + |> order_by(^get_order_by_keyword(sort_column, sort_order)) |> Repo.all() end @@ -136,4 +145,26 @@ defmodule App.Tag do def delete_tag(%Tag{} = tag) do Repo.delete(tag) end + + 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 end diff --git a/lib/app_web/live/stats_live.ex b/lib/app_web/live/stats_live.ex index 92dd8d68..8e3de982 100644 --- a/lib/app_web/live/stats_live.ex +++ b/lib/app_web/live/stats_live.ex @@ -1,7 +1,7 @@ defmodule AppWeb.StatsLive do require Logger use AppWeb, :live_view - alias App.{Stats, DateTimeHelper, Person} + alias App.{Stats, DateTimeHelper, Person, Repo} alias Phoenix.Socket.Broadcast # run authentication on mount @@ -74,7 +74,7 @@ defmodule AppWeb.StatsLive do sort_order = if socket.assigns.sort_column == sort_column do - toggle_sort_order(socket.assigns.sort_order) + Repo.toggle_sort_order(socket.assigns.sort_order) else :asc end @@ -114,7 +114,4 @@ defmodule AppWeb.StatsLive do def is_highlighted_person?(metric, person_id), do: metric.person_id == person_id - - defp toggle_sort_order(:asc), do: :desc - defp toggle_sort_order(:desc), do: :asc end diff --git a/lib/app_web/live/tags_live.ex b/lib/app_web/live/tags_live.ex index 86ef0011..6cac31f0 100644 --- a/lib/app_web/live/tags_live.ex +++ b/lib/app_web/live/tags_live.ex @@ -1,6 +1,6 @@ defmodule AppWeb.TagsLive do use AppWeb, :live_view - alias App.{DateTimeHelper, Person, Tag} + alias App.{DateTimeHelper, Person, Tag, Repo} # run authentication on mount on_mount(AppWeb.AuthController) @@ -25,6 +25,31 @@ defmodule AppWeb.TagsLive do )} 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 From f4a3f5944f728acf33bfd2fe71b874166d4765d0 Mon Sep 17 00:00:00 2001 From: panoramix360 Date: Mon, 13 Nov 2023 21:18:00 -0300 Subject: [PATCH 10/21] test: testing repo tag and tag_controller --- test/app/repo_test.exs | 35 ++++++ test/app/tag_test.exs | 104 +++++++++++++++++- .../controllers/tag_controller_test.exs | 13 +-- 3 files changed, 139 insertions(+), 13 deletions(-) create mode 100644 test/app/repo_test.exs diff --git a/test/app/repo_test.exs b/test/app/repo_test.exs new file mode 100644 index 00000000..b9d37987 --- /dev/null +++ b/test/app/repo_test.exs @@ -0,0 +1,35 @@ +defmodule App.RepoTest do + use ExUnit.Case + alias App.Repo + + describe "validate_order/1" do + test "validates 'asc' and 'desc' as a valid string order" do + assert Repo.validate_order("asc") == true + assert Repo.validate_order("desc") == true + end + + test "validates :asc and :desc as a valid atom order" do + assert Repo.validate_order(:asc) == true + assert Repo.validate_order(:desc) == true + end + + test "rejects invalid string order" do + assert Repo.validate_order("invalid") == false + assert Repo.validate_order(:invalid) == false + end + + test "rejects SQL injection attempt" do + assert Repo.validate_order("OR 1=1") == false + end + end + + 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 diff --git a/test/app/tag_test.exs b/test/app/tag_test.exs index f45c490d..b611f6eb 100644 --- a/test/app/tag_test.exs +++ b/test/app/tag_test.exs @@ -1,6 +1,6 @@ defmodule App.TagTest do use App.DataCase, async: true - alias App.Tag + alias App.{Item, Tag, Timer} describe "Test constraints and requirements for Tag schema" do test "valid tag changeset" do @@ -31,7 +31,7 @@ defmodule App.TagTest do @invalid_attrs %{text: nil} test "get_tag!/1 returns the tag with given id" do - {:ok, tag} = Tag.create_tag(@valid_attrs) + tag = add_test_tag(@valid_attrs) assert Tag.get_tag!(tag.id).text == tag.text end @@ -55,7 +55,7 @@ defmodule App.TagTest do end test "delete tag" do - {:ok, tag} = Tag.create_tag(@valid_attrs) + tag = add_test_tag(@valid_attrs) assert {:ok, _etc} = Tag.delete_tag(tag) end end @@ -64,10 +64,106 @@ defmodule App.TagTest do @valid_attrs %{text: "tag1", person_id: 1, color: "#FCA5A5"} test "list_person_tags_text/0 returns the tags texts" do - {:ok, _tag} = Tag.create_tag(@valid_attrs) + add_test_tag(@valid_attrs) tags_text_array = Tag.list_person_tags_text(@valid_attrs.person_id) assert length(tags_text_array) == 1 assert Enum.at(tags_text_array, 0) == @valid_attrs.text end end + + 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 sort parameters" 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, :invalid_order) + 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 end diff --git a/test/app_web/controllers/tag_controller_test.exs b/test/app_web/controllers/tag_controller_test.exs index 652aadb0..b81a89c4 100644 --- a/test/app_web/controllers/tag_controller_test.exs +++ b/test/app_web/controllers/tag_controller_test.exs @@ -11,19 +11,14 @@ defmodule AppWeb.TagControllerTest do tag end - 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 + describe "new tag" do + test "renders form for creating a tag", %{conn: conn} do conn = conn |> assign(:jwt, AuthPlug.Token.generate_jwt!(%{id: 1, picture: ""})) - |> get(Routes.tag_path(conn, :index)) + |> get(Routes.tag_path(conn, :new)) - assert html_response(conn, 200) =~ "logout" + assert html_response(conn, 200) =~ "New Tag" end end From b534ce9dc310963e5a8da3cb7fdd80661cb3aea3 Mon Sep 17 00:00:00 2001 From: panoramix360 Date: Mon, 13 Nov 2023 21:44:21 -0300 Subject: [PATCH 11/21] test: fixing stats live tests --- lib/app_web/live/stats_live.html.heex | 27 ++++++++++--- test/app_web/live/stats_live_test.exs | 58 +++++++++++++-------------- 2 files changed, 49 insertions(+), 36 deletions(-) diff --git a/lib/app_web/live/stats_live.html.heex b/lib/app_web/live/stats_live.html.heex index c4d20032..77b18172 100644 --- a/lib/app_web/live/stats_live.html.heex +++ b/lib/app_web/live/stats_live.html.heex @@ -13,7 +13,7 @@ highlight={&is_highlighted_person?(&1, @person_id)} > <:column :let={metric} label="Id" key="person_id"> - + <%= metric.person_id %> @@ -21,25 +21,37 @@ <: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) %> @@ -49,7 +61,10 @@ label="Total Elapsed Time" key="total_timers_in_seconds" > - + <%= format_seconds(metric.total_timers_in_seconds) %> diff --git a/test/app_web/live/stats_live_test.exs b/test/app_web/live/stats_live_test.exs index 7fbdcc57..d33c9f3b 100644 --- a/test/app_web/live/stats_live_test.exs +++ b/test/app_web/live/stats_live_test.exs @@ -1,7 +1,7 @@ defmodule AppWeb.StatsLiveTest do # alias App.DateTimeHelper use AppWeb.ConnCase, async: true - alias App.{Item, Timer} + alias App.{Item, Timer, DateTimeHelper} import Phoenix.LiveViewTest @person_id 55 @@ -32,27 +32,27 @@ defmodule AppWeb.StatsLiveTest do assert render(page_live) =~ "Stats" # two items and one timer expected - # assert page_live |> element("td[data-test-id=person_id]") |> render() =~ - # "55" + 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 |> render() =~ "1" - # assert page_live - # |> element("td[data-test-id=first_inserted_at]") - # |> render() =~ - # DateTimeHelper.format_date(started) + assert page_live + |> 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]") - # |> render() =~ - # DateTimeHelper.format_date(started) + assert page_live + |> 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]") - # |> render() =~ - # "" + assert page_live + |> element("td[data-test-id=total_timers_in_seconds_55]") + |> render() =~ + "" end test "handle broadcast when item is created", %{conn: conn} do @@ -63,8 +63,8 @@ defmodule AppWeb.StatsLiveTest do {:ok, page_live, _html} = live(conn, "/stats") assert render(page_live) =~ "Stats" - # num of items - this selector finds multiple items so this fails ... - # assert page_live |> element("td[data-test-id=num_items]") |> render() =~ "5" + + assert page_live |> element("td[data-test-id=num_items_55]") |> render() =~ "5" # Creating another item. AppWeb.Endpoint.broadcast( @@ -74,7 +74,7 @@ defmodule AppWeb.StatsLiveTest do ) # 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( @@ -84,7 +84,7 @@ defmodule AppWeb.StatsLiveTest do ) # 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 @@ -95,9 +95,8 @@ defmodule AppWeb.StatsLiveTest do {:ok, page_live, _html} = live(conn, "/stats") assert render(page_live) =~ "Stats" - # num of timers - this selector returns multiple elements. - # assert page_live |> element("td[data-test-id=num_timers]") |> render() =~ - # "0" + assert page_live |> element("td[data-test-id=num_timers_55]") |> render() =~ + "0" # Creating a timer. AppWeb.Endpoint.broadcast( @@ -106,9 +105,8 @@ defmodule AppWeb.StatsLiveTest do {:create, payload: %{person_id: @person_id}} ) - # num of timers - currently returning multiple elements ... - # assert page_live |> element("td[data-test-id=num_timers]") |> render() =~ - # "1" + 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( @@ -147,7 +145,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" @@ -155,9 +153,9 @@ 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" - first_element |> Floki.text() + assert first_element |> Floki.text() =~ "1" + first_element |> Floki.text() |> dbg() end end From 99269c2c378309c2122f0ef291bf1204500ca000 Mon Sep 17 00:00:00 2001 From: panoramix360 Date: Tue, 14 Nov 2023 11:08:39 -0300 Subject: [PATCH 12/21] test: adding tags_live tests --- lib/app_web/live/tags_live.html.heex | 27 ++++++++++------ test/app_web/live/stats_live_test.exs | 1 - test/app_web/live/tags_live_test.exs | 45 +++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 10 deletions(-) create mode 100644 test/app_web/live/tags_live_test.exs diff --git a/lib/app_web/live/tags_live.html.heex b/lib/app_web/live/tags_live.html.heex index 706cd819..ad9cc9b1 100644 --- a/lib/app_web/live/tags_live.html.heex +++ b/lib/app_web/live/tags_live.html.heex @@ -9,13 +9,14 @@ highlight={fn _ -> false end} > <:column :let={tag} label="Name" key="text"> - + <%= tag.text %> <:column :let={tag} label="Color" key="color"> - + + "> - - <%= format_date(tag.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 %> @@ -43,7 +48,8 @@ <:column :let={tag} label="Items Count" key="items_count"> - + + > <%= tag.items_count %> @@ -51,13 +57,16 @@ <:column :let={tag} label="Total Time Logged" key="total_time_logged"> - - <%= format_seconds(tag.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)) %> diff --git a/test/app_web/live/stats_live_test.exs b/test/app_web/live/stats_live_test.exs index d33c9f3b..a86a57b7 100644 --- a/test/app_web/live/stats_live_test.exs +++ b/test/app_web/live/stats_live_test.exs @@ -156,6 +156,5 @@ defmodule AppWeb.StatsLiveTest do [first_element | _] = Floki.find(result, "td[data-test-id=person_id_1]") assert first_element |> Floki.text() =~ "1" - first_element |> Floki.text() |> dbg() end end diff --git a/test/app_web/live/tags_live_test.exs b/test/app_web/live/tags_live_test.exs new file mode 100644 index 00000000..506dc038 --- /dev/null +++ b/test/app_web/live/tags_live_test.exs @@ -0,0 +1,45 @@ +defmodule AppWeb.TagsLiveTest do + use AppWeb.ConnCase, async: true + alias App.{Item, Timer, Tag} + import Phoenix.LiveViewTest + + @person_id 55 + + 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 + # add_tag(%{person_id: @person_id, text: "Tag1", color: "#000000"}) + # add_tag(%{person_id: @person_id, text: "Tag2", color: "#000000"}) + # add_tag(%{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_1]") + # |> render() =~ + # "start!s" + # end + + defp add_tag(attrs) do + {:ok, tag} = Tag.create_tag(attrs) + + {:ok, %{model: item}} = Item.create_item(%{ + person_id: @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: @person_id, start: seconds_ago_date}) + Timer.stop_timer_for_item_id(item.id) + + tag + end +end From 0e94dc903006627723ba5795906f957df8f05a75 Mon Sep 17 00:00:00 2001 From: panoramix360 Date: Tue, 14 Nov 2023 11:29:59 -0300 Subject: [PATCH 13/21] fix: merge conflicts fixes --- lib/app_web/live/tags_live.html.heex | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/lib/app_web/live/tags_live.html.heex b/lib/app_web/live/tags_live.html.heex index ad9cc9b1..35403d11 100644 --- a/lib/app_web/live/tags_live.html.heex +++ b/lib/app_web/live/tags_live.html.heex @@ -1,5 +1,9 @@ -<%= render(AppWeb.NavView, "nav.html", assigns) %> -<.h2 class="text-center mt-3">Listing Tags +
+
+

+ Listing Tags +

+ <.live_component module={AppWeb.TableComponent} id="tags_table_component" @@ -16,7 +20,6 @@ <:column :let={tag} label="Color" key="color"> - "> - > <%= format_date(tag.inserted_at) %> + <%= format_date(tag.inserted_at) %> @@ -38,7 +41,6 @@ class="px-6 py-4 text-center" data-test-id={"last_used_at_#{tag.id}"} > - > <%= if tag.last_used_at do %> <%= format_date(tag.last_used_at) %> <% else %> @@ -49,7 +51,6 @@ <:column :let={tag} label="Items Count" key="items_count"> - > <%= tag.items_count %> @@ -61,7 +62,7 @@ class="px-6 py-4 text-center" data-test-id={"total_time_logged_#{tag.id}"} > - > <%= format_seconds(tag.total_time_logged) %> + <%= format_seconds(tag.total_time_logged) %> From fbec564b88402a2b9c29e0bfa3e4e214708b5265 Mon Sep 17 00:00:00 2001 From: panoramix360 Date: Tue, 14 Nov 2023 11:31:29 -0300 Subject: [PATCH 14/21] fix: merge conflicts tests --- test/app_web/controllers/tag_controller_test.exs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/test/app_web/controllers/tag_controller_test.exs b/test/app_web/controllers/tag_controller_test.exs index b81a89c4..8d44964b 100644 --- a/test/app_web/controllers/tag_controller_test.exs +++ b/test/app_web/controllers/tag_controller_test.exs @@ -44,17 +44,6 @@ defmodule AppWeb.TagControllerTest do end end - describe "new tag" do - test "renders form for creating a tag", %{conn: conn} do - conn = - conn - |> assign(:jwt, AuthPlug.Token.generate_jwt!(%{id: 1, picture: ""})) - |> get(Routes.tag_path(conn, :new)) - - assert html_response(conn, 200) =~ "New Tag" - end - end - describe "edit tag" do setup [:create_tag] From 4750e95822cd763ddd48197882579d602360464c Mon Sep 17 00:00:00 2001 From: panoramix360 Date: Tue, 14 Nov 2023 11:34:57 -0300 Subject: [PATCH 15/21] fix: moving tests to lessen the modified files --- .../controllers/tag_controller_test.exs | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/test/app_web/controllers/tag_controller_test.exs b/test/app_web/controllers/tag_controller_test.exs index 8d44964b..415dfb76 100644 --- a/test/app_web/controllers/tag_controller_test.exs +++ b/test/app_web/controllers/tag_controller_test.exs @@ -11,17 +11,6 @@ defmodule AppWeb.TagControllerTest do tag end - describe "new tag" do - test "renders form for creating a tag", %{conn: conn} do - conn = - conn - |> assign(:jwt, AuthPlug.Token.generate_jwt!(%{id: 1, picture: ""})) - |> get(Routes.tag_path(conn, :new)) - - assert html_response(conn, 200) =~ "New Tag" - end - end - describe "create tag" do test "redirects to show when data is valid", %{conn: conn} do conn = @@ -44,6 +33,17 @@ defmodule AppWeb.TagControllerTest do end end + describe "new tag" do + test "renders form for creating a tag", %{conn: conn} do + conn = + conn + |> assign(:jwt, AuthPlug.Token.generate_jwt!(%{id: 1, picture: ""})) + |> get(Routes.tag_path(conn, :new)) + + assert html_response(conn, 200) =~ "New Tag" + end + end + describe "edit tag" do setup [:create_tag] From 822c6d96ccf85a6ac7443bfd56a29298b568a6fb Mon Sep 17 00:00:00 2001 From: panoramix360 Date: Tue, 14 Nov 2023 11:39:22 -0300 Subject: [PATCH 16/21] test: changed comment tags so it can be tested correctly when needed --- test/app_web/live/tags_live_test.exs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/app_web/live/tags_live_test.exs b/test/app_web/live/tags_live_test.exs index 506dc038..d80fe843 100644 --- a/test/app_web/live/tags_live_test.exs +++ b/test/app_web/live/tags_live_test.exs @@ -12,7 +12,7 @@ defmodule AppWeb.TagsLiveTest do end # test "display tags on table", %{conn: conn} do - # add_tag(%{person_id: @person_id, text: "Tag1", color: "#000000"}) + # tag1 = add_tag(%{person_id: @person_id, text: "Tag1", color: "#000000"}) # add_tag(%{person_id: @person_id, text: "Tag2", color: "#000000"}) # add_tag(%{person_id: @person_id, text: "Tag3", color: "#000000"}) @@ -21,9 +21,9 @@ defmodule AppWeb.TagsLiveTest do # assert render(page_live) =~ "Tags" # assert page_live - # |> element("td[data-test-id=text_1]") + # |> element("td[data-test-id=text_#{tag1.id}") # |> render() =~ - # "start!s" + # "Tag1" # end defp add_tag(attrs) do From 3e091112e9cb49bde6b46f84f6754d6aa028b5c3 Mon Sep 17 00:00:00 2001 From: nelsonic Date: Tue, 14 Nov 2023 22:07:46 +0000 Subject: [PATCH 17/21] style "Create Tag" button on /tags page #396 --- lib/app_web/live/tags_live.html.heex | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/app_web/live/tags_live.html.heex b/lib/app_web/live/tags_live.html.heex index 35403d11..b73d85bd 100644 --- a/lib/app_web/live/tags_live.html.heex +++ b/lib/app_web/live/tags_live.html.heex @@ -85,7 +85,8 @@ link_type="a" to={Routes.tag_path(@socket, :new)} label="Create Tag" - class="my-2" + 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" />
From 47240b85f9feb0879e51f713a96c0e01b8fc2cc2 Mon Sep 17 00:00:00 2001 From: panoramix360 Date: Wed, 3 Jan 2024 11:47:22 -0300 Subject: [PATCH 18/21] test: creating tests for tags_live --- test/app_web/live/tags_live_test.exs | 63 +++++++++++++++++++++------- 1 file changed, 48 insertions(+), 15 deletions(-) diff --git a/test/app_web/live/tags_live_test.exs b/test/app_web/live/tags_live_test.exs index d80fe843..26cb895b 100644 --- a/test/app_web/live/tags_live_test.exs +++ b/test/app_web/live/tags_live_test.exs @@ -3,7 +3,7 @@ defmodule AppWeb.TagsLiveTest do alias App.{Item, Timer, Tag} import Phoenix.LiveViewTest - @person_id 55 + @person_id 0 test "disconnected and connected render", %{conn: conn} do {:ok, page_live, disconnected_html} = live(conn, "/tags") @@ -11,33 +11,66 @@ defmodule AppWeb.TagsLiveTest do assert render(page_live) =~ "Tags" end - # test "display tags on table", %{conn: conn} do - # tag1 = add_tag(%{person_id: @person_id, text: "Tag1", color: "#000000"}) - # add_tag(%{person_id: @person_id, text: "Tag2", color: "#000000"}) - # add_tag(%{person_id: @person_id, text: "Tag3", color: "#000000"}) + 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") + {:ok, page_live, _html} = live(conn, "/tags") - # assert render(page_live) =~ "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"}) - # assert page_live - # |> element("td[data-test-id=text_#{tag1.id}") - # |> render() =~ - # "Tag1" - # end + {: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_tag(attrs) do + defp add_test_tag_with_details(attrs) do {:ok, tag} = Tag.create_tag(attrs) {:ok, %{model: item}} = Item.create_item(%{ - person_id: @person_id, + 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: @person_id, start: seconds_ago_date}) + Timer.start(%{item_id: item.id, person_id: tag.person_id, start: seconds_ago_date}) Timer.stop_timer_for_item_id(item.id) tag From 47df6931c674072ef3e96a1f5642f8477010e491 Mon Sep 17 00:00:00 2001 From: panoramix360 Date: Wed, 3 Jan 2024 11:53:18 -0300 Subject: [PATCH 19/21] feat: removing format_date from tags_view since it's not needed anymore --- lib/app_web/views/tag_view.ex | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/app_web/views/tag_view.ex b/lib/app_web/views/tag_view.ex index 38cfcac2..d238df92 100644 --- a/lib/app_web/views/tag_view.ex +++ b/lib/app_web/views/tag_view.ex @@ -1,8 +1,3 @@ defmodule AppWeb.TagView do use AppWeb, :view - alias App.DateTimeHelper - - def format_date(date) do - DateTimeHelper.format_date(date) - end end From e29ebb29d66ccf54d0b56a5163ef04e8b820b8be Mon Sep 17 00:00:00 2001 From: panoramix360 Date: Sun, 14 Jan 2024 17:52:37 -0300 Subject: [PATCH 20/21] feat: removing validate_order based on PR review --- lib/app/repo.ex | 32 -------------------------------- lib/app/stats.ex | 5 +---- lib/app/tag.ex | 2 -- test/app/repo_test.exs | 21 --------------------- test/app/stats_test.exs | 4 ++-- test/app/tag_test.exs | 4 ++-- 6 files changed, 5 insertions(+), 63 deletions(-) diff --git a/lib/app/repo.ex b/lib/app/repo.ex index b3760533..65ca3351 100644 --- a/lib/app/repo.ex +++ b/lib/app/repo.ex @@ -3,38 +3,6 @@ defmodule App.Repo do otp_app: :app, adapter: Ecto.Adapters.Postgres - @doc """ - `validate_order/1` validates the ordering is one of `asc` or `desc` - - ## Examples - - iex> App.Repo.validate_order("asc") - true - - iex> App.Repo.validate_order(:asc) - true - - iex> App.Repo.validate_order(:invalid) - false - - # Avoid common SQL injection attacks: - iex> App.Repo.validate_order("OR 1=1") - false - """ - def validate_order(order) when is_bitstring(order) do - Enum.member?( - ~w(asc desc), - order - ) - end - - def validate_order(order) when is_atom(order) do - Enum.member?( - [:asc, :desc], - order - ) - end - def toggle_sort_order(:asc), do: :desc def toggle_sort_order(:desc), do: :asc end diff --git a/lib/app/stats.ex b/lib/app/stats.ex index f1e34d67..89f01048 100644 --- a/lib/app/stats.ex +++ b/lib/app/stats.ex @@ -26,13 +26,10 @@ defmodule App.Stats do sort_order \\ :asc ) do sort_column = to_string(sort_column) - sort_order = to_string(sort_order) sort_column = if validate_sort_column(sort_column), do: sort_column, else: "person_id" - sort_order = if Repo.validate_order(sort_order), do: sort_order, else: "asc" - sql = """ SELECT i.person_id, COUNT(distinct i.id) AS "num_items", @@ -43,7 +40,7 @@ defmodule App.Stats do FROM items i LEFT JOIN timers t ON t.item_id = i.id GROUP BY i.person_id - ORDER BY #{sort_column} #{sort_order} + ORDER BY #{sort_column} #{to_string(sort_order)} """ Ecto.Adapters.SQL.query!(Repo, sql) diff --git a/lib/app/tag.ex b/lib/app/tag.ex index 6c0a2fdb..898e54a3 100644 --- a/lib/app/tag.ex +++ b/lib/app/tag.ex @@ -100,8 +100,6 @@ defmodule App.Tag do sort_column = if validate_sort_column(sort_column), do: sort_column, else: :text - sort_order = if Repo.validate_order(sort_order), do: sort_order, else: :asc - Tag |> where(person_id: ^person_id) |> join(:left, [t], it in ItemTag, on: t.id == it.tag_id) diff --git a/test/app/repo_test.exs b/test/app/repo_test.exs index b9d37987..1d22fa9c 100644 --- a/test/app/repo_test.exs +++ b/test/app/repo_test.exs @@ -2,27 +2,6 @@ defmodule App.RepoTest do use ExUnit.Case alias App.Repo - describe "validate_order/1" do - test "validates 'asc' and 'desc' as a valid string order" do - assert Repo.validate_order("asc") == true - assert Repo.validate_order("desc") == true - end - - test "validates :asc and :desc as a valid atom order" do - assert Repo.validate_order(:asc) == true - assert Repo.validate_order(:desc) == true - end - - test "rejects invalid string order" do - assert Repo.validate_order("invalid") == false - assert Repo.validate_order(:invalid) == false - end - - test "rejects SQL injection attempt" do - assert Repo.validate_order("OR 1=1") == false - end - end - describe "toggle_sort_order/1" do test "toggles :asc to :desc" do assert Repo.toggle_sort_order(:asc) == :desc diff --git a/test/app/stats_test.exs b/test/app/stats_test.exs index 0893b943..d4d112c7 100644 --- a/test/app/stats_test.exs +++ b/test/app/stats_test.exs @@ -84,7 +84,7 @@ defmodule App.StatsTest do refute Stats.validate_sort_column(:invalid) end - test "Stats.person_with_item_and_timer_count/1 returns a sorted list by person_id if invalid sorted column and order" do + test "Stats.person_with_item_and_timer_count/1 returns a sorted list by person_id if invalid sorted column" do {:ok, %{model: _, version: _version}} = Item.create_item(@valid_attrs) {:ok, %{model: _, version: _version}} = Item.create_item(@valid_attrs) @@ -93,7 +93,7 @@ defmodule App.StatsTest do # list person with number of timers and items result = - Stats.person_with_item_and_timer_count(:invalid_column, :invalid_order) + Stats.person_with_item_and_timer_count(:invalid_column) assert length(result) == 3 diff --git a/test/app/tag_test.exs b/test/app/tag_test.exs index b611f6eb..ec1ea008 100644 --- a/test/app/tag_test.exs +++ b/test/app/tag_test.exs @@ -135,11 +135,11 @@ defmodule App.TagTest do assert length(tags) == 1 end - test "handles invalid sort parameters" do + 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, :invalid_order) + tags = Tag.list_person_tags_complete(6, :invalid_column) assert length(tags) == 2 assert tags |> Enum.map(& &1.text) == ["AnotherTag", "BTag"] end From 74c584e883c66b46acb52253a7daee80039ca483 Mon Sep 17 00:00:00 2001 From: panoramix360 Date: Tue, 16 Jan 2024 10:31:04 -0300 Subject: [PATCH 21/21] chore: adding the docs to BUILDIT --- BUILDIT.md | 618 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 598 insertions(+), 20 deletions(-) diff --git a/BUILDIT.md b/BUILDIT.md index ce194dd9..a1fd05a2 100644 --- a/BUILDIT.md +++ b/BUILDIT.md @@ -113,16 +113,22 @@ With that in place, let's get building! - [14.3 Building the Stats Page](#143-building-the-stats-page) - [14.4 Broadcasting to `stats` channel](#144-broadcasting-to-stats-channel) - [14.5 Adding tests](#145-adding-tests) -- [15. `People` in Different Timezones 🌐](#15-people-in-different-timezones-) - - [15.1 Getting the `person`'s Timezone](#151-getting-the-persons-timezone) - - [15.2 Changing how the timer datetime is displayed](#152-changing-how-the-timer-datetime-is-displayed) - - [15.3 Persisting the adjusted timezone](#153-persisting-the-adjusted-timezone) - - [15.4 Adding test](#154-adding-test) -- [16. `Lists`](#16-lists) -- [17. Reordering `items` Using Drag \& Drop](#17-reordering-items-using-drag--drop) -- [18. Run the _Finished_ MVP App!](#18-run-the-finished-mvp-app) - - [18.1 Run the Tests](#181-run-the-tests) - - [18.2 Run The App](#182-run-the-app) +- [15. Implementing Enhanced Tag Details and Sorting](#15-implementing-enhanced-tag-details-and-sorting) + - [Implementing the `toggle_sort_order` in `Repo.ex`](#implementing-the-toggle_sort_order-in-repoex) + - [Extending the `Tag` Model](#extending-the-tag-model) + - [Adding the new columns on the `Tags` Page](#adding-the-new-columns-on-the-tags-page) + - [Creating the LiveView HTML template](#creating-the-liveview-html-template) + - [Adding the new page to the router](#adding-the-new-page-to-the-router) +- [16. `People` in Different Timezones 🌐](#16-people-in-different-timezones-) + - [16.1 Getting the `person`'s Timezone](#161-getting-the-persons-timezone) + - [16.2 Changing how the timer datetime is displayed](#162-changing-how-the-timer-datetime-is-displayed) + - [16.3 Persisting the adjusted timezone](#163-persisting-the-adjusted-timezone) + - [16.4 Adding test](#164-adding-test) +- [17. `Lists`](#17-lists) +- [18. Reordering `items` Using Drag \& Drop](#18-reordering-items-using-drag--drop) +- [19. Run the _Finished_ MVP App!](#19-run-the-finished-mvp-app) + - [19.1 Run the Tests](#191-run-the-tests) + - [19.2 Run The App](#192-run-the-app) - [Thanks!](#thanks) @@ -5396,8 +5402,580 @@ when creating `timers` or `items`. ![stats_final](https://user-images.githubusercontent.com/17494745/211345854-c541d21c-4289-4576-8fcf-c3b89251ed02.gif) +# 15. Implementing Enhanced Tag Details and Sorting -# 15. `People` in Different Timezones 🌐 +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` 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. + +`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 these 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 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 + +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 template + +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 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 +... + + 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 them: + +`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! + +# 16. `People` in Different Timezones 🌐 Our application works not only for ourselves but in a *collaborative environment*. @@ -5445,7 +6023,7 @@ the value of the `Datetime` of the `timer` *wouldn't make sense to you*. -## 15.1 Getting the `person`'s Timezone +## 16.1 Getting the `person`'s Timezone The easiest way to solve this is to only change how the `timers` are displayed @@ -5525,7 +6103,7 @@ But before that, let's adjust how it is *displayed to the person*. -## 15.2 Changing how the timer datetime is displayed +## 16.2 Changing how the timer datetime is displayed Open `lib/app_web/live/app_live.html.heex` and locate the line @@ -5574,7 +6152,7 @@ please check [`lib/app_web/live/app_live.html.heex`](https://github.com/dwyl/mvp/blob/63d98958be8f858e6ebcd063fa022bb59964b612/lib/app_web/live/app_live.html.heex#L326-L341). -## 15.3 Persisting the adjusted timezone +## 16.3 Persisting the adjusted timezone Now that we are displaying the correct timezones, we need to make sure the adjusted updated timer @@ -5705,7 +6283,7 @@ please check [`lib/app_web/live/app_live.ex`](https://github.com/dwyl/mvp/blob/63d98958be8f858e6ebcd063fa022bb59964b612/lib/app_web/live/app_live.ex#L218). -## 15.4 Adding test +## 16.4 Adding test Let's add a test case that will check if the datetime is shown with an offset that is mocked during testing. @@ -5803,7 +6381,7 @@ we expect the persisted value to be one hour *less* than what the person inputted. -# 16. `Lists` +# 17. `Lists` In preparation for the next set of features in the `MVP`, we added `lists` @@ -5823,7 +6401,7 @@ please comment on the issue: -# 17. Reordering `items` Using Drag & Drop +# 18. Reordering `items` Using Drag & Drop At present `people` using the `App` can only add new `items` to a stack @@ -5848,11 +6426,11 @@ please see: -# 18. Run the _Finished_ MVP App! +# 19. Run the _Finished_ MVP App! With all the code saved, let's run the tests one more time. -## 18.1 Run the Tests +## 19.1 Run the Tests In your terminal window, run: @@ -5895,7 +6473,7 @@ COV FILE LINES RELEVANT MISSED All tests pass and we have **`100%` Test Coverage**. This reminds us just how few _relevant_ lines of code there are in the MVP! -## 18.2 Run The App +## 19.2 Run The App In your second terminal tab/window, run: