Skip to content

Commit

Permalink
feat(web): chainable PageElements (#1864)
Browse files Browse the repository at this point in the history
This change enables `PageElements` to offer more advanced chaining patterns
and DOES NOT require any modifications to the existing client code.

`PageElements` now allows for defining the container element at runtime, **separately** from defining the filtering chain. 

For example:

```typescript
const shoppingList = () =>
  PageElement.located(By.id('shopping-list'))
    .describedAs('shopping list');

await actor.attemptsTo(
  Ensure.that(
    Text.of(
      green(items())
        .first()
        .of(shoppingList())
    ),
    equals('lettuce'),
  )
)
```

`PageElements` now also allows for using pre-composed filter chains, like the one above, in `.where` clauses:

```typescript
const shoppingLists = () =>
  PageElement.located(By.css('.shopping-list'))
    .describedAs('shopping list');
    
// find the last of container items that contain a specific first child item
shoppingLists()
  .where(Text.of(green(items()).first(), equals('lettuce'))
  .last()
```

Thanks to @viper3400 for the code review and suggestions
  • Loading branch information
jan-molak committed Aug 23, 2023
1 parent af95726 commit 4d0c7eb
Show file tree
Hide file tree
Showing 23 changed files with 1,122 additions and 1,346 deletions.
Expand Up @@ -330,8 +330,8 @@ and retrieve only those elements you need.
### Retrieving element from a collection

If you need to retrieve a specific element from a collection, and you know what position it occupies, you can use
[`PageElements#first()`](/api/web/class/PageElements#first),
[`PageElements#last()`](/api/web/class/PageElements#last),
[`PageElements#first()`](/api/web/class/PageElements#first),
[`PageElements#last()`](/api/web/class/PageElements#last),
and [`PageElements#nth(index)`](/api/web/class/PageElements#nth) APIs:

```typescript
Expand Down Expand Up @@ -452,6 +452,36 @@ await actorCalled('Alice').attemptsTo(
)
```

Just like `PageElement` is a meta-question, `PageElements` is a [`MetaList`](/api/core/class/MetaList)
that can be composed with another `PageElement` using a declarative [`pageElements.of(pageElement)`](/api/core/class/MetaList/#of) API
to dynamically model a descendants/ancestor (a.k.a. child/parent) relationship between the elements and their relative root element.

```typescript
import { actorCalled } from '@serenity-js/core'
import { By, PageElement } from '@serenity-js/web'
import { Ensure, equals } from '@serenity-js/assertions'

const basketItem = () =>
PageElement.located(By.css('#basket .item')) // <- Note singular `PageElement`
.describedAs('basket item')

const itemNames = () =>
PageElements.located(By.css('.name')) // <- Note plural `PageElements`
.describedAs('name')

await actorCalled('Alice').attemptsTo(
Ensure.that(
Text.ofAll( // <- retrieve text of
itemNames().of(basketItem()) // ALL the item names in one go
),
equals([
'apples',
'bananas',
)
),
)
```
Serenity/JS lets you compose not just the page elements, but also their **descriptions**.
In our example, description of `Text.of(itemName().of(basketItem()))` will be **derived from individual descriptions** of
questions in the chain and reported as `text of name of basket item`.
Expand Down Expand Up @@ -582,9 +612,9 @@ const basketItems = () => // Locate basket item contai
PageElements.located(By.css('#basket .item'))
.describedAs('basket items')

const BasketItemDetails: MetaQuestion<PageElement, Promise<{ name: string, price: number }>> = {
const BasketItemDetails: MetaQuestion<PageElement, Question<Promise<{ name: string, price: number }>>> = {

of: (element: PageElement) =>
of: (element: PageElement) => // A meta-qustion must provide a method called `of`
Question.about('basket item details', async actor => { // Create a question

Expand Down Expand Up @@ -621,7 +651,7 @@ you can also use [`Question.fromObject`](/api/core/class/Question/#fromObject),
more concise:
```typescript
const BasketItemDetails: MetaQuestion<PageElement, Promise<{ name: string, price: number }>> = {
const BasketItemDetails: MetaQuestion<PageElement, Question<Promise<{ name: string, price: number }>>> = {
of: (element: PageElement) =>
Question.fromObject({ // Create a question that returns a JSON object

Expand Down Expand Up @@ -702,15 +732,16 @@ const destroyButton = () => // Destroy button
### Filtering page elements
Serenity/JS [`PageElements`](/api/web/class/PageElements/) are a [`List`](/api/core/class/List/), which means they offer a filtering API
[`list.where(metaQuestion, expectation)`](/api/core/class/List/#where) and methods like [`first()`](/api/web/class/PageElements/#first),
[`list.where(metaQuestion, expectation)`](/api/core/class/List/#where) and methods like
[`first()`](/api/web/class/PageElements/#first),
[`last()`](/api/web/class/PageElements/#last),
or [`count()`](/api/web/class/PageElements/#count).
You can use those APIs to find only those shopping list items that have `buy` within their [`CssClasses`](/api/web/class/CssClasses/):
```typescript
import { actorCalled } from '@serenity-js/core'
import { CssClasses } from '@serenity-js/web';
import { CssClasses } from '@serenity-js/web'

await actorCalled('Alice').attemptsTo(
Ensure.that(
Expand All @@ -726,7 +757,7 @@ Furthermore, you can compose the result of your query with another question, lik
```typescript
import { actorCalled } from '@serenity-js/core'
import { CssClasses } from '@serenity-js/web';
import { CssClasses } from '@serenity-js/web'
import { Ensure, contain, equals } from '@serenity-js/assertions'

await actorCalled('Alice').attemptsTo(
Expand All @@ -745,7 +776,7 @@ You can also combine several `.where` calls, adding multiple meta-questions to y
```typescript
import { actorCalled } from '@serenity-js/core'
import { CssClasses } from '@serenity-js/web';
import { CssClasses } from '@serenity-js/web'
import { Ensure, contain, equals } from '@serenity-js/assertions'

await actorCalled('Alice').attemptsTo(
Expand All @@ -761,6 +792,28 @@ await actorCalled('Alice').attemptsTo(
)
```
You can also define a chain of filtering calls to **resolve it dynamically**
in the context of a root element at runtime, improving reusability of your code:
```typescript
import { actorCalled } from '@serenity-js/core'
import { Ensure, equals, isPresent } from '@serenity-js/assertions'

const itemCalled = (name: string) =>
PageElements.located(By.css('.item'))
.where(label(), equals(name))
.first()
.describedAs(`item called ${ name }`) // note how we DON'T have to specify the container element

await actorCalled('Alice').attemptsTo(
Ensure.that(
itemCalled('coffee')
.of(shoppingList()), // container defined at runtime
isPresent(),
)
)
```
### Finding a sibling element
To find a sibling element, e.g. find a destroy button for an item which label contains a certain text:
Expand All @@ -769,7 +822,7 @@ To find a sibling element, e.g. find a destroy button for an item which label co
```typescript
import { actorCalled } from '@serenity-js/core'
import { CssClasses, Click } from '@serenity-js/web';
import { CssClasses, Click } from '@serenity-js/web'
import { Ensure, contain, equals } from '@serenity-js/assertions'

const itemCalled = (name: string) =>
Expand All @@ -787,7 +840,6 @@ await actorCalled('Alice').attemptsTo(
### Iterating over elements
The [`List`](/api/core/class/List/) interface implemented by [`PageElements`](/api/web/class/PageElements/) lets you
use the [`List#forEach`](/api/core/class/List#forEach) API to
perform a sequence of interactions with each element of the collection.
Expand All @@ -798,7 +850,7 @@ For example, to toggle every item that hasn't been bought yet:
```typescript
import { actorCalled } from '@serenity-js/core'
import { CssClasses } from '@serenity-js/web';
import { CssClasses } from '@serenity-js/web'
import { Ensure, contain, equals } from '@serenity-js/assertions'

await actorCalled('Alice').attemptsTo(
Expand Down

0 comments on commit 4d0c7eb

Please sign in to comment.