1<?php 2/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ 3 4namespace Icinga\Web\Widget; 5 6use Icinga\Data\Filterable; 7use Icinga\Data\FilterColumns; 8use Icinga\Data\Filter\Filter; 9use Icinga\Data\Filter\FilterExpression; 10use Icinga\Data\Filter\FilterChain; 11use Icinga\Data\Filter\FilterOr; 12use Icinga\Web\Url; 13use Icinga\Application\Icinga; 14use Icinga\Exception\ProgrammingError; 15use Icinga\Web\Notification; 16use Exception; 17 18/** 19 * Filter 20 */ 21class FilterEditor extends AbstractWidget 22{ 23 /** 24 * The filter 25 * 26 * @var Filter 27 */ 28 private $filter; 29 30 /** 31 * The query to filter 32 * 33 * @var Filterable 34 */ 35 protected $query; 36 37 protected $url; 38 39 protected $addTo; 40 41 protected $cachedColumnSelect; 42 43 protected $preserveParams = array(); 44 45 protected $preservedParams = array(); 46 47 protected $preservedUrl; 48 49 protected $ignoreParams = array(); 50 51 protected $searchColumns; 52 53 /** 54 * @var string 55 */ 56 private $selectedIdx; 57 58 /** 59 * Whether the filter control is visible 60 * 61 * @var bool 62 */ 63 protected $visible = true; 64 65 /** 66 * Create a new FilterWidget 67 * 68 * @param Filter $filter Your filter 69 */ 70 public function __construct($props) 71 { 72 if (array_key_exists('filter', $props)) { 73 $this->setFilter($props['filter']); 74 } 75 if (array_key_exists('query', $props)) { 76 $this->setQuery($props['query']); 77 } 78 } 79 80 public function setFilter(Filter $filter) 81 { 82 $this->filter = $filter; 83 return $this; 84 } 85 86 public function getFilter() 87 { 88 if ($this->filter === null) { 89 $this->filter = Filter::fromQueryString((string) $this->url()->getParams()); 90 } 91 return $this->filter; 92 } 93 94 /** 95 * Set columns to search in 96 * 97 * @param array $searchColumns 98 * 99 * @return $this 100 */ 101 public function setSearchColumns(array $searchColumns = null) 102 { 103 $this->searchColumns = $searchColumns; 104 return $this; 105 } 106 107 public function setUrl($url) 108 { 109 $this->url = $url; 110 return $this; 111 } 112 113 protected function url() 114 { 115 if ($this->url === null) { 116 $this->url = Url::fromRequest(); 117 } 118 return $this->url; 119 } 120 121 protected function preservedUrl() 122 { 123 if ($this->preservedUrl === null) { 124 $this->preservedUrl = $this->url()->with($this->preservedParams); 125 } 126 return $this->preservedUrl; 127 } 128 129 /** 130 * Set the query to filter 131 * 132 * @param Filterable $query 133 * 134 * @return $this 135 */ 136 public function setQuery(Filterable $query) 137 { 138 $this->query = $query; 139 return $this; 140 } 141 142 public function ignoreParams() 143 { 144 $this->ignoreParams = func_get_args(); 145 return $this; 146 } 147 148 public function preserveParams() 149 { 150 $this->preserveParams = func_get_args(); 151 return $this; 152 } 153 154 /** 155 * Get whether the filter control is visible 156 * 157 * @return bool 158 */ 159 public function isVisible() 160 { 161 return $this->visible; 162 } 163 164 /** 165 * Set whether the filter control is visible 166 * 167 * @param bool $visible 168 * 169 * @return $this 170 */ 171 public function setVisible($visible) 172 { 173 $this->visible = (bool) $visible; 174 175 return $this; 176 } 177 178 protected function redirectNow($url) 179 { 180 $response = Icinga::app()->getFrontController()->getResponse(); 181 $response->redirectAndExit($url); 182 } 183 184 protected function mergeRootExpression($filter, $column, $sign, $expression) 185 { 186 $found = false; 187 if ($filter->isChain() && $filter->getOperatorName() === 'AND') { 188 foreach ($filter->filters() as $f) { 189 if ($f->isExpression() 190 && $f->getColumn() === $column 191 && $f->getSign() === $sign 192 ) { 193 $f->setExpression($expression); 194 $found = true; 195 break; 196 } 197 } 198 } elseif ($filter->isExpression()) { 199 if ($filter->getColumn() === $column && $filter->getSign() === $sign) { 200 $filter->setExpression($expression); 201 $found = true; 202 } 203 } 204 if (! $found) { 205 $filter = $filter->andFilter( 206 Filter::expression($column, $sign, $expression) 207 ); 208 } 209 return $filter; 210 } 211 212 protected function resetSearchColumns(Filter &$filter) 213 { 214 if ($filter->isChain()) { 215 $filters = &$filter->filters(); 216 if (!($empty = empty($filters))) { 217 foreach ($filters as $k => &$f) { 218 if (false === $this->resetSearchColumns($f)) { 219 unset($filters[$k]); 220 } 221 } 222 } 223 return $empty || !empty($filters); 224 } 225 return $filter->isExpression() ? !( 226 in_array($filter->getColumn(), $this->searchColumns) 227 && 228 $filter->getSign() === '=' 229 ) : true; 230 } 231 232 public function handleRequest($request) 233 { 234 $this->setUrl($request->getUrl()->without($this->ignoreParams)); 235 $params = $this->url()->getParams(); 236 237 $preserve = array(); 238 foreach ($this->preserveParams as $key) { 239 if (null !== ($value = $params->shift($key))) { 240 $preserve[$key] = $value; 241 } 242 } 243 $this->preservedParams = $preserve; 244 245 $add = $params->shift('addFilter'); 246 $remove = $params->shift('removeFilter'); 247 $strip = $params->shift('stripFilter'); 248 $modify = $params->shift('modifyFilter'); 249 250 251 252 $search = null; 253 if ($request->isPost()) { 254 $search = $request->getPost('q'); 255 } 256 257 if ($search === null) { 258 $search = $params->shift('q'); 259 } 260 261 $filter = $this->getFilter(); 262 263 if ($search !== null) { 264 if (strpos($search, '=') !== false) { 265 list($k, $v) = preg_split('/=/', $search); 266 $filter = $this->mergeRootExpression($filter, trim($k), '=', ltrim($v)); 267 } else { 268 if ($this->searchColumns === null && $this->query instanceof FilterColumns) { 269 $this->searchColumns = $this->query->getSearchColumns($search); 270 } 271 272 if (! empty($this->searchColumns)) { 273 if (! $this->resetSearchColumns($filter)) { 274 $filter = Filter::matchAll(); 275 } 276 $filters = array(); 277 $search = trim($search); 278 foreach ($this->searchColumns as $searchColumn) { 279 $filters[] = Filter::expression($searchColumn, '=', "*$search*"); 280 } 281 $filter = $filter->andFilter(new FilterOr($filters)); 282 } else { 283 Notification::error(mt('monitoring', 'Cannot search here')); 284 return $this; 285 } 286 } 287 288 $url = Url::fromRequest()->onlyWith($this->preserveParams); 289 $urlParams = $url->getParams(); 290 $url->setQueryString($filter->toQueryString()); 291 $url->getParams()->mergeValues($urlParams->toArray(false)); 292 $this->redirectNow($url); 293 } 294 295 if ($remove) { 296 $redirect = $this->url(); 297 if ($filter->getById($remove)->isRootNode()) { 298 $redirect->setQueryString(''); 299 } else { 300 $filter->removeId($remove); 301 $redirect->setQueryString($filter->toQueryString())->getParams()->add('modifyFilter'); 302 } 303 $this->redirectNow($redirect->addParams($preserve)); 304 } 305 306 if ($strip) { 307 $redirect = $this->url(); 308 $subId = $strip . '-1'; 309 if ($filter->getId() === $strip) { 310 $filter = $filter->getById($strip . '-1'); 311 } else { 312 $filter->replaceById($strip, $filter->getById($strip . '-1')); 313 } 314 $redirect->setQueryString($filter->toQueryString())->getParams()->add('modifyFilter'); 315 $this->redirectNow($redirect->addParams($preserve)); 316 } 317 318 319 if ($modify) { 320 if ($request->isPost()) { 321 if ($request->get('cancel') === 'Cancel') { 322 $this->redirectNow($this->preservedUrl()->without('modifyFilter')); 323 } 324 if ($request->get('formUID') === 'FilterEditor') { 325 $filter = $this->applyChanges($request->getPost()); 326 $url = $this->url()->setQueryString($filter->toQueryString())->addParams($preserve); 327 $url->getParams()->add('modifyFilter'); 328 329 $addFilter = $request->get('add_filter'); 330 if ($addFilter !== null) { 331 $url->setParam('addFilter', $addFilter); 332 } 333 334 $removeFilter = $request->get('remove_filter'); 335 if ($removeFilter !== null) { 336 $url->setParam('removeFilter', $removeFilter); 337 } 338 339 $this->redirectNow($url); 340 } 341 } 342 $this->url()->getParams()->add('modifyFilter'); 343 } 344 345 if ($add) { 346 $this->addFilterToId($add); 347 } 348 349 if ($this->query !== null && $request->isGet()) { 350 $this->query->applyFilter($this->getFilter()); 351 } 352 353 return $this; 354 } 355 356 protected function select($name, $list, $selected, $attributes = null) 357 { 358 $view = $this->view(); 359 if ($attributes === null) { 360 $attributes = ''; 361 } else { 362 $attributes = $view->propertiesToString($attributes); 363 } 364 $html = sprintf( 365 '<select name="%s"%s class="autosubmit">' . "\n", 366 $view->escape($name), 367 $attributes 368 ); 369 370 foreach ($list as $k => $v) { 371 $active = ''; 372 if ($k === $selected) { 373 $active = ' selected="selected"'; 374 } 375 $html .= sprintf( 376 ' <option value="%s"%s>%s</option>' . "\n", 377 $view->escape($k), 378 $active, 379 $view->escape($v) 380 ); 381 } 382 $html .= '</select>' . "\n\n"; 383 return $html; 384 } 385 386 protected function addFilterToId($id) 387 { 388 $this->addTo = $id; 389 return $this; 390 } 391 392 protected function removeIndex($idx) 393 { 394 $this->selectedIdx = $idx; 395 return $this; 396 } 397 398 protected function removeLink(Filter $filter) 399 { 400 return "<button type='submit' name='remove_filter' value='{$filter->getId()}'>" 401 . $this->view()->icon('trash', t('Remove this part of your filter')) 402 . '</button>'; 403 } 404 405 protected function addLink(Filter $filter) 406 { 407 return "<button type='submit' name='add_filter' value='{$filter->getId()}'>" 408 . $this->view()->icon('plus', t('Add another filter')) 409 . '</button>'; 410 } 411 412 protected function stripLink(Filter $filter) 413 { 414 return $this->view()->qlink( 415 '', 416 $this->preservedUrl()->with('stripFilter', $filter->getId()), 417 null, 418 array( 419 'icon' => 'minus', 420 'title' => t('Strip this filter') 421 ) 422 ); 423 } 424 425 protected function cancelLink() 426 { 427 return $this->view()->qlink( 428 '', 429 $this->preservedUrl()->without('addFilter'), 430 null, 431 array( 432 'icon' => 'cancel', 433 'title' => t('Cancel this operation') 434 ) 435 ); 436 } 437 438 protected function renderFilter($filter, $level = 0) 439 { 440 if ($level === 0 && $filter->isChain() && $filter->isEmpty()) { 441 return '<ul class="datafilter"><li class="active">' . $this->renderNewFilter() . '</li></ul>'; 442 } 443 444 if ($filter instanceof FilterChain) { 445 return $this->renderFilterChain($filter, $level); 446 } elseif ($filter instanceof FilterExpression) { 447 return $this->renderFilterExpression($filter); 448 } else { 449 throw new ProgrammingError('Got a Filter being neither expression nor chain'); 450 } 451 } 452 453 protected function renderFilterChain(FilterChain $filter, $level) 454 { 455 $html = '<span class="handle"> </span>' 456 . $this->selectOperator($filter) 457 . $this->removeLink($filter) 458 . ($filter->count() === 1 ? $this->stripLink($filter) : '') 459 . $this->addLink($filter); 460 461 if ($filter->isEmpty() && ! $this->addTo) { 462 return $html; 463 } 464 465 $parts = array(); 466 foreach ($filter->filters() as $f) { 467 $parts[] = '<li>' . $this->renderFilter($f, $level + 1) . '</li>'; 468 } 469 470 if ($this->addTo && $this->addTo == $filter->getId()) { 471 $parts[] = '<li style="background: #ffb">' . $this->renderNewFilter() .$this->cancelLink(). '</li>'; 472 } 473 474 $class = $level === 0 ? ' class="datafilter"' : ''; 475 $html .= sprintf( 476 "<ul%s>\n%s</ul>\n", 477 $class, 478 implode("", $parts) 479 ); 480 return $html; 481 } 482 483 protected function renderFilterExpression(FilterExpression $filter) 484 { 485 if ($this->addTo && $this->addTo === $filter->getId()) { 486 return 487 preg_replace( 488 '/ class="autosubmit"/', 489 ' class="autofocus"', 490 $this->selectOperator() 491 ) 492 . '<ul><li>' 493 . $this->selectColumn($filter) 494 . $this->selectSign($filter) 495 . $this->text($filter) 496 . $this->removeLink($filter) 497 . $this->addLink($filter) 498 . '</li><li class="active">' 499 . $this->renderNewFilter() .$this->cancelLink() 500 . '</li></ul>' 501 ; 502 } else { 503 return $this->selectColumn($filter) 504 . $this->selectSign($filter) 505 . $this->text($filter) 506 . $this->removeLink($filter) 507 . $this->addLink($filter) 508 ; 509 } 510 } 511 512 protected function text(Filter $filter = null) 513 { 514 $value = $filter === null ? '' : $filter->getExpression(); 515 if (is_array($value)) { 516 $value = '(' . implode('|', $value) . ')'; 517 } 518 return sprintf( 519 '<input type="text" name="%s" value="%s" />', 520 $this->elementId('value', $filter), 521 $this->view()->escape($value) 522 ); 523 } 524 525 protected function renderNewFilter() 526 { 527 $html = $this->selectColumn() 528 . $this->selectSign() 529 . $this->text(); 530 531 return preg_replace( 532 '/ class="autosubmit"/', 533 '', 534 $html 535 ); 536 } 537 538 protected function arrayForSelect($array, $flip = false) 539 { 540 $res = array(); 541 foreach ($array as $k => $v) { 542 if (is_int($k)) { 543 $res[$v] = ucwords(str_replace('_', ' ', $v)); 544 } elseif ($flip) { 545 $res[$v] = $k; 546 } else { 547 $res[$k] = $v; 548 } 549 } 550 // sort($res); 551 return $res; 552 } 553 554 protected function elementId($prefix, Filter $filter = null) 555 { 556 if ($filter === null) { 557 return $prefix . '_new_' . ($this->addTo ?: '0'); 558 } else { 559 return $prefix . '_' . $filter->getId(); 560 } 561 } 562 563 protected function selectOperator(Filter $filter = null) 564 { 565 $ops = array( 566 'AND' => 'AND', 567 'OR' => 'OR', 568 'NOT' => 'NOT' 569 ); 570 571 return $this->select( 572 $this->elementId('operator', $filter), 573 $ops, 574 $filter === null ? null : $filter->getOperatorName(), 575 array('style' => 'width: 5em') 576 ); 577 } 578 579 protected function selectSign(Filter $filter = null) 580 { 581 $signs = array( 582 '=' => '=', 583 '!=' => '!=', 584 '>' => '>', 585 '<' => '<', 586 '>=' => '>=', 587 '<=' => '<=', 588 ); 589 590 return $this->select( 591 $this->elementId('sign', $filter), 592 $signs, 593 $filter === null ? null : $filter->getSign(), 594 array('style' => 'width: 4em') 595 ); 596 } 597 598 public function setColumns(array $columns = null) 599 { 600 $this->cachedColumnSelect = $columns ? $this->arrayForSelect($columns) : null; 601 return $this; 602 } 603 604 protected function selectColumn(Filter $filter = null) 605 { 606 $active = $filter === null ? null : $filter->getColumn(); 607 608 if ($this->cachedColumnSelect === null && $this->query === null) { 609 return sprintf( 610 '<input type="text" name="%s" value="%s" />', 611 $this->elementId('column', $filter), 612 $this->view()->escape($active) // Escape attribute? 613 ); 614 } 615 616 if ($this->cachedColumnSelect === null && $this->query instanceof FilterColumns) { 617 $this->cachedColumnSelect = $this->arrayForSelect($this->query->getFilterColumns(), true); 618 asort($this->cachedColumnSelect); 619 } elseif ($this->cachedColumnSelect === null) { 620 throw new ProgrammingError('No columns set nor does the query provide any'); 621 } 622 623 $cols = $this->cachedColumnSelect; 624 if ($active && !isset($cols[$active])) { 625 $cols[$active] = str_replace('_', ' ', ucfirst(ltrim($active, '_'))); 626 } 627 628 return $this->select($this->elementId('column', $filter), $cols, $active); 629 } 630 631 protected function applyChanges($changes) 632 { 633 $filter = $this->filter; 634 $pairs = array(); 635 $addTo = null; 636 $add = array(); 637 foreach ($changes as $k => $v) { 638 if (preg_match('/^(column|value|sign|operator)((?:_new)?)_([\d-]+)$/', $k, $m)) { 639 if ($m[2] === '_new') { 640 if ($addTo !== null && $addTo !== $m[3]) { 641 throw new \Exception('F...U'); 642 } 643 $addTo = $m[3]; 644 $add[$m[1]] = $v; 645 } else { 646 $pairs[$m[3]][$m[1]] = $v; 647 } 648 } 649 } 650 651 $operators = array(); 652 foreach ($pairs as $id => $fs) { 653 if (array_key_exists('operator', $fs)) { 654 $operators[$id] = $fs['operator']; 655 } else { 656 $f = $filter->getById($id); 657 $f->setColumn($fs['column']); 658 if ($f->getSign() !== $fs['sign']) { 659 if ($f->isRootNode()) { 660 $filter = $f->setSign($fs['sign']); 661 } else { 662 $filter->replaceById($id, $f->setSign($fs['sign'])); 663 } 664 } 665 $f->setExpression($fs['value']); 666 } 667 } 668 669 krsort($operators, SORT_NATURAL); 670 foreach ($operators as $id => $operator) { 671 $f = $filter->getById($id); 672 if ($f->getOperatorName() !== $operator) { 673 if ($f->isRootNode()) { 674 $filter = $f->setOperatorName($operator); 675 } else { 676 $filter->replaceById($id, $f->setOperatorName($operator)); 677 } 678 } 679 } 680 681 if ($addTo !== null) { 682 if ($addTo === '0') { 683 $filter = Filter::expression($add['column'], $add['sign'], $add['value']); 684 } else { 685 $parent = $filter->getById($addTo); 686 $f = Filter::expression($add['column'], $add['sign'], $add['value']); 687 if (isset($add['operator'])) { 688 switch ($add['operator']) { 689 case 'AND': 690 if ($parent->isExpression()) { 691 if ($parent->isRootNode()) { 692 $filter = Filter::matchAll(clone $parent, $f); 693 } else { 694 $filter = $filter->replaceById($addTo, Filter::matchAll(clone $parent, $f)); 695 } 696 } else { 697 $parent->addFilter(Filter::matchAll($f)); 698 } 699 break; 700 case 'OR': 701 if ($parent->isExpression()) { 702 if ($parent->isRootNode()) { 703 $filter = Filter::matchAny(clone $parent, $f); 704 } else { 705 $filter = $filter->replaceById($addTo, Filter::matchAny(clone $parent, $f)); 706 } 707 } else { 708 $parent->addFilter(Filter::matchAny($f)); 709 } 710 break; 711 case 'NOT': 712 if ($parent->isExpression()) { 713 if ($parent->isRootNode()) { 714 $filter = Filter::not(Filter::matchAll($parent, $f)); 715 } else { 716 $filter = $filter->replaceById($addTo, Filter::not(Filter::matchAll($parent, $f))); 717 } 718 } else { 719 $parent->addFilter(Filter::not($f)); 720 } 721 break; 722 } 723 } else { 724 $parent->addFilter($f); 725 } 726 } 727 } 728 729 return $filter; 730 } 731 732 public function renderSearch() 733 { 734 $preservedUrl = $this->preservedUrl(); 735 736 $html = ' <form method="post" class="search inline" action="' 737 . $preservedUrl 738 . '"><input type="text" name="q" style="width: 8em" class="search" value="" placeholder="' 739 . t('Search...') 740 . '" /></form>'; 741 742 if ($this->filter->isEmpty()) { 743 $title = t('Filter this list'); 744 } else { 745 $title = t('Modify this filter'); 746 if (! $this->filter->isEmpty()) { 747 $title .= ': ' . $this->view()->escape($this->filter); 748 } 749 } 750 751 return $html 752 . '<a href="' 753 . $preservedUrl->with('modifyFilter', ! $preservedUrl->getParam('modifyFilter')) 754 . '" aria-label="' 755 . $title 756 . '" title="' 757 . $title 758 . '">' 759 . '<i aria-hidden="true" class="icon-filter"></i>' 760 . '</a>'; 761 } 762 763 public function render() 764 { 765 if (! $this->visible) { 766 return ''; 767 } 768 if (! $this->preservedUrl()->getParam('modifyFilter')) { 769 return '<div class="filter icinga-controls">' 770 . $this->renderSearch() 771 . $this->view()->escape($this->shorten($this->filter, 50)) 772 . '</div>'; 773 } 774 return '<div class="filter icinga-controls">' 775 . $this->renderSearch() 776 . '<form action="' 777 . Url::fromRequest() 778 . '" class="editor" method="POST">' 779 . '<input type="submit" name="submit" value="Apply" style="display:none;"/>' 780 . '<ul class="tree"><li>' 781 . $this->renderFilter($this->filter) 782 . '</li></ul>' 783 . '<div class="buttons">' 784 . '<input type="submit" name="cancel" value="Cancel" class="button btn-cancel" />' 785 . '<input type="submit" name="submit" value="Apply" class="button btn-primary"/>' 786 . '</div>' 787 . '<input type="hidden" name="formUID" value="FilterEditor">' 788 . '</form>' 789 . '</div>'; 790 } 791 792 protected function shorten($string, $length) 793 { 794 if (strlen($string) > $length) { 795 return substr($string, 0, $length) . '...'; 796 } 797 return $string; 798 } 799 800 public function __toString() 801 { 802 try { 803 return $this->render(); 804 } catch (Exception $e) { 805 return 'ERROR in FilterEditor: ' . $e->getMessage(); 806 } 807 } 808} 809