Skip to content

Commit

Permalink
More advanced interface for portal configuration
Browse files Browse the repository at this point in the history
Multiple portals can be defined using Tabasco.configure, and a test_id is
required to locate their containers.
  • Loading branch information
khamusa committed Jan 8, 2025
1 parent 865911b commit 04a1c8f
Show file tree
Hide file tree
Showing 12 changed files with 194 additions and 93 deletions.
30 changes: 21 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,27 +197,28 @@ demo_page.main_content.tenant_id # NoMethodError

### Portal sections

Sometimes, you'll need to bypass the automatic scoping of sections, and define a nested section that targets a DOM element that is not a children of the parent section container.
Sometimes you'll need to bypass the automatic scoping of sections, and define a nested section that targets a DOM element that is not a children of the parent section container.

A common use case is for targeting the contents of floating elements, such as toast messages, datepickers, popover elements, that are often inserted right below the body element. For this, you can use portals. To do that, you must define a block that finds in the global page scope, your portal elements.
A common use case is for targeting the contents of floating elements, such as toast messages, datepickers, popover elements, that are often inserted close to the root of the page DOM.

Consider the following html fragment:
For these cases, you can use portals. Consider the following html fragment:

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

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

First, we configure how the portal element is retrieved:
First, we give this portal a name and tell Tabasco how to find it:

```rb
Tabasco.configure do |config|
config.portal { find("[data-portal-container]") }
# test_id is only necessary if it does not match the portal name
config.portal(:toast_message, test_id: :toast_portal_container)
end
```

You can then define a portal subsection like this:
The `:toast_message` portal can now be used inside your sections:

```rb
class MyForm < Tabasco::Section
Expand All @@ -229,14 +230,25 @@ class MyForm < Tabasco::Section
end
```

The portal will work as a subsection of MyForm, even though it's container is not a child of the form div:
The portal will behave as a subsection of MyForm, even though it's container is not a child of the form div:

```rb
my_form_section = MyForm.load
expect(my_form_section.portal).to have_content("My portal content!")

# Caveat: this won't work, as the DOM element is not copied or moved to the parent element!
expect(my_form_section).to have_content("My portal content!")
```

This is an experimental API. For now it only support a single portal element, and needs to be configured globally, which is a rather strong limitation (although intentional, as we don't want to encourage bypassing section scope). Feedback is welcome.
You may define multiple types of portals as well, but we encourage you to use this with caution, given bypassing the natural scoping of sections defeats the purpose of using Tabasco and reduces the amount of guardrails we can offer you out of the box.

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

### Organizing your directory structure

Expand Down
3 changes: 1 addition & 2 deletions lib/tabasco.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@
require "capybara/dsl"
require "capybara/rspec/matchers"
require_relative "tabasco/version"
require_relative "tabasco/error"
require_relative "tabasco/configuration"

module Tabasco
class Error < StandardError; end

def self.configure
yield configuration.dsl
end
Expand Down
54 changes: 41 additions & 13 deletions lib/tabasco/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,61 @@

module Tabasco
class Configuration
attr_reader :portal
class Error < ::Tabasco::Error; end
class PortalNotConfigured < Error; end

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 PortalNotConfigured, message
end

class DSL
attr_reader :configuration

def initialize(configuration)
@configuration = configuration
end

# Define a proc that will be used to find the default portal element in the DOM.
# example:

# 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|
# config.portal do
# find("[data-floating-ui-portal]")
# end
# end
# # will locate data-testid="toast_message" anywhere in the DOM
# config.portal(:toast_message)
#
# Right now we only support a single portal element, but we could easily extend this
# to support multiple named portals.
def portal(&block)
configuration.instance_variable_set(:@portal, block)
# # As usual, the test_id can be overridden
# config.portal(:datepicker, test_id: :react_datepicker)
# end
def portal(portal_name, test_id: nil)
portal_name = portal_name.to_sym
test_id ||= portal_name

portals = configuration.instance_variable_get(:@portals)

raise Error, "The portal #{portal_name.inspect} is already defined" if portals.key?(portal_name.to_sym)

portals[portal_name] = {test_id:}

nil
end
end
end
end
end
4 changes: 4 additions & 0 deletions lib/tabasco/container.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ def inspect
end
# rubocop: enable Metrics/MethodLength

def self.prepare_test_id(test_id)
test_id.to_s.tr("_", "-")
end

# Automatically adds a private precondition method for any has_X? query method added.
# If a method named `has_<something>?` is defined, we will create a corresponding
# `has_<something>!` method.
Expand Down
5 changes: 5 additions & 0 deletions lib/tabasco/error.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# frozen_string_literal: true

module Tabasco
class Error < StandardError; end
end
47 changes: 22 additions & 25 deletions lib/tabasco/portal.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,42 +3,39 @@
require_relative "container"

module Tabasco
class PortalNotConfigured < ::Tabasco::Error; end

class Portal < Container
def container
@container ||= begin
self.class.assert_portal_configured!
class MissingHandleError < Error; end

Capybara.current_session.instance_eval(
&Tabasco.configuration.portal
)
end
def self.handle(value = nil)
return @handle if value.nil?

@handle = value.to_sym
end

def self.assert_portal_configured!
return if Tabasco.configuration.portal
def initialize(...)
raise MissingHandleError, "A handle must be defined when using portals" if self.class.handle.nil?

message = <<~ERR
Portal not configured, you must provide a block that fetches the default portal
container using Tabasco.configure. Example:
super
end

Tabasco.configure do |config|
config.portal do
find("[data-my-portal-container]")
end
end
ERR
private

raise PortalNotConfigured, message
def container
@container ||= case Tabasco.configuration.portal(self.class.handle)
in { test_id: test_id }
Capybara.current_session.find("[data-testid='#{self.class.prepare_test_id(test_id)}']")
else
raise ArgumentError, "The portal #{self.class.handle.inspect} is not configured"
end
end
end

class Container
def self.portal(name, klass = nil, &)
Portal.assert_portal_configured!

define_inline_section(name, klass, inline_superclass: Portal, &)
def self.portal(name, klass = nil, &block)
define_inline_section(name, klass, inline_superclass: Portal) do
handle name
class_eval(&block) if block
end
end
end
end
2 changes: 1 addition & 1 deletion lib/tabasco/section.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def container

class Container
def self.section(name, klass = nil, test_id: nil, &block)
test_id = (test_id || name).to_s.tr("_", "-")
test_id = prepare_test_id(test_id || name)

define_inline_section(name, klass, inline_superclass: Section) do
class_eval(&block) if block
Expand Down
8 changes: 6 additions & 2 deletions spec/fixtures/portal_test.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,20 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Oyster Tabasco Portal testing page</title>
<link rel="stylesheet" href="styles.css"
<link rel="stylesheet" href="styles.css" />
</head>
<body data-testid="root-container">
<div data-portal-container>
<div data-testid="portal-container">
<h1>This is the portal</h1>
This is a portal element, that can be accessed from internal page objects
or sections even though it's not a direct child of the section container.
However, this needs to be configured using Tabasco.configure to work.
</div>

<div data-testid="datepicker-container">
This is a floating datepicker element.
</div>

<section data-testid="home">
<h2>Home Section</h2>
<p>
Expand Down
9 changes: 7 additions & 2 deletions spec/fixtures/test_page.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Oyster Tabasco Testing Demo Page</title>
<link rel="stylesheet" href="styles.css"
<link rel="stylesheet" href="styles.css" />
</head>
<body data-testid="root-container">
<div data-portal-container>
<div data-testid="my-portal">
<h1>Portal Content</h1>
<p>This is the portal content.</p>
</div>

<div data-testid="another-portal-container">
<h1>Another Portal Content</h1>
<p>This is another portal content.</p>
</div>

<header data-testid="welcome-header">
<h1>Welcome to the Testing Demo Page</h1>
<p>This page is for system testing purposes.</p>
Expand Down
48 changes: 35 additions & 13 deletions spec/tabasco/configuration_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,48 @@

RSpec.describe Tabasco::Configuration do
subject(:configuration) { described_class.new }
let(:dsl) { configuration.dsl }

it "defines sensitive defaults" do
expect(configuration.portal).to be_nil
it "exposes a dsl for writing configuration" do
expect(dsl).to be_a(Tabasco::Configuration::DSL)

expect(dsl.configuration).to eq(configuration)
end

describe "#dsl" do
subject { configuration.dsl }
describe "portal configuration" do
it "can be defined using the DSL and read from the configuration object" do
dsl.portal(:my_portal)

expect(configuration.portal(:my_portal)).to eq(test_id: :my_portal)
end

it "accepts a test_id override" do
dsl.portal(:my_portal, test_id: :lorem_ipsum)
expect(configuration.portal(:my_portal)).to eq(test_id: :lorem_ipsum)
end

it "exposes a dsl for writing configuration" do
expect(subject).to be_a(Tabasco::Configuration::DSL)
it "allows the definition of multiple independent portals" do
dsl.portal(:my_portal, test_id: :lorem_ipsum)
dsl.portal(:datepicker, test_id: :the_date_picker)
dsl.portal(:toast_message)

expect(subject.configuration).to eq(configuration)
expect(configuration.portal(:my_portal)).to eq(test_id: :lorem_ipsum)
expect(configuration.portal(:datepicker)).to eq(test_id: :the_date_picker)
expect(configuration.portal(:toast_message)).to eq(test_id: :toast_message)
end

describe "#portal" do
it "stores the portal block in the configuration object" do
block = -> { "lorem" }
subject.portal(&block)
expect(configuration.portal).to be block
end
it "raises an error when trying to read a portal that has not been defined" do
expect {
configuration.portal(:my_portal)
}.to raise_error(Tabasco::Configuration::PortalNotConfigured)
end

it "raises an error when trying to define a portal with the same name twice" do
dsl.portal(:my_portal)

expect {
dsl.portal(:my_portal)
}.to raise_error(Tabasco::Configuration::Error, "The portal :my_portal is already defined")
end
end
end
Loading

0 comments on commit 04a1c8f

Please sign in to comment.