Skip to content

Commit

Permalink
Add the .contains macro
Browse files Browse the repository at this point in the history
What?
=====

This introduces a new macro, `.contains`, which delegates all of that
page object's methods at the level `contains` is called.

So, rather than having to declare a composed has_one and delegate all
methods to it by hand, this allows for a one-line automatic delegation.
  • Loading branch information
joshuaclayton committed Aug 2, 2023
1 parent 43ee379 commit 86cd375
Show file tree
Hide file tree
Showing 3 changed files with 331 additions and 0 deletions.
66 changes: 66 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,72 @@ The methods defined by PageEz can be passed additional options from Capybara. Re
* [`Capybara::Node::Finders#find`]
* [`Capybara::Node::Matchers#has_css?`]

### `contains`

This provides a shorthand for delegating methods from one page object to
another, flattening the hierarchy of page objects and making it easier to
interact with application-level componentry.

```rb
class SidebarModal < PageEz::Page
base_selector "div[data-role=sidebar]"

has_one :sidebar_heading, "h2"
has_one :sidebar_contents, "section[data-role=contents]"
end

class PeopleIndex < PageEz::Page
contains SidebarModal

has_many :people_rows, "ul[data-role=people-list] li" do
has_one :name, "span[data-role=person-name]"
has_one :edit_link, "a", text: "Edit"
end

def change_person_name(from:, to:)
people_row_matching(text: from).edit_link.click

within sidebar_contents do
fill_in "Name", with: to
click_on "Save Person"
end
end
end
```

By default, this delegates all methods to an instance of the page object. If
you prefer to delegate a subset of the methods, you can do so with the `only`
option:

```rb
class SidebarModal < PageEz::Page
base_selector "div[data-role=sidebar]"

has_one :sidebar_heading, "h2"
has_one :sidebar_contents, "section[data-role=contents]"
end

class PeopleIndex < PageEz::Page
contains SidebarModal, only: %i[sidebar_contents]
end
```

The equivalent functionality could be achieved with:

```rb
class SidebarModal < PageEz::Page
base_selector "div[data-role=sidebar]"

has_one :sidebar_heading, "h2"
has_one :sidebar_contents, "section[data-role=contents]"
end

class PeopleIndex < PageEz::Page
has_one :sidebar_modal, SidebarModal
delegate :sidebar_contents, to: :sidebar_modal
end
```

### Using Methods as Dynamic Selectors

In the examples above, the CSS selectors are static.
Expand Down
15 changes: 15 additions & 0 deletions lib/page_ez/page.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,21 @@ def self.base_selector(value)
self.container_base_selector = value
end

def self.contains(page_object, only: nil)
delegation_target = :"__page_object_#{page_object.object_id}__"

has_one(delegation_target, page_object)

if only
methods_delegated_that_do_not_exist = only - page_object.instance_methods(false)
if methods_delegated_that_do_not_exist.any?
raise NoMethodError, "Attempting to delegate non-existent method(s) to #{page_object}: #{methods_delegated_that_do_not_exist.join(", ")}"
end
end

delegate(*(only || page_object.instance_methods(false)), to: delegation_target)
end

def initialize(container = nil)
@container = container || Class.new do
include Capybara::DSL
Expand Down
250 changes: 250 additions & 0 deletions spec/features/contains_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
require "spec_helper"

RSpec.describe "contains" do
it "pulls in everything from the corresponding PageEz::Page" do
page = build_page(<<-HTML)
<heading data-role="primary">
<h1>Application Title</h1>
</heading>
<main>
<h2>Awesome Site</h2>
</main>
<footer data-role="primary">
<p data-role=copyright>Copyright 2023 Company Name</p>
</footer>
HTML

heading = Class.new(PageEz::Page) do
base_selector "heading[data-role=primary]"

has_one :application_title, "h1"
end

footer = Class.new(PageEz::Page) do
base_selector "footer[data-role=primary]"

has_one :copyright, "p[data-role=copyright]"

def awesome?
true
end
end

test_page = Class.new(PageEz::Page) do
contains heading
contains footer

has_one :main do
has_one :heading, "h2"
end
end.new(page)

page.visit "/"

expect(test_page.application_title).to have_text("Application Title")
expect(test_page).to have_application_title(text: "Application Title")

expect(test_page.main).to have_heading(text: "Awesome Site")
expect(test_page).to have_copyright(text: "Copyright 2023 Company Name")
expect(test_page).to be_awesome
end

it "allows multiple page objects to use contains with the same page object" do
expect do
heading = Class.new(PageEz::Page) do
has_one :header
end

Class.new(PageEz::Page) do
contains heading
end

Class.new(PageEz::Page) do
contains heading
end
end.not_to raise_error
end

it "allows for delegating only a subset of methods" do
page = build_page(<<-HTML)
<heading data-role="primary">
<h1>Application Title</h1>
</heading>
<main>
<h2>Awesome Site</h2>
</main>
<footer data-role="primary">
<p data-role=copyright>Copyright 2023 Company Name</p>
</footer>
HTML

footer = Class.new(PageEz::Page) do
base_selector "footer[data-role=primary]"

has_one :copyright, "p[data-role=copyright]"

def awesome?
true
end
end

test_page = Class.new(PageEz::Page) do
contains footer, only: %i[has_copyright?]

def awesome?
false
end
end.new(page)

page.visit "/"

expect(test_page).to have_copyright(text: "Copyright 2023 Company Name")
expect(test_page).not_to be_awesome
end

it "applies base selectors to contained pages" do
page = build_page(<<-HTML)
<ul data-role="complete-todos">
<li data-role="todo">
<span data-role="name">Buy milk</span>
<button data-role="toggle-complete">Mark Incomplete</button>
</li>
</ul>
<ul data-role="incomplete-todos">
<li data-role="todo">
<span data-role="name">Buy eggs</span>
<button data-role="toggle-complete">Mark Complete</button>
</li>
</ul>
HTML

todo_list = Class.new(PageEz::Page) do
has_many_ordered :items, "li[data-role=todo]" do
has_one :name, "span[data-role=name]"
has_one :toggle_complete_button, "[data-role=toggle-complete]"
end
end

complete_todos = Class.new(PageEz::Page) do
base_selector "ul[data-role=complete-todos]"

contains todo_list
end.new(page)

incomplete_todos = Class.new(PageEz::Page) do
base_selector "ul[data-role=incomplete-todos]"

contains todo_list
end.new(page)

page.visit "/"

expect(complete_todos.item_at(0)).to have_name(text: "Buy milk")
expect(complete_todos.item_at(0)).to have_toggle_complete_button(text: "Mark Incomplete")

expect(incomplete_todos.item_at(0)).to have_name(text: "Buy eggs")
expect(incomplete_todos.item_at(0)).to have_toggle_complete_button(text: "Mark Complete")
end

it "raises when macros collide" do
nav = Class.new(PageEz::Page) do
has_many :links, "a"
end

expect do
Class.new(PageEz::Page) do
contains nav

has_many :links, "li a"
end
end.to raise_error(PageEz::DuplicateElementDeclarationError)

expect do
Class.new(PageEz::Page) do
has_many :links, "li a"

contains nav
end
end.to raise_error(PageEz::DuplicateElementDeclarationError)
end

it "raises when instance methods collide" do
nav = Class.new(PageEz::Page) do
def awesome?
true
end
end

expect do
Class.new(PageEz::Page) do
contains nav

def awesome?
false
end
end
end.to raise_error(PageEz::DuplicateElementDeclarationError)

expect do
Class.new(PageEz::Page) do
def awesome?
false
end

contains nav
end
end.to raise_error(PageEz::DuplicateElementDeclarationError)
end

it "raises when instance methods collide with element names" do
nav = Class.new(PageEz::Page) do
has_many :links, "a"
end

expect do
Class.new(PageEz::Page) do
contains nav

def links
"whoops"
end
end
end.to raise_error(PageEz::DuplicateElementDeclarationError)

expect do
Class.new(PageEz::Page) do
def links
"whoops"
end

contains nav
end
end.to raise_error(PageEz::DuplicateElementDeclarationError)
end

it "does not raise when instance methods collide with element names in nested pages" do
nav = Class.new(PageEz::Page) do
has_many :links, "a"
end

expect do
Class.new(PageEz::Page) do
contains nav

has_one :header do
has_many :links, "a"
end
end
end.not_to raise_error
end

it "raises NoMethodError when attempting to contains->delegate a method that does not exist" do
footer = Class.new(PageEz::Page)

expect do
Class.new(PageEz::Page) do
contains footer, only: %i[bogus]
end
end.to raise_error(NoMethodError, /Attempting to delegate.*to #{footer}: bogus/)
end
end

0 comments on commit 86cd375

Please sign in to comment.