You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
607 lines
15 KiB
607 lines
15 KiB
1 year ago
|
<?php
|
||
|
/*
|
||
|
** Zabbix
|
||
|
** Copyright (C) 2001-2023 Zabbix SIA
|
||
|
**
|
||
|
** This program is free software; you can redistribute it and/or modify
|
||
|
** it under the terms of the GNU General Public License as published by
|
||
|
** the Free Software Foundation; either version 2 of the License, or
|
||
|
** (at your option) any later version.
|
||
|
**
|
||
|
** This program is distributed in the hope that it will be useful,
|
||
|
** but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
|
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
|
** GNU General Public License for more details.
|
||
|
**
|
||
|
** You should have received a copy of the GNU General Public License
|
||
|
** along with this program; if not, write to the Free Software
|
||
|
** Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
|
||
|
**/
|
||
|
|
||
|
require_once 'vendor/autoload.php';
|
||
|
|
||
|
require_once dirname(__FILE__).'/../CElement.php';
|
||
|
|
||
|
use \Facebook\WebDriver\Exception\UnrecognizedExceptionException;
|
||
|
use \Facebook\WebDriver\Exception\ElementNotInteractableException;
|
||
|
|
||
|
/**
|
||
|
* Multifield table element.
|
||
|
*/
|
||
|
class CMultifieldTableElement extends CTableElement {
|
||
|
|
||
|
/**
|
||
|
* Element selectors.
|
||
|
*
|
||
|
* @var array
|
||
|
*/
|
||
|
protected $selectors = [
|
||
|
'header' => 'xpath:./thead/tr/th',
|
||
|
'row' => 'xpath:./tbody/tr[contains(@class, "form_row") or contains(@class, "pairRow") or contains(@class, "editable_table_row")]',
|
||
|
'column' => 'xpath:./td'
|
||
|
];
|
||
|
|
||
|
/**
|
||
|
* Field mapping.
|
||
|
*
|
||
|
* @var array
|
||
|
*/
|
||
|
protected $mapping;
|
||
|
|
||
|
/**
|
||
|
* Field mapping names.
|
||
|
*
|
||
|
* @var array
|
||
|
*/
|
||
|
protected $names;
|
||
|
|
||
|
/**
|
||
|
* Field mapping aliases.
|
||
|
*
|
||
|
* @var array
|
||
|
*/
|
||
|
protected $aliases = [];
|
||
|
|
||
|
/**
|
||
|
* Get field mapping.
|
||
|
*
|
||
|
* @return array
|
||
|
*/
|
||
|
public function getFieldMapping() {
|
||
|
return is_array($this->mapping) ? $this->mapping : [];
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Set field mapping names.
|
||
|
*
|
||
|
* @param array $names field name
|
||
|
*
|
||
|
* @return CMultifieldTableElement
|
||
|
*/
|
||
|
public function setFieldNames($names) {
|
||
|
if (!is_array($this->names)) {
|
||
|
$this->names = [];
|
||
|
}
|
||
|
|
||
|
foreach ($names as $field => $name) {
|
||
|
$this->names[$field] = $name;
|
||
|
}
|
||
|
|
||
|
return $this;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Set field mapping.
|
||
|
* Field mapping is used to address controls within multifield table row.
|
||
|
*
|
||
|
* For example, if there is a three control row like this:
|
||
|
* [ tag ] [Contains|Equals] [ value ]
|
||
|
*
|
||
|
* The following mappings can be used:
|
||
|
* 1. ['tag', 'operator', 'value']
|
||
|
* This will set names for the fields, but controls will be detected automatically (slow).
|
||
|
* 2. [['name' => 'tag'], ['name' => 'operator'], ['name' => 'value']]
|
||
|
* This is the same mapping as was described in #1.
|
||
|
* 3. [
|
||
|
* ['name' => 'tag', 'class' => 'CElement'],
|
||
|
* ['name' => 'operator', 'class' => 'CSegmentedRadioElement'],
|
||
|
* ['name' => 'value', 'class' => 'CElement']
|
||
|
* ]
|
||
|
* This will set names and expected control types for the fields (CElement is generic input).
|
||
|
* 4. [
|
||
|
* ['name' => 'tag', 'selector' => 'xpath:./input', 'class' => 'CElement'],
|
||
|
* ['name' => 'operator', 'selector' => 'class:radio-list-control', 'class' => 'CSegmentedRadioElement'],
|
||
|
* ['name' => 'value', 'selector' => 'xpath:./input', 'class' => 'CElement']
|
||
|
* ]
|
||
|
* This will set names, selectors and expected control types for the fields.
|
||
|
*
|
||
|
* Field mapping indices should match indices of columns in table row. For example, for sortable table rows, there
|
||
|
* is an additional column with sortable controls (first column in this example):
|
||
|
* [::] [ field ] [ value ]
|
||
|
*
|
||
|
* When defining a mapping, sortable column could be skipped by specifying the indices:
|
||
|
* [1 => 'field', 2 => 'value']
|
||
|
* Or it could be set to null:
|
||
|
* [null, 'field', 'value']
|
||
|
*
|
||
|
* For tables with headings, mapping keys should match headings and not indices. For example, mapping for table:
|
||
|
* Name Value
|
||
|
* [ tag ] [ value ]
|
||
|
* Should be defined as follows (array keys match table headers):
|
||
|
* ['Name' => 'tag', 'Value' => 'value']
|
||
|
*
|
||
|
* Be advised that when mapping is not set, multifield operations are slower and fields are indexed by indices (for
|
||
|
* tables without headers) or by header text (for tables with headers).
|
||
|
*
|
||
|
* @param array $mapping field mapping
|
||
|
*/
|
||
|
public function setFieldMapping($mapping) {
|
||
|
$this->mapping = $mapping;
|
||
|
|
||
|
return $this;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Detect field mapping based on the first row elements.
|
||
|
*
|
||
|
* @param array $headers table headers
|
||
|
*
|
||
|
* @return array
|
||
|
*/
|
||
|
public function detectFieldMapping($headers = null) {
|
||
|
$rows = $this->getRows();
|
||
|
|
||
|
if ($rows->count() === 0) {
|
||
|
throw new \Exception('Failed to detect mapping for an empty multifield table.');
|
||
|
}
|
||
|
|
||
|
if ($headers === null) {
|
||
|
$headers = $this->getHeadersText();
|
||
|
}
|
||
|
|
||
|
$result = [];
|
||
|
foreach ($rows->first()->query($this->selectors['column'])->all() as $i => $column) {
|
||
|
$label = CTestArrayHelper::get($headers, $i, $i);
|
||
|
$element = CElementQuery::getInputElement($column, '.')->detect();
|
||
|
|
||
|
if (!$element->isValid()) {
|
||
|
$result[$label] = null;
|
||
|
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
$value = $element->getAttribute('name');
|
||
|
if ($value !== null) {
|
||
|
$element->query('xpath', './/*[@name]')->one(false);
|
||
|
if ($element->isValid()) {
|
||
|
$value = $element->getAttribute('name');
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if ($value !== null) {
|
||
|
$name = $value;
|
||
|
if (substr($value, -1) === ']') {
|
||
|
$pos = strrpos($value, '[');
|
||
|
if ($pos !== false) {
|
||
|
$name = substr($value, $pos + 1, -1);
|
||
|
if (is_numeric($name)) {
|
||
|
$value = substr($value, 0, $pos);
|
||
|
$pos = strrpos($value, '[');
|
||
|
|
||
|
if ($pos !== false) {
|
||
|
$name = substr($value, $pos + 1, -1);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (!$name || is_numeric($name)) {
|
||
|
$name = $label;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
else {
|
||
|
// Element name cannot be detected, using label or index.
|
||
|
$name = $label;
|
||
|
}
|
||
|
|
||
|
$aliases = [];
|
||
|
if ($name !== $label) {
|
||
|
$aliases[] = $label;
|
||
|
}
|
||
|
$aliases[] = $i;
|
||
|
|
||
|
if (!empty($this->names) && array_key_exists($name, $this->names)) {
|
||
|
$aliases[] = $name;
|
||
|
$name = $this->names[$name];
|
||
|
}
|
||
|
|
||
|
foreach ($aliases as $alias) {
|
||
|
$this->aliases[$alias] = $name;
|
||
|
}
|
||
|
|
||
|
$result[$label] = [
|
||
|
'name' => $name,
|
||
|
'class' => get_class($element),
|
||
|
'selector' => CElementQuery::getLastSelector()
|
||
|
];
|
||
|
}
|
||
|
|
||
|
return $result;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get controls from row.
|
||
|
*
|
||
|
* @param CTableRowElement $row table row
|
||
|
* @param array $headers table headers
|
||
|
*
|
||
|
* @return array
|
||
|
*/
|
||
|
public function getRowControls($row, $headers = null) {
|
||
|
$controls = [];
|
||
|
|
||
|
if ($headers === null) {
|
||
|
$headers = $this->getHeadersText();
|
||
|
}
|
||
|
|
||
|
if ($this->mapping === null) {
|
||
|
$this->mapping = $this->detectFieldMapping();
|
||
|
}
|
||
|
|
||
|
foreach ($row->query($this->selectors['column'].'|./th')->all() as $i => $column) {
|
||
|
$label = CTestArrayHelper::get($headers, $i, $i);
|
||
|
$mapping = CTestArrayHelper::get($this->mapping, $label, $label);
|
||
|
if ($mapping === null) {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
if (!is_array($mapping)) {
|
||
|
$mapping = ['name' => $mapping];
|
||
|
}
|
||
|
elseif (!array_key_exists('name', $mapping)) {
|
||
|
$mapping['name'] = $label;
|
||
|
}
|
||
|
|
||
|
if (array_key_exists('selector', $mapping)) {
|
||
|
$control = $column->query($mapping['selector'])
|
||
|
->cast(CTestArrayHelper::get($mapping, 'class', 'CElement'))
|
||
|
->one(false);
|
||
|
}
|
||
|
else {
|
||
|
$control = (!is_array($this->mapping) || array_key_exists($label, $this->mapping))
|
||
|
? CElementQuery::getInputElement($column, '.', CTestArrayHelper::get($mapping, 'class'))
|
||
|
: new CNullElement();
|
||
|
}
|
||
|
|
||
|
if (!$control->isValid()) {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
$controls[$mapping['name']] = $control;
|
||
|
}
|
||
|
|
||
|
return $controls;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get values from all the rows.
|
||
|
*
|
||
|
* @return array
|
||
|
*/
|
||
|
public function getValue() {
|
||
|
$data = [];
|
||
|
$headers = $this->getHeadersText();
|
||
|
|
||
|
foreach ($this->getRows() as $row) {
|
||
|
$values = [];
|
||
|
|
||
|
foreach ($this->getRowControls($row, $headers) as $name => $control) {
|
||
|
$values[$name] = $control->getValue();
|
||
|
}
|
||
|
|
||
|
$data[] = $values;
|
||
|
}
|
||
|
|
||
|
return $data;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Get values from a specific row.
|
||
|
*
|
||
|
* @param integer $index row index
|
||
|
*
|
||
|
* @return array
|
||
|
*/
|
||
|
public function getRowValue($index) {
|
||
|
$value = [];
|
||
|
|
||
|
foreach ($this->getRowControls($this->getRow($index)) as $name => $control) {
|
||
|
$value[$name] = $control->getValue();
|
||
|
}
|
||
|
|
||
|
return $value;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Add new row.
|
||
|
*
|
||
|
* @param array $values row values
|
||
|
*
|
||
|
* return $this
|
||
|
*/
|
||
|
public function addRow($values) {
|
||
|
$rows = $this->getRows()->count();
|
||
|
$this->query('button:Add')->one()->click();
|
||
|
|
||
|
// Wait until new table row appears.
|
||
|
$this->query('xpath:.//'.CXPathHelper::fromSelector($this->selectors['row']).'['.($rows + 1).']')->waitUntilPresent();
|
||
|
return $this->updateRow($rows, $values);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Update row by index.
|
||
|
*
|
||
|
* @param integer $index row index
|
||
|
* @param array $values row values
|
||
|
*
|
||
|
* @throws Exception if not all fields could be found within a row
|
||
|
*
|
||
|
* return $this
|
||
|
*/
|
||
|
public function updateRow($index, $values) {
|
||
|
$controls = $this->getRowControls($this->getRow($index));
|
||
|
foreach ($values as $name => $value) {
|
||
|
$field = (array_key_exists($name, $this->aliases)) ? $this->aliases[$name] : $name;
|
||
|
|
||
|
if (array_key_exists($field, $controls)) {
|
||
|
try {
|
||
|
$controls[$field]->fill($value);
|
||
|
}
|
||
|
catch (\Exception $e1) {
|
||
|
if (!($e1 instanceof UnrecognizedExceptionException)
|
||
|
&& !($e1 instanceof ElementNotInteractableException)) {
|
||
|
throw $e1;
|
||
|
}
|
||
|
|
||
|
try {
|
||
|
$controls = $this->getRowControls($this->getRow($index));
|
||
|
$controls[$field]->fill($value);
|
||
|
}
|
||
|
catch (\Exception $e2) {
|
||
|
throw $e1;
|
||
|
}
|
||
|
}
|
||
|
unset($values[$name]);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if ($values) {
|
||
|
throw new Exception('Failed to set values for fields ['.implode(', ', array_keys($values)).'] when filling'.
|
||
|
' multifield row (controls are not present for those fields).'
|
||
|
);
|
||
|
}
|
||
|
|
||
|
return $this;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Remove row by index.
|
||
|
*
|
||
|
* @param array $index row index
|
||
|
*
|
||
|
* return $this
|
||
|
*/
|
||
|
public function removeRow($index) {
|
||
|
$row = $this->getRow($index);
|
||
|
$row->query('button:Remove')->one()->click();
|
||
|
$row->waitUntilNotPresent();
|
||
|
|
||
|
return $this;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Remove all rows.
|
||
|
*
|
||
|
* return $this
|
||
|
*/
|
||
|
public function clear() {
|
||
|
foreach(array_reverse($this->getRows()->asArray()) as $row) {
|
||
|
$row->query('button:Remove')->one()->click();
|
||
|
}
|
||
|
|
||
|
$this->query($this->selectors['row'])->waitUntilNotPresent();
|
||
|
|
||
|
return $this;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Find row indexes by row data.
|
||
|
*
|
||
|
* @param array $fields row fields
|
||
|
*
|
||
|
* @return array
|
||
|
*/
|
||
|
protected function findRowsByFields($fields) {
|
||
|
$indices = [];
|
||
|
|
||
|
if (array_key_exists('index', $fields)) {
|
||
|
return [$fields['index']];
|
||
|
}
|
||
|
|
||
|
foreach ($this->getValue() as $index => $values) {
|
||
|
foreach ($fields as $name => $value) {
|
||
|
$field = (array_key_exists($name, $this->aliases)) ? $this->aliases[$name] : $name;
|
||
|
|
||
|
if (array_key_exists($field, $values) && $values[$field] === $value) {
|
||
|
$indices[] = $index;
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return $indices;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Fill table with specified data.
|
||
|
* For example, if there is a two control row, with mapping set to ['tag', 'value'], the following $data values
|
||
|
* can be used:
|
||
|
* 1. [
|
||
|
* ['tag' => 'tag1', 'value' => '1'],
|
||
|
* ['tag' => 'tag2', 'value' => '2'],
|
||
|
* ['tag' => 'tag3', 'value' => '3']
|
||
|
* ]
|
||
|
* This will add three rows with values "tag1:1", "tag2:2" and "tag2:3".
|
||
|
* 2. [
|
||
|
* ['tag' => 'tag4', 'value' => '4'],
|
||
|
* ['action' => USER_ACTION_UPDATE, 'index' => 1, 'tag' => 'new tag2', 'value' => 'new 2'],
|
||
|
* ['action' => USER_ACTION_REMOVE, 'index' => 2],
|
||
|
* ['action' => USER_ACTION_REMOVE, 'tag' => 'tag1']
|
||
|
* ]
|
||
|
* This will add row "tag4:4", will update row with index 1 to "new tag2:new 2", will remove rows by index 2
|
||
|
* and rows by tag name "tag1".
|
||
|
*
|
||
|
* @param array $data data array to be set.
|
||
|
*
|
||
|
* @throws Exception
|
||
|
*
|
||
|
* @return $this
|
||
|
*/
|
||
|
public function fill($data) {
|
||
|
if (CTestArrayHelper::isAssociative($data)) {
|
||
|
$data = [$data];
|
||
|
}
|
||
|
|
||
|
// If the first row already presents in multifield table no need to press Add.
|
||
|
$rows = $this->getRows()->count();
|
||
|
if (count($data) >= 1 && CTestArrayHelper::get($data[0], 'action') === null && $rows >= 1) {
|
||
|
if ($this->mapping === null) {
|
||
|
$this->mapping = $this->detectFieldMapping();
|
||
|
}
|
||
|
|
||
|
$fields = [];
|
||
|
foreach ($this->mapping as $mapping) {
|
||
|
if (!is_array($mapping) || !array_key_exists('name', $mapping) || !array_key_exists('class', $mapping)) {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
$fields[$mapping['name']] = $mapping['class'];
|
||
|
}
|
||
|
|
||
|
$empty = true;
|
||
|
$values = $this->getRowValue($rows - 1);
|
||
|
|
||
|
foreach ($values as $key => $value) {
|
||
|
// Elements with predefined values are always ignored.
|
||
|
if (in_array(CTestArrayHelper::get($fields, $key), [CCheckboxElement::class, CRadioButtonList::class,
|
||
|
CSegmentedRadioElement::class])) {
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
if ($value !== '') {
|
||
|
$empty = false;
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if ($empty) {
|
||
|
$data[0]['action'] = USER_ACTION_UPDATE;
|
||
|
$data[0]['index'] = $rows - 1;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
foreach ($data as $row) {
|
||
|
$action = CTestArrayHelper::get($row, 'action', USER_ACTION_ADD);
|
||
|
unset($row['action']);
|
||
|
|
||
|
switch ($action) {
|
||
|
case USER_ACTION_ADD:
|
||
|
$this->addRow($row);
|
||
|
break;
|
||
|
|
||
|
case USER_ACTION_UPDATE:
|
||
|
$indices = $this->findRowsByFields($row);
|
||
|
unset($row['index']);
|
||
|
|
||
|
foreach ($indices as $index) {
|
||
|
$this->updateRow($index, $row);
|
||
|
}
|
||
|
|
||
|
break;
|
||
|
|
||
|
case USER_ACTION_REMOVE:
|
||
|
$indices = $this->findRowsByFields($row);
|
||
|
sort($indices);
|
||
|
|
||
|
foreach (array_reverse($indices) as $index) {
|
||
|
$this->removeRow($index);
|
||
|
}
|
||
|
break;
|
||
|
|
||
|
default:
|
||
|
throw new Exception('Cannot perform action "'.$action.'".');
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return $this;
|
||
|
}
|
||
|
|
||
|
/*
|
||
|
* @inheritdoc
|
||
|
*/
|
||
|
public function checkValue($expected, $raise_exception = true) {
|
||
|
$rows = $this->getRows();
|
||
|
|
||
|
if ($rows->count() !== count($expected)) {
|
||
|
if ($raise_exception) {
|
||
|
throw new Exception('Row count "'.$rows->count().'" doesn\'t match expected row count "'.
|
||
|
count($expected).'" of multifield element.'
|
||
|
);
|
||
|
}
|
||
|
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
$headers = $this->getHeadersText();
|
||
|
foreach ($rows as $id => $row) {
|
||
|
if (!array_key_exists($id, $expected)) {
|
||
|
if ($raise_exception) {
|
||
|
throw new Exception('Row with index "'.$id.'" is not expected in multifield element.');
|
||
|
}
|
||
|
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
$controls = $this->getRowControls($row, $headers);
|
||
|
foreach ($expected[$id] as $name => $value) {
|
||
|
$field = (array_key_exists($name, $this->aliases)) ? $this->aliases[$name] : $name;
|
||
|
|
||
|
if (!array_key_exists($field, $controls)) {
|
||
|
if ($raise_exception) {
|
||
|
throw new Exception('Expected field "'.$name.'" is not present in multifield element row.');
|
||
|
}
|
||
|
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
try {
|
||
|
if (!$controls[$field]->checkValue($value, $raise_exception)) {
|
||
|
return false;
|
||
|
}
|
||
|
}
|
||
|
catch (Exception $exception) {
|
||
|
if ($raise_exception) {
|
||
|
CExceptionHelper::setMessage($exception, 'Multifield element value for field "'.$name.
|
||
|
'['.$id.']" is invalid: '.$exception->getMessage()
|
||
|
);
|
||
|
}
|
||
|
|
||
|
throw $exception;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return true;
|
||
|
}
|
||
|
}
|