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