Group attributes and create new ones

Components

Components let you group attributes together, they are just plain objects with attributes on it. Components can define a scope.

Example

<h1>New user</h1>
<form class="awesome-form">
  <input id="firstName" placeholder="First name">
  <input id="lastName" placeholder="Last name">
  <button>Create</button>
</form>
const { visitable, text, fillable, clickable } = PageObject;

var page = PageObject.create({
  visit: visitable('/user/create'),
  title: text('h1'),

  form: {
    scope: '.awesome-form',

    firstName: fillable('#firstName'),
    lastName: fillable('#lastName'),
    submit: clickable('button')
  }
});

page
  .visit()
  .form()
  .firstName('John')
  .lastName('Doe')
  .submit();

andThen(function() {
  // assert something
});

Default attributes

By default, all components define handy attributes to be used without been explicitely declared.

Example

Suppose you have a modal dialog

<div class="modal">
  Are you sure you want to exit the page?
  <button>I'm sure</button>
  <button>No</button>
</form>
const { visitable } = PageObject;

var page = PageObject.create({
  visit: visitable('/'),

  modal: {
    scope: '.modal'
  }
});

page.visit();

andThen(function() {
  assert.ok(page.modal().contains('Are you sure you want to exit the page?'));
});

page.modal().clickOn("I'm sure");

.collection

Easily model a table or a list of items.

Attribute signature

PageObject.collection(definition)

The collection definition has the following structure

{
  itemScope: '', // css selector

  item: {
    // item attributes
  },

  // collection attributes
}

The attributes defined in the item object are scoped using the itemScope selector. The attributes defined outside the item object are available at collection scope.

Example

<table id="users">
  <caption>The list of users</caption>
  <tr>
    <td>Jane</td>
    <td>Doe</td>
  </tr>
  <tr>
    <td>John</td>
    <td>Doe</td>
  </tr>
</table>
const { visitable, text, collection } = PageObject;

var page = PageObject.create({
  visit: visitable('/users'),

  users: collection({
    itemScope: '#users tr',

    item: {
      firstName: text('td:nth-of-type(1)'),
      lastName: text('td:nth-of-type(2)')
    },

    caption: text('#users caption')
  })
});

test('show all users', function(assert) {
  page.visit();

  andThen(function() {
    assert.equal(login.users().caption(), 'The list of users');
    assert.equal(login.users().count(), 2); // count attribute is added for free
    assert.equal(login.users(1).firstName(), 'Jane');
    assert.equal(login.users(1).lastName(), 'Doe');
    assert.equal(login.users(2).firstName(), 'John');
    assert.equal(login.users(2).lastName(), 'Doe');
  });
});

.customHelper

Define reusable helpers using information of the surrounding context.

PageObject.customHelper(function(selector, options) {
  // user code goes here

  return value;
});

There are three different types of custom helpers and are differentiated by the return value. You can define custom helpers that return:

  1. A basic type value
  2. A plain object value
  3. A function value

Given this HTML snippet, the following is an example of each type of custom helpers

<form>
  <label class="has-error">
    User name
    <input id="userName" />
  </label>
</form>

1. Basic type value

This type of custom helper is useful to return the result of a calculation, for example the result of a jQuery expression.

var disabled = customHelper(function(selector, options) {
  return $(selector).prop('disabled');
});

var page = PageObject.create({
  userName: {
    disabled: disabled('#userName')
  }
});

assert.ok(!page.userName().disabled(), 'user name input is not disabled');

As you can see the jQuery expression is returned.

2. Plain Object

This is very similar to a component. The difference with components is that we can do calculations or use custom options before returning the component.

var input = customHelper(function(selector, options) {
  return {
    value: value(selector),
    hasError: function() {
      return $(selector).parent().hasClass('has-error');
    }
  };
});

var page = PageObject.create({
  scope: 'form',
  userName: input('#userName')
});

assert.ok(page.userName().hasError(), 'user name has errors');

As you can see the returned plain object is converted to a component.

3. Functions

The main difference with the previous custom helpers is that the returned functions receive invocation parameters. This is most useful when creating custom actions that receives options when invoked (like fillIn helper).

/* global click */
var clickManyTimes = customHelper(function(selector, options) {
  return function(numberOfTimes) {
    click(selector);

    for(let i = 0; i < numberOfTimes - 1; i++) {
      click(selector);
    }
  };
});

var page = PageObject.create({
  clickAgeSelector: clickManyTimes('#ageSelector .spinner-button'),
  ageValue: value('#ageSelector input')
});

page.visit().clickOnAgeSelector(18 /* times*/);

andThen(function() {
  assert.equal(page.ageValue(), 18, 'User is 18 years old');
});

We can see that our clickOnAgeSelector takes one parameter that’s used by the returned function.

Custom options

Custom helpers can receive custom options, here’s an example of this:

var prop = customHelper(function(selector, options) {
  return $(selector).prop(options.name);
});

var page = PageObject.create({
  userName: {
    disabled: prop('#userName', { name: 'disabled' })
  }
});

assert.ok(!page.userName().disabled(), 'user name input is not disabled');

Scopes

The scope attribute can be used to reduce the set of matched elements to the ones enclosed by the given selector.

Given the following HTML

<div class="article">
  <p>Lorem ipsum dolor</p>
</div>
<div class="footer">
  <p>Copyright 2015 - Acme Inc.</p>
</div>

the following configuration will match the article paragraph element

var page = PageObject.create({
  scope: '.article',

  textBody: PageObject.text('p'),
});

andThen(function() {
  assert.equal(page.textBody(), 'Lorem ipsum dolor.');
});

The attribute’s selector can be omited when the scope matches the element we want to use.

Given the following HTML

<form>
  <input id="userName" value="a value" />
  <button>Submit</button>
</form>

We can define several attributes on the same input element as follows

var page = PageObject.create({
  input: {
    scope: '#userName',

    hasError: hasClass('has-error'),
    value: value(),
    fillIn: fillable()
  },

  submit: clickable('button')
});

page
  .input()
  .fillIn('an invalid value');

page.submit();

andThen(function() {
  assert.ok(page.input().hasError(), 'Input has an error');
});

collection inherits parent scope by default

<div class="todo">
  <input type="text" value="invalid value" class="error" placeholder="To do..." />
  <input type="text" placeholder="To do..." />
  <input type="text" placeholder="To do..." />
  <input type="text" placeholder="To do..." />

  <button>Create</button>
</div>
var page = PageObject.create({
  scope: '.todo',

  todos: collection({
    itemScope: 'input',

    item: {
      value: value(),
      hasError: hasClass('error')
    },

    create: clickable('button')
  });
});
call translates to
page.todos().create() click('.todo button')
page.todos(1).value() find('.todo input:eq(0)').val()

You can reset parent scope by setting the scope attribute on the collection declaration.

var page = PageObject.create({
  scope: '.todo',

  todos: collection({
    scope: ' ',
    itemScope: 'input',

    item: {
      value: value(),
      hasError: hasClass('error')
    },

    create: clickable('button')
  });
});
call translates to
page.todos().create() click('button')
page.todos(1).value() find('input:eq(0)').val()

itemScope is inherited as default scope on components defined inside the item object.

<ul class="todos">
  <li>
    <span>To do</span>
    <input value="" />
  </li>
  ...
</ul>
var page = PageObject.create({
  scope: '.todos',

  todos: collection({
    itemScope: 'li',

    item: {
      label: text('span'),
      input: {
        value: value('input')
      }
    }
  });
});
call translates to
page.todos(1).input().value() find('.todos li:nth-of-child(1) input').val()

component inherits parent scope by default

<div class="search">
  <input placeholder="Search..." />
  <button>Search</button>
</div>
var page = PageObject.create({
  search: {
    scope: '.search',

    input: {
      fillIn: fillable('input'),
      value: value('input')
    }
  }
});
call translates to
page.search().input().value() find('.search input').val()

You can reset parent scope by setting the scope attribute on the component declaration.

var page = PageObject.create({
  search: {
    scope: '.search',

    input: {
      scope: 'input',

      fillIn: fillable(),
      value: value()
    }
  }
});
call translates to
page.search().input().value() find('input').val()