Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Portal API #22

Merged
merged 8 commits into from
Jan 22, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,31 @@ PATH
remote: .
specs:
tabasco (0.1.0)
activesupport (>= 3.2)
capybara (~> 3.0)

GEM
remote: https://rubygems.org/
specs:
activesupport (8.0.1)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
ast (2.4.2)
base64 (0.2.0)
benchmark (0.4.0)
bigdecimal (3.1.9)
capybara (3.40.0)
addressable
matrix
Expand All @@ -20,13 +37,20 @@ GEM
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
coderay (1.1.3)
concurrent-ruby (1.3.4)
connection_pool (2.5.0)
diff-lcs (1.5.1)
drb (2.2.1)
i18n (1.14.6)
concurrent-ruby (~> 1.0)
json (2.9.0)
language_server-protocol (3.17.0.3)
logger (1.6.5)
matrix (0.4.2)
method_source (1.1.0)
mini_mime (1.1.5)
mini_portile2 (2.8.8)
minitest (5.25.4)
nokogiri (1.17.2)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
Expand Down Expand Up @@ -80,9 +104,13 @@ GEM
rubocop-rspec (3.3.0)
rubocop (~> 1.61)
ruby-progressbar (1.13.0)
securerandom (0.4.1)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.1.2)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uri (1.0.2)
xpath (3.2.0)
nokogiri (~> 1.8)

Expand All @@ -91,7 +119,6 @@ PLATFORMS
ruby

DEPENDENCIES
tabasco!
pry
rack
rake (~> 13.0)
Expand All @@ -100,6 +127,7 @@ DEPENDENCIES
rubocop-capybara
rubocop-performance
rubocop-rspec
tabasco!

BUNDLED WITH
2.5.18
13 changes: 13 additions & 0 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,27 @@ Additional notes:

DEPENDENCIES

activesupport: MIT
addressable: Apache-2.0
ast: MIT
base64: Ruby
benchmark: Ruby
bigdecimal: Ruby
bundler: MIT
capybara: MIT
coderay: MIT
concurrent-ruby: MIT
connection_pool: MIT
diff-lcs: MIT
drb: Ruby
i18n: MIT
json: Ruby
language_server-protocol: MIT
logger: Ruby
matrix: Ruby
method_source: MIT
mini_mime: MIT
minitest: MIT
nokogiri: MIT
parallel: MIT
parser: MIT
Expand All @@ -42,7 +52,10 @@ rubocop-performance: MIT
rubocop-rspec: MIT
rubocop: MIT
ruby-progressbar: MIT
securerandom: Ruby
tabasco: MIT
tzinfo: MIT
unicode-display_width: MIT
unicode-emoji: MIT
uri: Ruby
xpath: MIT
145 changes: 145 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,151 @@ demo_page.tenant_id # ok
demo_page.main_content.tenant_id # NoMethodError
```

### Portal sections

Portals in Tabasco are designed to handle elements that are not nested within the parent section container’s DOM hierarchy. Common use cases include modal dialogs or other floating ui elements (popovers, tooltips, ...). This allows Tabasco to seamlessly interact with such elements while maintaining a structured and predictable test framework.

Portals bypass the natural scoping of sections, targeting elements that are often inserted at the root of the page DOM. This guide will walk you through configuring portals, using them effectively, and extending their behavior.

#### Defining a Portal

Consider the following html fragment:

```html
<div data-testid="toast-portal-container">This is a toast message!</div>

<div data-testid="my_form">...</div>
```

To interact with the toast message, we first define a portal in Tabasco:

```rb
Tabasco.configure do |config|
# test_id is only necessary if it does not match the portal name
config.portal(:toast_message, test_id: :toast_portal_container)
end
```

Here, the :toast_message portal is linked to the data-testid="toast-portal-container" element.

#### Using Portals in Sections

You can use the defined portal inside a section. For example:

```rb
class MyForm < Tabasco::Section
# ...

portal :my_portal
end
```

Even though the portal’s container is not a child of the form, it will behave as a subsection of MyForm:

```rb
my_form_section = MyForm.load
expect(my_form_section.toast_message).to have_content("This is a toast message!")

# ⚠ Caveat: This won't work, as the DOM element is not part of the form's container!
expect(my_form_section).to have_content("This is a toast message!")
Oyster-Moura marked this conversation as resolved.
Show resolved Hide resolved
```

Note: Interact directly with the portal, as its content is not copied or moved to the parent container.

#### Extending Portal Behavior

Portals are similar to sections and can be extended using a block:

```rb
class MyForm < Tabasco::Section
# ...

portal :toast_message do
def dismiss
click_button "Dismiss"
end
end
end

my_form_section.toast_message.dismiss
```

#### Managing Multiple Portals

You can define multiple portals in your global configuration, but use this feature sparingly to maintain structure and ensure readability:

```rb
Tabasco.configure do |config|
config.portal(:toast_message, test_id: :toast_portal_container)
config.portal(:datepicker, test_id: :react_datepicker)
end
```

⚠ Warning: portals can bypass Tabasco’s natural scoping, reducing the guardrails that prevent test brittleness. Use them judiciously.

#### Using concrete classes on Portals

Portals can reuse behavior through concrete classes, just like sections. Imagine you have a general-purpose modal dialog with a close button. Define its behavior in a class:

```rb
class ModalDialog < Tabasco::Section
# ...
def dismiss
click_button "Close"
end
end
```

You can tie this class to all instances of a portal globally:

```rb
Tabasco.configure do |config|
config.portal(:modal_dialog, ModalDialog, test_id: :modal_container)
end
```

Alternatively, specify a class on a case-by-case basis:

```rb
class MyForm < Tabasco::Section
# ...
portal :modal_dialog, ModalDialog
end
```

#### Inline Block Specialization

You can further specialize individual portal instances by using an inline block:

```rb
class MyForm < Tabasco::Section
portal :modal_dialog, ModalDialog do
def confirm
click_button "Confirm"
end
end
end

# Only this instance of `modal_dialog` has the `confirm` method
my_form_section.modal_dialog.confirm
```

Note: If you provide a concrete class globally and in a section, the section-specific class must inherit from the global one.

#### Caveat: portals do not move or copy DOM elements around

The following won't work:

```rb
expect(my_section).to have_content("Portal content")
```

Instead, you must interact directly with the portal element:

```rb
expect(my_section.portal).to have_content("Portal content")
```

### Organizing your directory structure

Ideally, we want every page object to be co-located with their matching spec files. Page tests are placed in `spec/pages` (not `spec/system/pages`), and we encourage you to organize the directory structure following the navigational structure of your app. The goal is to make it intuitive to find the tests for a page by mirroring the navigation structure of your application.
Expand Down
17 changes: 17 additions & 0 deletions lib/tabasco.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# frozen_string_literal: true

require "active_support/core_ext/class/attribute"
require "capybara/dsl"
require "capybara/rspec/matchers"

Expand All @@ -8,6 +9,22 @@
module Tabasco
class Error < StandardError; end
class PreconditionNotMetError < Error; end
class InconsistentPortalKlassError < Error; end
class PortalNotConfiguredError < Error; end
class PortalAlreadyConfiguredError < Error; end

def self.configure
yield configuration.dsl
end

def self.configuration
@configuration ||= Configuration.new
end

def self.reset_configuration!
@configuration = nil
end
end

require_relative "tabasco/configuration"
require_relative "tabasco/page"
67 changes: 67 additions & 0 deletions lib/tabasco/configuration.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# frozen_string_literal: true

module Tabasco
class Configuration
def initialize
@portals = {}
end

def dsl
@dsl ||= DSL.new(self)
end

def portal(name)
name = name&.to_sym

return @portals[name] if @portals.key?(name)

message = <<~ERR
Portal #{name.inspect} is not configured. Use Tabasco.configure to declare the acceptable
portals. Refer to the README document for more information.
ERR

raise PortalNotConfiguredError, message
end

class DSL
attr_reader :configuration

def initialize(configuration)
@configuration = configuration
end

# Declare portal elements your tests are allowed to use, with their related test_ids
# This is a global configuration as a way to centralize and make it difficult to abuse
# the usage of portals, since they ignore completely the automatic scoping of
# Capybara's finders that Tabasco provides.
#
# Tabasco.configure do |config|
# # will locate data-testid="toast_message" anywhere in the DOM
# config.portal(:toast_message)
#
# # As usual, the test_id can be overridden
# config.portal(:datepicker, test_id: :react_datepicker)
#
# # And you can provide concrete subclass of Tabasco::Section class
# config.portal(:datepicker, MyDatepicker)
# end
def portal(name, klass = nil, test_id: nil)
name = name.to_sym
test_id ||= name

if portals.key?(name)
raise PortalAlreadyConfiguredError,
"The portal #{name.inspect} is already defined"
end

portals[name] = {klass:, test_id:}

nil
end

private

def portals = configuration.instance_variable_get(:@portals)
end
end
end
Loading
Loading