-
Notifications
You must be signed in to change notification settings - Fork 2
How to model tables
To simplify the problem, it can be broken into 2 pieces. The first piece is the entire row. The second piece is the entire table. Any table is basically made up of of zero or more rows. So, we will first solve the issue with the rows which then can be used in the table. So, we have 2 generic classes for use in modeling tables which are GenericRow & GenericTable.
For any row, the first thing you need to do is to decide/determine how a row will be uniquely identified. Based on the structure of the HTML, this could be the id attribute or any other attribute that makes the row unique. It could also be the case that the rows cannot be uniquely identified for this case just assume there will be an id attribute for now. (This case will be explained/handled later.) We will use the following table for the explanation.
The corresponding HTML for the table:
<table id="table2" class="tablesorter">
<thead>
<tr>
<th class="header"><span class="last-name">Last Name</span></th>
<th class="header"><span class="first-name">First Name</span></th>
<th class="header"><span class="email">Email</span></th>
<th class="header"><span class="dues">Due</span></th>
<th class="header"><span class="web-site">Web Site</span></th>
<th class="header"><span class="action">Action</span></th>
</tr>
</thead>
<tbody>
<tr>
<td class="last-name">Smith</td>
<td class="first-name">John</td>
<td class="email">[email protected]</td>
<td class="dues">$50.00</td>
<td class="web-site">http://www.jsmith.com</td>
<td class="action">
<a href="#edit">edit</a>
<a href="#delete">delete</a>
</td>
</tr>
<tr>
<td class="last-name">Bach</td>
<td class="first-name">Frank</td>
<td class="email">[email protected]</td>
<td class="dues">$51.00</td>
<td class="web-site">http://www.frank.com</td>
<td class="action">
<a href="#edit">edit</a>
<a href="#delete">delete</a>
</td>
</tr>
<tr>
<td class="last-name">Doe</td>
<td class="first-name">Jason</td>
<td class="email">[email protected]</td>
<td class="dues">$100.00</td>
<td class="web-site">http://www.jdoe.com</td>
<td class="action">
<a href="#edit">edit</a>
<a href="#delete">delete</a>
</td>
</tr>
<tr>
<td class="last-name">Conway</td>
<td class="first-name">Tim</td>
<td class="email">[email protected]</td>
<td class="dues">$50.00</td>
<td class="web-site">http://www.timconway.com</td>
<td class="action">
<a href="#edit">edit</a>
<a href="#delete">delete</a>
</td>
</tr>
</tbody>
</table>
In this case, the rows cannot be uniquely identified as such we will need to add a way to uniquely identify them later. This will be explained/handled later by the table. So, if we somehow knew the id attribute of the row we wanted to work with, then all would be fine. The issue you would normally have is the id is dynamic and the PageFactory does not work with dynamic locators. However, in the framework we can handle dynamic locators. This is done by creating a substitution map of key to dynamic values. There is also a key of row available which uniquely identifies the displayed row. In this specific case, it will be the id attribute of the displayed row.
Given this information, we can construct locators to all the columns (fields) we are interested in. A key needs to be wrapped in a locator as ${key}. Lets write the locator for the last Name.
@FindBy(css = "[id='${row}'] .last-name")
private WebComponent lastName;
At run-time the ${row} will be replaced with the actual id attribute that was added to the row in this case. So, for the second row the locator would resolve to the something like the following at run-time:
@FindBy(css = "[id='abcdefghij1'] .last-name")
private WebComponent lastName;
At this point, we can create the page object to represent the row which will extend GenericRow.
@SuppressWarnings("squid:MaximumInheritanceDepth")
public class HerokuappRow extends GenericRow {
@FindBy(css = "[id='${row}'] .last-name")
private WebComponent lastName;
public HerokuappRow() {
super();
}
public HerokuappRow(TestContext context) {
super(context);
}
public String getLastName() {
return lastName.getText();
}
}
This page object can get the last name for any row and for this explanation is sufficient. However, normally you would put all the other columns as well. This page object can be used with the generic table to complete the modeling of the table.
Once the row for the table has been modelled, it is straight forward to complete the modelling of the table. All you need is the following information:
- The locator to find all rows. This should exclude the header & footer if they exist.
- The locator to find the message when no rows. This can be null if the table is never empty.
- The constructor to create a new row page object.
- If the rows have unique id attributes, then we will use the id attribute to identify the row. If the rows do not have any id attribute, then we will add them to uniquely identify the rows.
At this point we can create the page object to represent the table which will extend GenericTable and implement the 3 methods that are required: getAllRowsLocator, getNoRowsLocator & getNewRowInstance:
@SuppressWarnings("squid:MaximumInheritanceDepth")
public class HerokuappDataTablesPage extends GenericTable<HerokuappRow> {
private static final By TABLE2_ROWS = By.cssSelector("#table2 tbody tr");
public HerokuappDataTablesPage() {
super();
}
public HerokuappDataTablesPage(TestContext context) {
super(context);
}
@Override
protected By getAllRowsLocator() {
return TABLE2_ROWS;
}
@Override
protected By getNoRowsLocator() {
return null;
}
@Override
protected HerokuappRow getNewRowInstance() {
return new HerokuappRow();
}
@Override
protected String getAttributeToExtractRowKey() {
// This is not necessary but it makes it clear the attribute we are adding to the element
return JsUtils.ID;
}
@Override
protected void addAttributeToRow(WebElement row, String attribute, String uniqueValue) {
// We know these rows do not have IDs as such it is safe to add an ID
JsUtils.addAttribute(row, attribute, uniqueValue);
}
public HerokuappDataTablesPage resetRows() {
resetTableRows();
return this;
}
public List<HerokuappRow> getAllRows() {
return getTableRows();
}
}
I have also overridden the methods getAttributeToExtractRowKey & addAttributeToRow. In this case, we are adding the id attribute to each row. Also, I have exposed methods to reset the rows as they are cached after first retrieval and get all the rows. This is the most basic table implementation required in this case.
By default, the method getAttributeToExtractRowKey uses the id attribute. All that necessary is to override this method with the existing attribute to use.
@Override
protected String getAttributeToExtractRowKey() {
return "qa-data";
}
By default, only the key row is in the substitutions. If additional keys are required, then override the getSubstitutions method.
@SuppressWarnings("squid:MaximumInheritanceDepth")
public class HerokuappRowTable1 extends GenericRow {
@XStreamOmitField
@FindBy(xpath = "//*[@id='${row}']/*[position()=${LAST_NAME}]")
private WebComponent lastName;
@XStreamOmitField
@FindBy(xpath = "//*[@id='${row}']/*[position()=${FIRST_NAME}]")
private WebComponent firstName;
// ...
}
In this example, I have include 2 additional keys: LAST_NAME & FIRST_NAME. This requires the getSubstitutions method to be override like the following in the table page object:
@Override
protected Map<String, String> getSubstitutions() {
HerokuappColumnPositionsExtractor extractor = new HerokuappColumnPositionsExtractor();
Map<String, String> columnPositions = extractor.getMap();
return extractor.getSubstitutions(HerokuappColumnMapping.LAST_NAME, columnPositions);
}
See page for further explanation.
By default, the method getIncludePredicate includes all rows found by the locator. By overriding this method, you can control the rows that are included.
private static final String DATA_ROW_REGEX = ".*:\\d*:0$"; // All data rows end with :N:0 in their id
@Override
protected Predicate<WebElement> getIncludePredicate() {
return this::isDataRow;
}
private boolean isDataRow(WebElement element) {
String id = StringUtils.defaultString(element.getAttribute(JsUtils.ID));
return id.matches(DATA_ROW_REGEX);
}
The generic table supports doing a regular expression against all the columns. It also supports doing the search across a paginated table. To support the generic search, first implement an isMatch method in the row class.
public boolean isMatch(HerokuappRow rowToMatch) {
return isMatch(this::getLastName, rowToMatch.lastName.getData(DataTypes.Data, true)) &&
isMatch(this::getFirstName, rowToMatch.firstName.getData(DataTypes.Data, true)) &&
isMatch(this::getEmail, rowToMatch.email.getData(DataTypes.Data, true)) &&
isMatch(this::getDues, rowToMatch.dues.getData(DataTypes.Data, true)) &&
isMatch(this::getWebsite, rowToMatch.website.getData(DataTypes.Data, true))
;
}
In the table class, override the isMatch method to use the above method.
@Override
protected boolean isMatch(HerokuappRow actualRow, HerokuappRow rowToMatch) {
return actualRow.isMatch(rowToMatch);
}
This will allow the generic search to work (without pagination.) If you need to account for pagination, then you need to override the methods: getMaxIterations, isNextPage, isPreviousPage, clickNextPage & clickPreviousPage. (If you only want to search in one direction, then only override the methods for that direction.) Finally, you would expose a method to do the search.
@FindBy(css = "[id$='next']")
private WebComponent next;
@FindBy(css = "[id$='prev']")
private WebComponent prev;
@Override
protected int getMaxIterations() {
// Give up & fail after searching 10 pages to prevent possible infinite loop.
return 10;
}
@Override
protected boolean isNextPage() {
// Let's assume that the next button is always displayed but disabled when there are no more next pages
return next.isEnabled();
}
@Override
protected boolean isPreviousPage() {
// Let's assume that the previous button is always displayed but disabled when there are no more previous pages
return prev.isEnabled();
}
@Override
protected void clickNextPage() {
// Note: This should not return until the next page is loaded.
click(next);
}
@Override
protected void clickPreviousPage() {
// Note: This should not return until the previous page is loaded.
click(prev);
}
public HerokuappRow findNextRow(HerokuappRow rowToMatch) {
// For this example just assume we are at page 1
return findTableRow(rowToMatch, true, true, true);
}
public HerokuappRow findPrevRow(HerokuappRow rowToMatch) {
// For this example just assume we are at the last page
return findTableRow(rowToMatch, true, true, false);
}