'search-editor',
'class' => 'search-editor'
];
/** @var string */
protected $queryString;
/** @var Url */
protected $suggestionUrl;
/** @var Parser */
protected $parser;
/** @var Filter\Rule */
protected $filter;
/** @var bool */
protected $cleared = false;
/**
* Set the filter query string to populate the form with
*
* Use {@see SearchEditor::getParser()} to subscribe to parser events.
*
* @param string $query
*
* @return $this
*/
public function setQueryString($query)
{
$this->queryString = $query;
return $this;
}
/**
* Get the suggestion url
*
* @return ?Url
*/
public function getSuggestionUrl(): ?Url
{
return $this->suggestionUrl;
}
/**
* Set the suggestion url
*
* @param Url $url
*
* @return $this
*/
public function setSuggestionUrl(Url $url)
{
$this->suggestionUrl = $url;
return $this;
}
/**
* Get the query string parser being used
*
* @return Parser
*/
public function getParser()
{
if ($this->parser === null) {
$this->parser = new Parser();
}
return $this->parser;
}
/**
* Get the current filter
*
* @return Filter\Rule
*/
public function getFilter()
{
if ($this->filter === null) {
$this->filter = $this->getParser()
->setQueryString($this->queryString)
->parse();
}
return $this->filter;
}
public function populate($values)
{
// applyChanges() is basically this form's own populate implementation, hence
// why it changes $values and needs to run before actually populating the form
$filter = (new Parser(isset($values['filter']) ? $values['filter'] : $this->queryString))
->setStrict()
->parse();
$filter = $this->applyChanges($filter, $values);
parent::populate($values);
$this->filter = $this->applyStructuralChange($filter);
if ($this->filter !== null && ($this->filter instanceof Filter\Condition || ! $this->filter->isEmpty())) {
$this->queryString = (new Renderer($this->filter))->setStrict()->render();
} else {
$this->queryString = '';
}
return $this;
}
public function hasBeenSubmitted()
{
if (parent::hasBeenSubmitted()) {
return true;
}
return $this->cleared;
}
public function validate()
{
if ($this->cleared) {
$this->isValid = true;
} else {
parent::validate();
}
return $this;
}
protected function applyChanges(Filter\Rule $rule, array &$values, array $path = [0])
{
$identifier = 'rule-' . join('-', $path);
if ($rule instanceof Filter\Condition) {
$newColumn = $this->popKey($values, $identifier . '-column-search');
if ($newColumn === null) {
$newColumn = $this->popKey($values, $identifier . '-column');
} else {
// Make sure we don't forget to present the column labels again
$rule->metaData()->set('columnLabel', $this->popKey($values, $identifier . '-column'));
}
if ($newColumn !== null && $rule->getColumn() !== $newColumn) {
$rule->setColumn($newColumn ?: static::FAKE_COLUMN);
// TODO: Clear meta data?
}
$newValue = $this->popKey($values, $identifier . '-value');
$oldValue = $rule->getValue();
if ($newValue !== null && $oldValue !== $newValue) {
$rule->setValue($newValue);
}
$newOperator = $this->popKey($values, $identifier . '-operator');
if ($newOperator !== null && QueryString::getRuleSymbol($rule) !== $newOperator) {
$value = $rule->getValue();
$column = $rule->getColumn();
switch ($newOperator) {
case '~':
return Filter::like($column, $value);
case '!~':
return Filter::unlike($column, $value);
case '=':
return Filter::equal($column, $value);
case '!=':
return Filter::unequal($column, $value);
case '>':
return Filter::greaterThan($column, $value);
case '>=':
return Filter::greaterThanOrEqual($column, $value);
case '<':
return Filter::lessThan($column, $value);
case '<=':
return Filter::lessThanOrEqual($column, $value);
}
}
$value = $rule->getValue();
if ($oldValue !== $value && is_string($value) && strpos($value, '*') !== false) {
if (QueryString::getRuleSymbol($rule) === '=') {
return Filter::like($rule->getColumn(), $value);
} elseif (QueryString::getRuleSymbol($rule) === '!=') {
return Filter::unlike($rule->getColumn(), $value);
}
}
} else {
/** @var Filter\Chain $rule */
$newGroupOperator = $this->popKey($values, $identifier);
$oldGroupOperator = $rule instanceof Filter\None ? '!' : QueryString::getRuleSymbol($rule);
if ($newGroupOperator !== null && $oldGroupOperator !== $newGroupOperator) {
switch ($newGroupOperator) {
case '&':
$rule = Filter::all(...$rule);
break;
case '|':
$rule = Filter::any(...$rule);
break;
case '!':
$rule = Filter::none(...$rule);
break;
}
}
$i = 0;
foreach ($rule as $child) {
$childPath = $path;
$childPath[] = $i++;
$newChild = $this->applyChanges($child, $values, $childPath);
if ($child !== $newChild) {
$rule->replace($child, $newChild);
}
}
}
return $rule;
}
protected function applyStructuralChange(Filter\Rule $rule)
{
$structuralChange = $this->getPopulatedValue('structural-change');
if (empty($structuralChange)) {
return $rule;
} elseif (is_array($structuralChange)) {
ksort($structuralChange);
}
list($type, $where) = explode(':', is_array($structuralChange)
? array_shift($structuralChange)
: $structuralChange);
$targetPath = explode('-', substr($where, 5));
$targetFinder = function ($path) use ($rule) {
$parent = null;
$target = null;
$children = [$rule];
foreach ($path as $targetPos) {
if ($target !== null) {
$parent = $target;
$children = $parent instanceof Filter\Chain
? iterator_to_array($parent)
: [];
}
if (! isset($children[$targetPos])) {
return [null, null];
}
$target = $children[$targetPos];
}
return [$parent, $target];
};
list($parent, $target) = $targetFinder($targetPath);
if ($target === null) {
return $rule;
}
$emptyEqual = Filter::equal(static::FAKE_COLUMN, '');
switch ($type) {
case 'move-rule':
if (! is_array($structuralChange) || empty($structuralChange)) {
return $rule;
}
list($placement, $moveToPath) = explode(':', array_shift($structuralChange));
list($moveToParent, $moveToTarget) = $targetFinder(explode('-', substr($moveToPath, 5)));
$parent->remove($target);
if ($placement === 'to') {
$moveToTarget->add($target);
} elseif ($placement === 'before') {
$moveToParent->insertBefore($target, $moveToTarget);
} else {
$moveToParent->insertAfter($target, $moveToTarget);
}
break;
case 'add-condition':
$target->add($emptyEqual);
break;
case 'add-group':
$target->add(Filter::all($emptyEqual));
break;
case 'wrap-rule':
if ($parent !== null) {
$parent->replace($target, Filter::all($target));
} else {
$rule = Filter::all($target);
}
break;
case 'drop-rule':
if ($parent !== null) {
$parent->remove($target);
} else {
$rule = $emptyEqual;
}
break;
case 'clear':
$this->cleared = true;
$rule = null;
}
return $rule;
}
protected function createTree(Filter\Rule $rule, array $path = [0])
{
$identifier = 'rule-' . join('-', $path);
if ($rule instanceof Filter\Condition) {
$parts = [$this->createCondition($rule, $identifier), $this->createButtons($rule, $identifier)];
if (count($path) === 1) {
$item = new HtmlElement('ol', null, new HtmlElement(
'li',
Attributes::create(['id' => $identifier]),
...$parts
));
} else {
array_splice($parts, 1, 0, [
new Icon('bars', ['class' => 'drag-initiator'])
]);
$item = (new HtmlDocument())->addHtml(...$parts);
}
} else {
/** @var Filter\Chain $rule */
$item = new HtmlElement('ul');
$groupOperatorInput = $this->createElement('select', $identifier, [
'options' => [
'&' => 'ALL',
'|' => 'ANY',
'!' => 'NONE'
],
'value' => $rule instanceof Filter\None ? '!' : QueryString::getRuleSymbol($rule)
]);
$this->registerElement($groupOperatorInput);
$item->addHtml(HtmlElement::create('li', ['id' => $identifier], [
$groupOperatorInput,
count($path) > 1
? new Icon('bars', ['class' => 'drag-initiator'])
: null,
$this->createButtons($rule, $identifier)
]));
$children = new HtmlElement('ol');
$item->addHtml(new HtmlElement('li', null, $children));
$i = 0;
foreach ($rule as $child) {
$childPath = $path;
$childPath[] = $i++;
$children->addHtml(new HtmlElement(
'li',
Attributes::create([
'id' => 'rule-' . join('-', $childPath),
'class' => $child instanceof Filter\Condition
? 'filter-condition'
: 'filter-chain'
]),
$this->createTree($child, $childPath)
));
}
}
return $item;
}
protected function createButtons(Filter\Rule $for, $identifier)
{
$buttons = [];
if ($for instanceof Filter\Chain) {
$buttons[] = $this->createElement('submitButton', 'structural-change', [
'value' => 'add-condition:' . $identifier,
'label' => t('Add Condition', 'to a group of filter conditions'),
'formnovalidate' => true
]);
$buttons[] = $this->createElement('submitButton', 'structural-change', [
'value' => 'add-group:' . $identifier,
'label' => t('Add Group', 'of filter conditions'),
'formnovalidate' => true
]);
}
$buttons[] = $this->createElement('submitButton', 'structural-change', [
'value' => 'wrap-rule:' . $identifier,
'label' => t('Wrap in Group', 'a filter rule'),
'formnovalidate' => true
]);
$buttons[] = $this->createElement('submitButton', 'structural-change', [
'value' => 'drop-rule:' . $identifier,
'label' => t('Delete', 'a filter rule'),
'formnovalidate' => true
]);
$ul = new HtmlElement('ul');
foreach ($buttons as $button) {
$ul->addHtml(new HtmlElement('li', null, $button));
}
return new HtmlElement(
'div',
Attributes::create(['class' => 'buttons']),
$ul,
new Icon('ellipsis-h')
);
}
protected function createCondition(Filter\Condition $condition, $identifier)
{
$columnInput = $this->createElement('text', $identifier . '-column', [
'value' => $condition->metaData()->get(
'columnLabel',
$condition->getColumn() !== static::FAKE_COLUMN
? $condition->getColumn()
: null
),
'title' => $condition->getColumn() !== static::FAKE_COLUMN
? $condition->getColumn()
: null,
'required' => true,
'autocomplete' => 'off',
'data-type' => 'column',
'data-enrichment-type' => 'completion',
'data-term-suggestions' => '#search-editor-suggestions'
]);
$columnInput->getAttributes()->registerAttributeCallback('data-suggest-url', function () {
return (string) $this->getSuggestionUrl();
});
(new CallbackDecorator(function ($element) {
$errors = new HtmlElement('ul', Attributes::create(['class' => 'search-errors']));
foreach ($element->getMessages() as $message) {
$errors->addHtml(new HtmlElement('li', null, Text::create($message)));
}
if (! $errors->isEmpty()) {
if (trim($element->getValue())) {
$element->getAttributes()->add(
'pattern',
sprintf(
'^\s*(?!%s\b).*\s*$',
$element->getValue()
)
);
}
$element->prependWrapper(new HtmlElement(
'div',
Attributes::create(['class' => 'search-error']),
$element,
$errors
));
}
}))->decorate($columnInput);
$columnFakeInput = $this->createElement('hidden', $identifier . '-column-search', [
'value' => static::FAKE_COLUMN
]);
$columnSearchInput = $this->createElement('hidden', $identifier . '-column-search', [
'value' => $condition->getColumn() !== static::FAKE_COLUMN
? $condition->getColumn()
: null,
'validators' => ['Callback' => function ($value) use ($condition, $columnInput, &$columnSearchInput) {
if (! $this->hasBeenSubmitted()) {
return true;
}
try {
$this->emit(static::ON_VALIDATE_COLUMN, [$condition]);
} catch (SearchException $e) {
$columnInput->addMessage($e->getMessage());
return false;
}
$columnSearchInput->setValue($condition->getColumn());
$columnInput->setValue($condition->metaData()->get('columnLabel', $condition->getColumn()));
return true;
}]
]);
$operatorInput = $this->createElement('select', $identifier . '-operator', [
'options' => [
'~' => '~',
'!~' => '!~',
'=' => '=',
'!=' => '!=',
'>' => '>',
'<' => '<',
'>=' => '>=',
'<=' => '<='
],
'value' => QueryString::getRuleSymbol($condition)
]);
$valueInput = $this->createElement('text', $identifier . '-value', [
'value' => $condition->getValue(),
'autocomplete' => 'off',
'data-type' => 'value',
'data-enrichment-type' => 'completion',
'data-term-suggestions' => '#search-editor-suggestions'
]);
$valueInput->getAttributes()->registerAttributeCallback('data-suggest-url', function () {
return (string) $this->getSuggestionUrl();
});
$this->registerElement($columnInput);
$this->registerElement($columnSearchInput);
$this->registerElement($operatorInput);
$this->registerElement($valueInput);
return new HtmlElement(
'fieldset',
Attributes::create(['name' => $identifier . '-']),
$columnInput,
$columnFakeInput,
$columnSearchInput,
$operatorInput,
$valueInput
);
}
protected function assemble()
{
$filterInput = $this->createElement('hidden', 'filter');
$filterInput->getAttributes()->registerAttributeCallback(
'value',
function () {
return $this->queryString ?: static::FAKE_COLUMN;
},
[$this, 'setQueryString']
);
$this->addElement($filterInput);
$filter = $this->getFilter();
if ($filter instanceof Filter\Chain && $filter->isEmpty()) {
$filter = Filter::equal('', '');
}
$this->addHtml($this->createTree($filter));
$this->addHtml(new HtmlElement('div', Attributes::create([
'id' => 'search-editor-suggestions',
'class' => 'search-suggestions'
])));
if ($this->queryString) {
$this->addHtml($this->createElement('submitButton', 'structural-change', [
'value' => 'clear:rule-0',
'class' => 'cancel-button',
'label' => t('Clear Filter'),
'formnovalidate' => true
]));
}
$this->addElement('submit', 'btn_submit', [
'label' => t('Apply')
]);
// Add submit button also as first element to make Web 2 submit
// the form instead of using a structural change to submit if
// the user just presses Enter.
$this->prepend($this->getElement('btn_submit'));
}
private function popKey(array &$from, $key, $default = null)
{
if (isset($from[$key])) {
$value = $from[$key];
unset($from[$key]);
return $value;
}
return $default;
}
}