From a04cbab6c2d4a6f9fffe7798d0a27f9e60089fc6 Mon Sep 17 00:00:00 2001 From: Sergio Arbeo Date: Mon, 16 Oct 2023 00:20:58 +0200 Subject: [PATCH] Add cheatsheets (#7) --- guides/cheatsheets/accessors.cheatmd | 146 ++++++++++++++++++++++++++ guides/cheatsheets/assertions.cheatmd | 71 +++++++++++++ guides/cheatsheets/selectors.cheatmd | 146 ++++++++++++++++++++++++++ lib/dom_helpers/accessors.ex | 142 ++++++++++++++++++++++++- lib/dom_helpers/assertions.ex | 4 +- mix.exs | 8 +- 6 files changed, 510 insertions(+), 7 deletions(-) create mode 100644 guides/cheatsheets/accessors.cheatmd create mode 100644 guides/cheatsheets/assertions.cheatmd create mode 100644 guides/cheatsheets/selectors.cheatmd diff --git a/guides/cheatsheets/accessors.cheatmd b/guides/cheatsheets/accessors.cheatmd new file mode 100644 index 0000000..065b9b5 --- /dev/null +++ b/guides/cheatsheets/accessors.cheatmd @@ -0,0 +1,146 @@ +# Accessors Cheatsheet + +Accessors let you access certains parts of the element you have selected previously. +As the given htmlable can contain multiple nodes, these functions return lists. + +All function that can be filtered using a selector, they are done by using a selector +as second argument. In that case, there is a version of the same function ended with +`_in` where the first and second arguments are switched. These are just convenience +function in case you want to pipe when building a complex selector. + +## Selecting nodes +{: .col-2} + +#### All matching nodes + +```elixir +find(view, "p") +find_in("p", view) +# Returns all paragraphs. +``` + +#### First matching node + +```elixir +find_first(view, "div.contents") +find_first_in("div.contents", view) +# Returns the first div with contents class +``` + +#### Count the matching nodes + +Just a convinience function. + +```elixir +find_count(view, "p") +find_count_in("p", view) +# Counts the number of paragraphs + +find_count(view, "nav ul li") +find_count_in("nav ul li", view) +# Counts the number of elements in the navigation list +``` + +## Selecting attributes +{: .col-2} + +### Classes + +Classes are quite common in HTML. Specially useful when checking for classes like +`hidden` in Tailwind, that checks if an element should be hidden or not. Class +attributes will be split in words before being returned. + +#### One element + +Look out for the nested list! + +```elixir +classes(~s[
Content
]) +# [["one", "two", "three"]] +``` + +#### Several elements + +```elixir +classes(~s[
Content
Other
]) +# [["one", "two", "three"], ["four", "five"]] +``` + +#### Using selectors +`classes/2` let's you pass a selector as second argument. This is convenient so you don't +to pipe `find` just for this. + +```elixir +classes(~s[
Content
Other
], ".one") +classes_in(".one", ~s[
Content
Other
]) +# [["one", "two", "three"]] +classes(~s[
Content
Other
], "div") +classes_in("div", ~s[
Content
Other
]) +# [["one", "two", "three"], ["four", "five"]] +``` + +### Other attributes + +Other attributes can easily be accessed too. Unlike classes, these values are returned +as strings, unparsed in any way. + +#### One element + +```elixir +attribute(~s[
  • 1
  • ], "data-index") +# ["0"] +``` + +#### Several elements + +```elixir +attribute(~s[
  • 1
  • 2
  • ], "data-index") +# ["0", "1"] +``` + +#### Using selectors + +`attribute/3` let's you pass a selector as second argument. This is convenient so you don't +to pipe `find` just for this. + + +```elixir +attribute(~s[
    Content
    Other
    ], ".one", "data-index") +attribute_in(".one", ~s[
    Content
    Other
    ], "data-index") +# ["0"] +attribute_in("div", ~s[
    Content
    Other
    ], "class") +# ["one two three", "four five"] +``` + +### Text + +#### Several elements, combined + +Text would return just one string for all elements by default. + +```elixir +text(~s(), ".odd") +text_in(".odd", ~s()) +# "First Third" + +text(~s(), ".even") +text_in(".even", ~s()) +# "Second" + +text(~s(), ".none") +text_in(".none", ~s()) +# "" + +text(~s(), "li") +text_in("li", ~s()) +# "First Second Third" +``` + +#### Several elements, list + +Combine `Enum.map/2` with `Nobs.Accessors.text/3` to create a list. + +```elixir +find(~s(), "li") |> Enum.map(&text/1) +# ~w(First Second Third) +``` \ No newline at end of file diff --git a/guides/cheatsheets/assertions.cheatmd b/guides/cheatsheets/assertions.cheatmd new file mode 100644 index 0000000..08fbdb2 --- /dev/null +++ b/guides/cheatsheets/assertions.cheatmd @@ -0,0 +1,71 @@ +# Assertions Cheatsheet + +These ways of asserting in tests give you some advantages over the "standard" way: + +- Better errors. Most of these recipes rely on ExDoc to provide better errors when a + test fails (except for [`is_in?/2`](`DomHelpers.Assertions.is_in?/2`) which is just a + convenience function). +- Forces you to be more specific about what you are testing. The usual way of using + `render(view) =~ expected_content` is prone to false green and, when failing, makes + it harder to know the intent of the test. + +## Elements existence +{: .col-2} + +#### [`is_in?/2`](`DomHelpers.Assertions.is_in?/2`) + +Just a convenience function that reverses the arguments of `Phoenix.LiveViewTest.has_element?/2`. + +```elixir +assert "input" + |> with_attrs(type: "checkbox", name: {:ends_with, "[type]"}) + |> is_in?(view) +``` + +#### Check the number of elements matching a selector + +Other comparisons are possible, like greater than or less than. + +```elixir +assert find_count(view, "[data-test=accordion]") == 2 +``` + +## Elements attributes +{: .col-2} + +#### Checking it has a class + +```elixir +assert selector + |> with_class(expected_class) + |> is_in?(view) +assert expected_class in (find_first(view, selector) |> classes() |> List.first()) +``` + +#### Checking it has certain attribute + +```elixir +assert selector + |> with_attr("aria-expanded", "true") + |> is_in?(view) +assert [["true"]] == + attribute(view, selector, "aria-expanded") +``` + +#### Checking the text of an element + +When fails, you get a nice diff between both text. Also, `=~` is prone to false greens. + +```elixir +assert text(view, "[data-test=my-element]") == "My content" +``` + +#### Multiple checks at once + +No better errors here, except for text. + +```elixir +selector = "button" |> with_class(".destroy") |> with_attrs(disabled: true, type: "submit") + +assert text(view, selector) == "Submit form" +``` diff --git a/guides/cheatsheets/selectors.cheatmd b/guides/cheatsheets/selectors.cheatmd new file mode 100644 index 0000000..0dc19b8 --- /dev/null +++ b/guides/cheatsheets/selectors.cheatmd @@ -0,0 +1,146 @@ +# Selectors Cheatsheet + +## Building selectors +{: .col-2} + +### Adding class / classes to a selector + +#### Just one class + +```elixir +with_class("span", "hidden") +``` + +#### Several classes + +```elixir +with_classes("span", ~w(contents grid)) +``` + +### Checking no class in a selector + +#### Just one class + +```elixir +without_class("span", "hidden") +``` + +#### Several classes + +```elixir +without_classes("span", ~w(contents grid)) +``` + +## Id +{: .col-1} + +### With id + +```elixir +with_id("input", "form_field") +``` + +## Attributes +{: .col-2} + +### Adding attributes to a selector + +#### One attribute + +```elixir +with_attr("button", "disabled") +with_attr("input", "type", "checkbox") +``` + +#### Several attributes + +```elixir +with_attrs("input", type: "checkbox", name: "form[field]") +``` + +#### Checking an element does not have a selector. + +```elixir +without_attr("button", "disabled") +without_attr("button", "phx-click", "remove-item") +``` + +### Multiple selector options + +#### Checking just the existence of some attributes + +If the value given to one attribute is just `true`, it'll just check for the existence without checking for the value. + +```elixir +with_attrs("button", type: "button", disabled: true) +``` + +#### Checking that an attribute is not there + +We can also check that an attribute is not there with `with_attrs`, just give `false` as value. + +```elixir +with_attrs("button", type: "button", disabled: false) +``` + +### Use different matchers in attributes. + +`dom_helpers` support _"modifiers"_ in the form of `{modifier, value}` that +can be used to change the matcher used in the selectors. [Documentation for these +selectors can be found in MDN](https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors#syntax) + +#### Exact match + +Without a _"modifier"_, it just check for the exact value. There is also the `:equal` +modifier. + +```elixir +with_attr("input", "type", "checkbox") +with_attr("input", "type", {:equal, "checkbox"}) +with_attrs("input", type: "checkbox") +with_attrs("input", type: {:equal, "checkbox"}) +``` + +#### Contains + +Checks if the value given to the selector is contained within the actual value +in the element. + +``` +with_attr("input", "name", {:contains, "[certain_sub_field]"}) +with_attrs("input", name: {:contains, "[certain_sub_field]"}) +``` + +#### Contains word + +With the `:contains_word` modifier, the `~=` matcher is used. This matcher +considers the value a list of whitespace-separated words and just checks that +one of the words is the given value to the selector. + +```elixir +with_attr("span", "class", {:contains_word, "hidden"}) +with_attrs("span", class: {:contains_word, "hidden"}) + +# Yeah, I know it would be best to use `with_class` for those examples. +``` + +#### Starts with / Ends with modifiers. + +There are also a `:starts_with` modifier for the `^=` matcher and `:ends_with` modifier for +the `$=` matcher. + +```elixir +with_attr("input", "name", {:starts_with, "form[field]"}) +with_attrs("input", name: {:starts_with, "form[field]"}) +with_attr("input", "name", {:ends_with, "[subfield][sub_subfield]"}) +with_attrs("input", name: {:ends_with, "[subfield][sub_subfield]"}) +``` + +#### Subcode + +See MDN documentation for this one, as it is a bit tricky. + +```elixir +with_attr("html", "lang", {:subcode, "es"}) +with_attrs("html", lang: {:subcode, "es"}) +``` \ No newline at end of file diff --git a/lib/dom_helpers/accessors.ex b/lib/dom_helpers/accessors.ex index 651394e..8772465 100644 --- a/lib/dom_helpers/accessors.ex +++ b/lib/dom_helpers/accessors.ex @@ -41,6 +41,26 @@ defmodule DomHelpers.Accessors do def attribute(htmlable, selector, attr_name), do: htmlable |> parse!() |> Floki.attribute(selector, attr_name) + @doc """ + Convenience method for piping when building a selector. Behaves like `attribute/3` but + first argument is the selector and the second one is the htmlable. + + ## Examples + + ``` + iex> attribute_in("li", ~s(), "class") + ~w(odd even odd) + + iex> attribute_in("li", ~s(), "data-test") + ~w(first second third) + + iex> attribute_in(".even", ~s(), "data-test") + ~w(second) + ``` + """ + def attribute_in(selector, htmlable, attr_name), + do: attribute(htmlable, selector, attr_name) + @doc """ Return a list of the classes of the current fragment. @@ -48,14 +68,17 @@ defmodule DomHelpers.Accessors do ``` iex> classes(~s(
    Hello
    )) - ~w(some classes here) + [~w(some classes here)] iex> classes(~s(
    Hello
    )) - ~w(some classes here) + [~w(some classes here)] + + iex> classes(~s(
  • 1
  • 2
  • )) + [~w(odd first), ~w(even second)] ``` """ def classes(htmlable), - do: htmlable |> attribute("class") |> List.first("") |> String.split(" ", trim: true) + do: htmlable |> attribute("class") |> Enum.map(&String.split(&1, " ", trim: true)) @doc """ Return a list with the list of classes of all the elements that satisfy the selector @@ -69,10 +92,32 @@ defmodule DomHelpers.Accessors do iex> classes(~s(), "li") [["odd"], ["even"], ["odd"]] + + iex> classes(~s[
    Content
    Other
    ], "div") + [["one", "two", "three"], ["four", "five"]] ``` """ def classes(htmlable, selector), - do: htmlable |> find(selector) |> Enum.map(&classes/1) + do: htmlable |> find(selector) |> Enum.flat_map(&classes/1) + + @doc """ + Convenience method for piping when building a selector. Behaves like `classes/2` but + first argument is the selector. + + ## Example + + ``` + iex> classes_in(".odd", ~s()) + [["odd"], ["odd"]] + + iex> classes_in("li", ~s()) + [["odd"], ["even"], ["odd"]] + + iex> classes_in("div", ~s[
    Content
    Other
    ]) + [["one", "two", "three"], ["four", "five"]] + ``` + """ + def classes_in(selector, htmlable), do: classes(htmlable, selector) @doc """ Finds all the nodes in the htmlable that satisfy the selector. @@ -95,6 +140,28 @@ defmodule DomHelpers.Accessors do """ def find(htmlable, selector), do: htmlable |> parse!() |> Floki.find(selector) + @doc """ + Convenience method for piping when building a selector. Behaves like `find/2` but + first argument is the selector. + + ## Examples + + ``` + iex> find_in(".odd", ~s()) + [{"li", [{"class", "odd"}], ["First"]}, {"li", [{"class", "odd"}], ["Third"]}] + + iex> find_in(".even", ~s()) + [{"li", [{"class", "even"}], ["Second"]}] + + iex> find(~s(), ".none") + [] + + iex> find_in("li", ~s()) + [{"li", [{"class", "odd"}], ["First"]}, {"li", [{"class", "even"}], ["Second"]}, {"li", [{"class", "odd"}], ["Third"]}] + ``` + """ + def find_in(selector, htmlable), do: find(htmlable, selector) + @doc """ Returns the number of elements that satisfy the given selector. @@ -116,6 +183,28 @@ defmodule DomHelpers.Accessors do """ def find_count(htmlable, selector), do: htmlable |> find(selector) |> length() + @doc """ + Convenience method for piping when building a selector. Behaves like `find_count/2` but + first argument is the selector. + + ## Examples + + ``` + iex> find_count_in(".odd", ~s()) + 2 + + iex> find_count_in(".even", ~s()) + 1 + + iex> find_count_in(".none", ~s()) + 0 + + iex> find_count_in("li", ~s()) + 3 + ``` + """ + def find_count_in(selector, htmlable), do: find_count(htmlable, selector) + @doc """ Like `find/2` but gets the first instance. @@ -137,6 +226,28 @@ defmodule DomHelpers.Accessors do """ def find_first(htmlable, selector), do: htmlable |> find(selector) |> List.first() + @doc """ + Convenience method for piping when building a selector. Behaves like `find_first/2` but + first argument is the selector. + + ## Examples + + ``` + iex> find_first_in(".odd", ~s()) + {"li", [{"class", "odd"}], ["First"]} + + iex> find_first_in(".even", ~s()) + {"li", [{"class", "even"}], ["Second"]} + + iex> find_first_in(".none", ~s()) + nil + + iex> find_first_in("li", ~s()) + {"li", [{"class", "odd"}], ["First"]} + ``` + """ + def find_first_in(selector, htmlable), do: find_first(htmlable, selector) + @doc """ Returns the whole text inside the html fragment passed in. Spaces are normalised (meaning that if there are multiple spaces together they are reduced to just one and the text is trimmed on both ends). @@ -179,6 +290,29 @@ defmodule DomHelpers.Accessors do |> String.replace(~r/\s+/, " ") |> String.trim() + @doc """ + Convenience method for piping when building a selector. Behaves like `text/3` but + first argument is the selector. Selector is not optional in this function. + + ## Examples + + ``` + iex> text_in(".odd", ~s()) + "First Third" + + iex> text_in(".even", ~s()) + "Second" + + iex> text_in(".none", ~s()) + "" + + iex> text_in("li", ~s()) + "First Second Third" + ``` + """ + def text_in(selector, htmlable, options \\ []), + do: text(htmlable, selector, options) + # If it is already parsed. defp parse!(tree) when is_list(tree) or is_tuple(tree), do: tree diff --git a/lib/dom_helpers/assertions.ex b/lib/dom_helpers/assertions.ex index 09e0d72..ee1bf0d 100644 --- a/lib/dom_helpers/assertions.ex +++ b/lib/dom_helpers/assertions.ex @@ -5,7 +5,7 @@ defmodule DomHelpers.Assertions do [selectors](`DomHelpers.Selectors`) and [accessors](`DomHelpers.Accessors`). """ - import DomHelpers.Accessors + alias DomHelpers.Accessors @doc """ Checks if the given selector is found at least once @@ -27,6 +27,6 @@ defmodule DomHelpers.Assertions do ``` """ def is_in?(selector, htmlable) do - find_count(htmlable, selector) > 0 + Accessors.find_count(htmlable, selector) > 0 end end diff --git a/mix.exs b/mix.exs index 84c68c8..fa660a9 100644 --- a/mix.exs +++ b/mix.exs @@ -49,7 +49,13 @@ defmodule DomHelpers.MixProject do defp docs do [ - extras: [{:"README.md", [title: "Overview"]}], + extras: [ + {:"README.md", [title: "Overview"]}, + {:"guides/cheatsheets/accessors.cheatmd", [title: "Accessors"]}, + {:"guides/cheatsheets/assertions.cheatmd", [title: "Assertions"]}, + {:"guides/cheatsheets/selectors.cheatmd", [title: "Selectors"]} + ], + groups_for_extras: [Cheatsheets: ~r/cheatsheets\/.?/], main: "readme", source_url: @source_url, source_ref: "v#{@version}"