diff --git a/BUILDIT.md b/BUILDIT.md index 09ae5a77..b2c58912 100644 --- a/BUILDIT.md +++ b/BUILDIT.md @@ -2782,303 +2782,20 @@ not just `ISO6601`. Currently, Elixir doesn't have a way to create a datetime object from any string format. For this, we are going use -[`Bluzky's datetime parser`](https://gist.github.com/bluzky/62a20cdb57b17f47c67261c10aa3da8b). - -Create a file in `lib/app/datetime_parser.ex` and -use the following code: +[`Timex`](https://github.com/bitwalker/timex). +In `mix.exs`, add the following piece of code in the `deps` section. ```elixir -# Full credit of this module goes to https://dev.to/onpointvn/build-your-own-date-time-parser-in-elixir-50be -# Do check the gist -> https://gist.github.com/bluzky/62a20cdb57b17f47c67261c10aa3da8b -defmodule App.DateTimeParser do - @mapping %{ - "H" => "(?\\d{2})", - "I" => "(?\\d{2})", - "M" => "(?\\d{2})", - "S" => "(?\\d{2})", - "d" => "(?\\d{2})", - "m" => "(?\\d{2})", - "y" => "(?\\d{2})", - "Y" => "(?-?\\d{4})", - "z" => "(?[+-]?\\d{4})", - "Z" => "(?[a-zA-Z_\/]+)", - "p" => "(?

PM|AM)", - "P" => "(?

pm|am)", - "%" => "%" - } - - @doc """ - Parse string to datetime struct - **Example** - parse("2021-20-10", "%Y-%M-%d") - Support format - | format | description| value example | - | -- | -- | -- | - | H | 24 hour | 00 - 23 | - | I | 12 hour | 00 - 12 | - | M | minute| 00 - 59 | - | S | second | 00 - 59 | - | d | day | 01 - 31 | - | m | month | 01 -12 | - | y | 2 digits year | 00 - 99 | - | Y | 4 digits year | | - | z | timezone offset | +0100, -0330 | - | Z | timezone name | UTC+7, Asia/Ho_Chi_Minh | - | p | PM or AM | | - | P | pm or am | | - """ - def parse!(dt_string, format \\ "%Y-%m-%dT%H:%M:%SZ") do - case parse(dt_string, format) do - {:ok, dt} -> - dt - - {:error, message} -> - raise "Parse string #{dt_string} with error: #{message}" - end - end - - @doc """ - Parses the string according to the format. Pipes through regex compilation, casts each part of the string to a named regex capture and tries to convert to datetime. - """ - def parse(dt_string, format \\ "%Y-%m-%dT%H:%M:%SZ") do - format - |> build_regex - |> Regex.named_captures(dt_string) - |> cast_data - |> to_datetime - end - - @doc """ - Builds the regex expression to later be captured (have named key-value captures).any() - This uses the @mapping structure to name specific parts of the entered string to convert to datetime. - """ - def build_regex(format) do - keys = Map.keys(@mapping) |> Enum.join("") - - Regex.compile!("([^%]*)%([#{keys}])([^%]*)") - |> Regex.scan(format) - |> Enum.map(fn [_, s1, key, s2] -> - [s1, Map.get(@mapping, key), s2] - end) - |> to_string() - |> Regex.compile!() - end - - @default_value %{ - day: 1, - month: 1, - year: 0, - hour: 0, - minute: 0, - second: 0, - utc_offset: 0, - tz_name: "UTC", - shift: "AM" - } - def cast_data(nil), do: {:error, "invalid datetime"} - - @doc """ - Casts each capture of the regex to appropriated format (compatible with DateTime struct) - """ - def cast_data(captures) do - captures - |> Enum.reduce_while([], fn {part, value}, acc -> - {:ok, data} = cast(part, value) - {:cont, [data | acc]} - end) - |> Enum.into(@default_value) - end - - @value_rages %{ - "hour" => [0, 23], - "hour12" => [0, 12], - "minute" => [0, 59], - "second" => [0, 59], - "day" => [0, 31], - "month" => [1, 12], - "year2" => [0, 99] - } - - defp cast("P", value) do - cast("p", String.upcase(value)) - end - - defp cast("p", value) do - {:ok, {:shift, value}} - end - - defp cast("tz", value) do - {hour, minute} = String.split_at(value, 3) - - with {:ok, {_, hour}} <- cast("offset_h", hour), - {:ok, {_, minute}} <- cast("offset_m", minute) do - sign = div(hour, abs(hour)) - {:ok, {:utc_offset, sign * (abs(hour) * 3600 + minute * 60)}} - end - end - - defp cast("tz_name", value) do - {:ok, {:tz_name, value}} - end - - defp cast(part, value) do - value = String.to_integer(value) - - valid = - case Map.get(@value_rages, part) do - [min, max] -> - value >= min and value <= max - - _ -> - true - end - - if valid do - {:ok, {String.to_atom(part), value}} - end - end - - defp to_datetime({:error, _} = error), do: error - - defp to_datetime(%{year2: value} = data) do - current_year = DateTime.utc_now() |> Map.get(:year) - year = div(current_year, 100) * 100 + value - - data - |> Map.put(:year, year) - |> Map.delete(:year2) - |> to_datetime() - end - - defp to_datetime(%{hour12: hour} = data) do - # 12AM is not valid - - if hour == 12 and data.shift == "AM" do - {:error, "12AM is invalid value"} - else - hour = - cond do - hour == 12 and data.shift == "PM" -> hour - data.shift == "AM" -> hour - data.shift == "PM" -> hour + 12 - end - - data - |> Map.put(:hour, hour) - |> Map.delete(:hour12) - |> to_datetime() - end - end - - defp to_datetime(data) do - with {:ok, date} <- Date.new(data.year, data.month, data.day), - {:ok, time} <- Time.new(data.hour, data.minute, data.second), - {:ok, datetime} <- DateTime.new(date, time) do - datetime = DateTime.add(datetime, -data.utc_offset, :second) - - if data.tz_name != "UTC" do - {:error, "Only UTC timezone is available"} - else - {:ok, datetime} - end - end - end -end +{:timex, "~> 3.7"}, ``` -With this parser, we have access to `parse/2` where we can +and run `mix deps.get`. +This will download and install the package so we can use it. + +With this library, we have access to `parse/3` where we can create a DateTime object from a string according to a given format. We are going to be using this later on. -For now, let's add some tests to this module. -Create a new test file in -`test/app/datetime_parser_test.exs` -and add the following tests. - -```elixir -defmodule App.DateTimeParserTest do - use App.DataCase - alias App.DateTimeParser - - test "valid parse of valid datetime" do - parsed_time = - DateTimeParser.parse!("2022-10-27 14:47:56", "%Y-%m-%d %H:%M:%S") - - {:ok, expected_datetime, 0} = DateTime.from_iso8601("2022-10-27T14:47:56Z") - - assert parsed_time == expected_datetime - end - - test "valid parse of valid date with %Y-%m-%d format" do - parsed_time = DateTimeParser.parse!("2022-10-27", "%Y-%m-%d") - {:ok, expected_datetime, 0} = DateTime.from_iso8601("2022-10-27T00:00:00Z") - - assert parsed_time == expected_datetime - end - - test "non-compatible regex when parsing" do - assert_raise Regex.CompileError, fn -> - DateTimeParser.parse!("2022-10-27 14:47:56", "%Y-%Y-%Y") - end - end - - test "invalid datetime format" do - assert_raise RuntimeError, fn -> - DateTimeParser.parse!("2022-102-2752 1423:4127:56", "%Y-%m-%d %H:%M:%S") - end - end - - test "valid timezone offset (with tz)" do - parsed_date = - DateTimeParser.parse!("2022-10-27T00:00:00Z+0230", "%Y-%m-%dT%H:%M:%SZ%z") - - {:ok, expected_datetime, 9000} = - DateTime.from_iso8601("2022-10-27T00:00:00+02:30") - - assert parsed_date == expected_datetime - end - - test "valid timezone offset (with UTC)" do - parsed_date = - DateTimeParser.parse!( - "2022-10-27T00:00:00ZUTC+0230", - "%Y-%m-%dT%H:%M:%SZ%Z" - ) - - assert parsed_date == ~U[2022-10-27 00:00:00Z] - end - - test "invalid timezone name" do - assert_raise RuntimeError, fn -> - DateTimeParser.parse!( - "2022-10-27T00:00:00ZEtc+0230", - "%Y-%m-%dT%H:%M:%SZ%Z" - ) - end - end - - test "valid datetime with PM/AM" do - date_under = DateTimeParser.parse!("2022-10-27 06:02pm", "%Y-%m-%d %H:%M%P") - date_sup = DateTimeParser.parse!("2022-10-27 06:02PM", "%Y-%m-%d %H:%M%p") - - assert date_under == date_sup - end - - test "valid datetime with PM/AM with two digits" do - parsed_datetime = - DateTimeParser.parse!("2022-10-27 06:02pm", "%Y-%m-%d %I:%M%P") - - assert parsed_datetime == ~U[2022-10-27 18:02:00Z] - end - - test "valid datetime with two-digit year" do - parsed_date = DateTimeParser.parse!("10-10-27", "%y-%m-%d") - - assert parsed_date == ~U[2010-10-27 00:00:00Z] - end -end -``` - ## 12.2 Persisting update in database So far we can only start, stop and fetch timers. We need a way to directly update a specific timer through their `id`. @@ -3117,17 +2834,30 @@ This will properly reference `Timer` to the `Item` object. end ``` -In the same file, let us add a way to list all the timers associated -with a certain `item` id. Paste the following. +In the same file, let us add a way to list all the timer changesets associated +with a certain `item` id. +We are returning changesets because of form validation. +In case an error occurs, we want to provide feedback to the user. +To do this, we use these changesets and add errors to them, +which will later be displayed on the UI. +Paste the following. ```elixir - def list_timers(item_id) do + def list_timers_changesets(item_id) do from(v in Timer, where: [item_id: ^item_id], order_by: [asc: :id]) |> Repo.all() - end + |> Enum.map(fn t -> + Timer.changeset(t, %{ + id: t.id, + start: t.start, + stop: t.stop, + item_id: t.item_id + }) + end) + end ``` -## 12.3 Showing timers in UI +## 12.3 Adding event handler in `app_live.ex` We need a way to show the timers related to an `item` in the UI. Currently, in `lib/app_web/live/app_live.ex`, every time the user edits an item, an `edit-timer` event is propped up, setting the @@ -3136,7 +2866,7 @@ socket assigns accordingly. We want to fetch the timers of an item *ad-hoc*. Instead of loading all the timers on mount, it's best to dynamically fetch the timers whenever we want to edit a timer. For this, we are going to add an -**array of timer changesets** to the docket assigns and show these +**array of timer changesets** to the socket assigns and show these when editing a timer. Let's do that. In `lib/app_web/live/app_live.ex`, in the `mount` function, add @@ -3153,24 +2883,14 @@ In `lib/app_web/live/app_live.ex`, in the `mount` function, add ``` Let's change the `handle_event` handler for the `edit-item` event -to fetch the timers when editing an item. Change the function +to fetch the timer changesets when editing an item. Change the function to the following: ```elixir def handle_event("edit-item", data, socket) do item_id = String.to_integer(data["id"]) - timers_list = Timer.list_timers(item_id) - - timers_list_changeset = - Enum.map(timers_list, fn t -> - Timer.changeset(t, %{ - id: t.id, - start: t.start, - stop: t.stop, - item_id: t.item_id - }) - end) + timers_list_changeset = Timer.list_timers_changesets(item_id) {:noreply, assign(socket, editing: item_id, editing_timers: timers_list_changeset)} @@ -3201,101 +2921,387 @@ in the same file. }, socket ) do + timer_changeset_list = socket.assigns.editing_timers index = String.to_integer(index) + + case Timer.update_timer_inside_changeset_list(id, timer_start, timer_stop, index, timer_changeset_list) do + {:ok, _list} -> {:noreply, assign(socket, editing: nil, editing_timers: [])} + {:error, updated_errored_list} -> {:noreply, assign(socket, editing_timers: updated_errored_list)} + end + end +``` + +Let's do a rundown of what we just added. +From the form, we receive an `index` of the timer inside the `editing_timers` +socket assign array. We use this `index` to replace the changeset being edited +in case there's an error with the string format or the dates. + +We are calling a function `update_timer_inside_changeset_list/5` +that we will implement shortly, This function will either +update the timer successfully or return an error, +with an updated list of timer changesets to display the error on the UI. + +We want the users to be able to update timers even when +there's an ongoing timer and have the users still +see the list of timers. +For this, we ought to update the events that are created +when clicking `Resume` or `Stop`. +Therefore, we need to these handlers and the broadcast +`update` event that is sent to all users. + +Let's check the `start` and `stop` event handlers inside `app_live.ex`. +Let's add information to the event with the `item.id` that is being edited. +Change these event handlers so they look like this. + +```elixir + @impl true + def handle_event("start", data, socket) do + item = Item.get_item!(Map.get(data, "id")) + person_id = get_person_id(socket.assigns) + + {:ok, _timer} = + Timer.start(%{ + item_id: item.id, + person_id: person_id, + start: NaiveDateTime.utc_now() + }) + + AppWeb.Endpoint.broadcast(@topic, "update", {:start, item.id}) + {:noreply, socket} + end + + @impl true + def handle_event("stop", data, socket) do + timer_id = Map.get(data, "timerid") + {:ok, _timer} = Timer.stop(%{id: timer_id}) + + AppWeb.Endpoint.broadcast(@topic, "update", {:stop, Map.get(data, "id")}) + {:noreply, socket} + end +``` + +Now we need to update the `handle_info/2` event handler +that deals with this broadcasting event that is used +everytime `Start/Resume` or `Stop` is called. + +```elixir + @impl true + def handle_info(%Broadcast{event: "update", payload: payload}, socket) do + person_id = get_person_id(socket.assigns) + items = Item.items_with_timers(person_id) + + isEditingItem = socket.assigns.editing + + # If the item is being edited, we update the timer list of the item being edited. + if isEditingItem do + case payload do + {:start, item_id} -> + timers_list_changeset = Timer.list_timers_changesets(item_id) + + {:noreply, + assign(socket, + items: items, + editing: item_id, + editing_timers: timers_list_changeset + )} + + {:stop, item_id} -> + timers_list_changeset = Timer.list_timers_changesets(item_id) + + {:noreply, + assign(socket, + items: items, + editing: item_id, + editing_timers: timers_list_changeset + )} + + _ -> + {:noreply, assign(socket, items: items)} + end + + # If not, just update the item list. + else + {:noreply, assign(socket, items: items)} + end + end +``` + +Now, everytime the `update` event is broadcasted, +we update the timer list if the item is being edited. +If not, we update the timer list, as normally. +What this does is that every user will have the `socket.assigns` +properly updated everytime a timer is edited. + + +## 12.4 Updating timer changeset list on `timer.ex` +Let's create the unimplemented function that we +previously added. +In the `timer.ex` file, add the following. + +```elixir +def update_timer_inside_changeset_list( + timer_id, + timer_start, + timer_stop, + index, + timer_changeset_list + ) when timer_stop == "" or timer_stop == nil do + + # Getting the changeset to change in case there's an error changeset_obj = Enum.at(timer_changeset_list, index) try do - start = App.DateTimeParser.parse!(timer_start, "%Y-%m-%dT%H:%M:%S") - stop = App.DateTimeParser.parse!(timer_stop, "%Y-%m-%dT%H:%M:%S") - case DateTime.compare(start, stop) do - :lt -> - Timer.update_timer(%{id: id, start: start, stop: stop}) - {:noreply, assign(socket, editing: nil, editing_timers: [])} + # Parsing the dates + {start_op, start} = + Timex.parse(timer_start, "%Y-%m-%dT%H:%M:%S", :strftime) - :eq -> - updated_changeset_timers_list = - error_timer_changeset( - timer_changeset_list, - changeset_obj, - index, - :id, - "Start or stop are equal." - ) + # Error guards when parsing the date + if start_op === :error do + throw(:error_invalid_start) + end - {:noreply, - assign(socket, editing_timers: updated_changeset_timers_list)} + # Getting a list of the other timers (the rest we aren't updating) + other_timers_list = List.delete_at(timer_changeset_list, index) + + # Latest timer end + max_end = + other_timers_list |> Enum.map(fn chs -> chs.data.stop end) |> Enum.max() + case NaiveDateTime.compare(start, max_end) do :gt -> - updated_changeset_timers_list = - error_timer_changeset( - timer_changeset_list, - changeset_obj, - index, - :id, - "Start is newer that stop." - ) + update_timer(%{id: timer_id, start: start, stop: nil}) + {:ok, []} - {:noreply, - assign(socket, editing_timers: updated_changeset_timers_list)} + _ -> throw(:error_not_after_others) end - rescue - e -> + catch + :error_invalid_start -> updated_changeset_timers_list = - error_timer_changeset( + Timer.error_timer_changeset( timer_changeset_list, changeset_obj, index, :id, - "Date format invalid on either start or stop." + "Start field has an invalid date format.", + :update ) - {:noreply, - assign(socket, editing_timers: updated_changeset_timers_list)} + {:error, updated_changeset_timers_list} + + :error_not_after_others -> + updated_changeset_timers_list = + Timer.error_timer_changeset( + timer_changeset_list, + changeset_obj, + index, + :id, + "When editing an ongoing timer, make sure it's after all the others.", + :update + ) + + {:error, updated_changeset_timers_list} end end -``` + def update_timer_inside_changeset_list( + timer_id, + timer_start, + timer_stop, + index, + timer_changeset_list + ) do -Let's do a rundown of what we just created. -From the form, we receive an `index` of the timer inside the `editing_timers` -socket assign array. We use this `index` to replace the changeset in case -there's an error with the string format or the dates. + # Getting the changeset to change in case there's an error + changeset_obj = Enum.at(timer_changeset_list, index) + + try do + + # Parsing the dates + {start_op, start} = + Timex.parse(timer_start, "%Y-%m-%dT%H:%M:%S", :strftime) + + {stop_op, stop} = Timex.parse(timer_stop, "%Y-%m-%dT%H:%M:%S", :strftime) + + # Error guards when parsing the dates + if start_op === :error do + throw(:error_invalid_start) + end + + if stop_op === :error do + throw(:error_invalid_stop) + end + + case NaiveDateTime.compare(start, stop) do + :lt -> + + # Creates a list of all other timers to check for overlap + other_timers_list = List.delete_at(timer_changeset_list, index) + + # Timer overlap verification + for chs <- other_timers_list do + chs_start = chs.data.start + chs_stop = chs.data.stop + + # The condition needs to FAIL (StartA <= EndB) and (EndA >= StartB) + # so no timer overlaps one another + compareStartAEndB = NaiveDateTime.compare(start, chs_stop) + compareEndAStartB = NaiveDateTime.compare(stop, chs_start) + + if( + (compareStartAEndB == :lt || compareStartAEndB == :eq) && + (compareEndAStartB == :gt || compareEndAStartB == :eq) + ) do + throw(:error_overlap) + end + end -We try to parse the files using the specified format. If this succeeds, we compare -the `start` and `stop` parameters and check if `start` does not start after `stop`. -If everything is correct, the `Timer` is updated corretly. -If not, the timer changeset is updated with the error and replaced in the array. -We use the `error_timer_changeset/4` function to do that. Let's create it. + update_timer(%{id: timer_id, start: start, stop: stop}) + {:ok, []} + + :eq -> + throw(:error_start_equal_stop) + + :gt -> + throw(:error_start_greater_than_stop) + end + catch + :error_invalid_start -> + updated_changeset_timers_list = + Timer.error_timer_changeset( + timer_changeset_list, + changeset_obj, + index, + :id, + "Start field has an invalid date format.", + :update + ) + + {:error, updated_changeset_timers_list} + + :error_invalid_stop -> + updated_changeset_timers_list = + Timer.error_timer_changeset( + timer_changeset_list, + changeset_obj, + index, + :id, + "Stop field has an invalid date format.", + :update + ) + + {:error, updated_changeset_timers_list} + + :error_overlap -> + updated_changeset_timers_list = + Timer.error_timer_changeset( + timer_changeset_list, + changeset_obj, + index, + :id, + "This timer interval overlaps with other timers. Make sure all the timers are correct and don't overlap with each other", + :update + ) + + {:error, updated_changeset_timers_list} + + :error_start_equal_stop -> + updated_changeset_timers_list = + Timer.error_timer_changeset( + timer_changeset_list, + changeset_obj, + index, + :id, + "Start or stop are equal.", + :update + ) + + {:error, updated_changeset_timers_list} + + :error_start_greater_than_stop -> + updated_changeset_timers_list = + Timer.error_timer_changeset( + timer_changeset_list, + changeset_obj, + index, + :id, + "Start is newer that stop.", + :update + ) + + {:error, updated_changeset_timers_list} + end + end +``` + +That is a lot of code! But it's fairly simple. +Firstly, these two functions are called according to +pattern matching of the `timer_stop` field. +If `timer_stop` field is empty, we assume it's an +ongoing timer being edited. +If both `timer_start` and `timer_stop` is being edited, +it's because the user is changing an old timer. + +Inside both functions, the flow is the same. +We first get the *timer changeset* being edited +by using the `index` parameter and the passed changeset list. +After this, we try to parse the field using `Timex`. +If this doesn't work, we **throw an error**. +All of errors thrown are later catched. + +If the parse is successful, we compare the +`start` and `stop` fields and check if the `start` +is newer than `stop` or if they're equal. +This is not allowed, so we throw an error if this is the case. + +If these verifications are passed, in the case of +*ongoing timers*, we check if the timer `start` being edited +is **after all the timers**. +In the case of *old timer being updated*, +we check if there is an overlap with the rest of the timers. + +If all of these validations are successful, +the timer is updated. +If not, the error that was thrown is caught +using `catch`. +Depending on the error, we add a different error text +to be displayed on the form and then return the error. + +In each error, we make use of the `error_timer_changeset/6` +function, which just replaces the timer inside the list +with a custom error to be displayed on the form. +Let's add this function. ```elixir - defp error_timer_changeset( - timer_changeset_list, - changeset_to_error, - changeset_index, - changeset_error_key, - changeset_error_message - ) do - # Adding error to changeset + def error_timer_changeset( + timer_changeset_list, + changeset_to_error, + changeset_index, + error_key, + error_message, + action + ) do + # Clearing and adding error to changeset + cleared_changeset = Map.put(changeset_to_error, :errors, []) + errored_changeset = Ecto.Changeset.add_error( - changeset_to_error, - changeset_error_key, - changeset_error_message + cleared_changeset, + error_key, + error_message ) {_reply, errored_changeset} = - Ecto.Changeset.apply_action(errored_changeset, :update) + Ecto.Changeset.apply_action(errored_changeset, action) # Updated list with errored changeset List.replace_at(timer_changeset_list, changeset_index, errored_changeset) end ``` -This function simply adds the specified error and updates the -socket assign `editing_timer` array with the errored changeset object. - +And now all that's left is to change the UI! Let's do that. +## 12.5 Updating the UI Now let's focus on showing the timers in the UI. Head over to `lib/app_web/live/app_live.html.heex` and make the following changes. We are showing each timer whenever an `item` is being edited. @@ -3393,7 +3399,6 @@ We are showing each timer whenever an `item` is being edited. @@ -3434,10 +3439,16 @@ We are showing each timer whenever an `item` is being edited. ``` As you can see from the snippet above, -when the changes from the form are submitted, a +for each timer related to an `item`, +we are creating a form. +When the changes from the form are submitted, a `update-item-timer` event is created. +With this event, all the fields added inside +the form is passed on (the timer `id`, +`index` inside the timer changesetlist, +`timer_start` and `timer_stop`) -## 12.4 Updating the tests and going back to 100% coverage +## 12.6 Updating the tests and going back to 100% coverage If we run `source .env_sample` and `MIX_ENV=test mix coveralls.html ; open cover/excoveralls.html` we will see how coverage dropped. @@ -3489,8 +3500,6 @@ test "update an item's timer", %{conn: conn} do {:ok, seven_seconds_ago} = NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), -7)) - {:ok, now} = NaiveDateTime.new(Date.utc_today(), Time.utc_now()) - # Start the timer 7 seconds ago: {:ok, timer} = Timer.start(%{item_id: item.id, person_id: 1, start: seven_seconds_ago}) @@ -3535,15 +3544,25 @@ test "update an item's timer", %{conn: conn} do "timer_stop" => start }) =~ "Start is newer that stop." - # Trying to update with equal start greater than stop + # Trying to update with start as invalid format render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)}) assert render_submit(view, "update-item-timer", %{ "timer_id" => timer.id, "index" => 0, "timer_start" => "invalid", - "timer_stop" => "invalid" - }) =~ "Date format invalid on either start or stop." + "timer_stop" => stop + }) =~ "Start field has an invalid date format." + + # Trying to update with stop as invalid format + render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)}) + + assert render_submit(view, "update-item-timer", %{ + "timer_id" => timer.id, + "index" => 0, + "timer_start" => start, + "timer_stop" => "invalid" + }) =~ "Stop field has an invalid date format." end ``` @@ -3562,7 +3581,275 @@ Change the test to the following. end ``` -You should now have a function way to change the timers! :wink: +Let's add more tests for the edge cases. +Let's test ongoing timers and overlapping :smile: +In the same `test_live_test.exs` file, +add the following tests. + +```elixir +test "update timer timer with ongoing timer ", %{conn: conn} do + {:ok, item} = + Item.create_item(%{text: "Learn Elixir", person_id: 0, status: 2}) + + {:ok, seven_seconds_ago} = + NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), -7)) + + {:ok, now} = NaiveDateTime.new(Date.utc_today(), Time.utc_now()) + + {:ok, four_seconds_ago} = + NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), -4)) + + {:ok, ten_seconds_after} = + NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), 10)) + + # Start the timer 7 seconds ago: + {:ok, timer} = + Timer.start(%{item_id: item.id, person_id: 1, start: seven_seconds_ago}) + + # Stop the timer based on its item_id + Timer.stop_timer_for_item_id(item.id) + + # Start a second timer + {:ok, timer2} = Timer.start(%{item_id: item.id, person_id: 1, start: now}) + + # Stop the timer based on its item_id + Timer.stop_timer_for_item_id(item.id) + + {:ok, view, _html} = live(conn, "/") + + # Update fails because of overlap timer ----------- + render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)}) + + four_seconds_ago_string = + NaiveDateTime.truncate(four_seconds_ago, :second) + |> NaiveDateTime.to_string() + |> String.graphemes() + |> Enum.with_index() + |> Enum.map(fn {value, index} -> + if index == 10 do + "T" + else + value + end + end) + |> List.to_string() + + now_string = + NaiveDateTime.truncate(now, :second) + |> NaiveDateTime.to_string() + |> String.graphemes() + |> Enum.with_index() + |> Enum.map(fn {value, index} -> + if index == 10 do + "T" + else + value + end + end) + |> List.to_string() + + error_view = + render_submit(view, "update-item-timer", %{ + "timer_id" => timer2.id, + "index" => 1, + "timer_start" => four_seconds_ago_string, + "timer_stop" => "" + }) + + assert error_view =~ "When editing an ongoing timer" + + # Update fails because of format ----------- + render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)}) + + error_format_view = + render_submit(view, "update-item-timer", %{ + "timer_id" => timer2.id, + "index" => 1, + "timer_start" => "invalidformat", + "timer_stop" => "" + }) + + assert error_format_view =~ "Start field has an invalid date format." + + # Update successful ----------- + ten_seconds_after_string = + NaiveDateTime.truncate(ten_seconds_after, :second) + |> NaiveDateTime.to_string() + |> String.graphemes() + |> Enum.with_index() + |> Enum.map(fn {value, index} -> + if index == 10 do + "T" + else + value + end + end) + |> List.to_string() + + ten_seconds_after_datetime = + NaiveDateTime.truncate(ten_seconds_after, :second) + + render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)}) + + view = + assert render_submit(view, "update-item-timer", %{ + "timer_id" => timer2.id, + "index" => 1, + "timer_start" => ten_seconds_after_string, + "timer_stop" => "" + }) + + updated_timer2 = Timer.get_timer!(timer2.id) + + assert updated_timer2.start == ten_seconds_after_datetime + end + + test "timer overlap error when updating timer", %{conn: conn} do + {:ok, item} = + Item.create_item(%{text: "Learn Elixir", person_id: 0, status: 2}) + + {:ok, seven_seconds_ago} = + NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), -7)) + + {:ok, now} = NaiveDateTime.new(Date.utc_today(), Time.utc_now()) + + {:ok, four_seconds_ago} = + NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), -4)) + + # Start the timer 7 seconds ago: + {:ok, timer} = + Timer.start(%{item_id: item.id, person_id: 1, start: seven_seconds_ago}) + + # Stop the timer based on its item_id + Timer.stop_timer_for_item_id(item.id) + + # Start a second timer + {:ok, timer2} = Timer.start(%{item_id: item.id, person_id: 1, start: now}) + + # Stop the timer based on its item_id + Timer.stop_timer_for_item_id(item.id) + + {:ok, view, _html} = live(conn, "/") + + # Update fails because of overlap ----------- + render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)}) + + four_seconds_ago_string = + NaiveDateTime.truncate(four_seconds_ago, :second) + |> NaiveDateTime.to_string() + |> String.graphemes() + |> Enum.with_index() + |> Enum.map(fn {value, index} -> + if index == 10 do + "T" + else + value + end + end) + |> List.to_string() + + now_string = + NaiveDateTime.truncate(now, :second) + |> NaiveDateTime.to_string() + |> String.graphemes() + |> Enum.with_index() + |> Enum.map(fn {value, index} -> + if index == 10 do + "T" + else + value + end + end) + |> List.to_string() + + assert render_submit(view, "update-item-timer", %{ + "timer_id" => timer2.id, + "index" => 0, + "timer_start" => four_seconds_ago_string, + "timer_stop" => now_string + }) =~ "This timer interval overlaps with other timers." + end +``` + +Let us not forget we also changed the way +the `update` event is broadcasted. +It now updates the socket assigns depending +on whether an item is being edited or not. +Let's add tests in the same file to cover these scenarios + +```elixir +test "handle_info/2 update with editing open (start)", %{conn: conn} do + {:ok, view, _html} = live(conn, "/") + + {:ok, item} = + Item.create_item(%{text: "Always Learning", person_id: 0, status: 2}) + + {:ok, now} = NaiveDateTime.new(Date.utc_today(), Time.utc_now()) + + now_string = + NaiveDateTime.truncate(now, :second) + |> NaiveDateTime.to_string() + |> String.graphemes() + |> Enum.with_index() + |> Enum.map(fn {value, index} -> + if index == 10 do + "T" + else + value + end + end) + |> List.to_string() + + render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)}) + render_click(view, "start", %{"id" => Integer.to_string(item.id)}) + + # The editing panel is open and showing the newly created timer on the 'Start' text input field + assert render(view) =~ now_string + end + + test "handle_info/2 update with editing open (stop)", %{conn: conn} do + {:ok, view, _html} = live(conn, "/") + + {:ok, item} = + Item.create_item(%{text: "Always Learning", person_id: 0, status: 2}) + + {:ok, seven_seconds_ago} = + NaiveDateTime.new(Date.utc_today(), Time.add(Time.utc_now(), -7)) + + # Start the timer 7 seconds ago: + {:ok, timer} = + Timer.start(%{item_id: item.id, person_id: 1, start: seven_seconds_ago}) + + # Stop the timer based on its item_id + Timer.stop_timer_for_item_id(item.id) + + render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)}) + render_click(view, "start", %{"id" => Integer.to_string(item.id)}) + render_click(view, "stop", %{"timerid" => timer.id, "id" => item.id}) + + num_timers_rendered = + (render(view) |> String.split("Update") |> length()) - 1 + + # Checking if two timers were rendered + assert num_timers_rendered = 2 + end + + test "handle_info/2 update with editing open (delete)", %{conn: conn} do + {:ok, view, _html} = live(conn, "/") + + {:ok, item} = + Item.create_item(%{text: "Always Learning", person_id: 0, status: 2}) + + render_click(view, "edit-item", %{"id" => Integer.to_string(item.id)}) + + send(view.pid, %Broadcast{ + event: "update", + payload: :delete + }) + + assert render(view) =~ item.text + end +``` # 13. Run the _Finished_ MVP App!