xref: /plugin/struct/meta/AggregationTable.php (revision a2672b004a6b66665da7e9702be5f9d728ba9694)
1<?php
2
3namespace dokuwiki\plugin\struct\meta;
4
5/**
6 * Creates the table aggregation output
7 *
8 * @package dokuwiki\plugin\struct\meta
9 */
10class AggregationTable
11{
12
13    /**
14     * @var string the page id of the page this is rendered to
15     */
16    protected $id;
17    /**
18     * @var string the Type of renderer used
19     */
20    protected $mode;
21    /**
22     * @var \Doku_Renderer the DokuWiki renderer used to create the output
23     */
24    protected $renderer;
25    /**
26     * @var SearchConfig the configured search - gives access to columns etc.
27     */
28    protected $searchConfig;
29
30    /**
31     * @var Column[] the list of columns to be displayed
32     */
33    protected $columns;
34
35    /**
36     * @var  Value[][] the search result
37     */
38    protected $result;
39
40    /**
41     * @var int number of all results
42     */
43    protected $resultCount;
44
45    /**
46     * @var string[] the result PIDs for each row
47     */
48    protected $resultPIDs;
49    protected $resultRids;
50    protected $resultRevs;
51
52    /**
53     * @var array for summing up columns
54     */
55    protected $sums;
56
57    /**
58     * @var bool skip full table when no results found
59     */
60    protected $simplenone = true;
61
62    /**
63     * @todo we might be able to get rid of this helper and move this to SearchConfig
64     * @var \helper_plugin_struct_config
65     */
66    protected $helper;
67
68    /**
69     * Initialize the Aggregation renderer and executes the search
70     *
71     * You need to call @see render() on the resulting object.
72     *
73     * @param string $id
74     * @param string $mode
75     * @param \Doku_Renderer $renderer
76     * @param SearchConfig $searchConfig
77     */
78    public function __construct($id, $mode, \Doku_Renderer $renderer, SearchConfig $searchConfig)
79    {
80        $this->id = $id;
81        $this->mode = $mode;
82        $this->renderer = $renderer;
83        $this->searchConfig = $searchConfig;
84        $this->data = $searchConfig->getConf();
85        $this->columns = $searchConfig->getColumns();
86
87        $this->result = $this->searchConfig->execute();
88        $this->resultCount = $this->searchConfig->getCount();
89        $this->resultPIDs = $this->searchConfig->getPids();
90        $this->resultRids = $this->searchConfig->getRids();
91        $this->resultRevs = $this->searchConfig->getRevs();
92        $this->helper = plugin_load('helper', 'struct_config');
93    }
94
95    /**
96     * Returns the page id for the table
97     */
98    public function getID()
99    {
100        return $this->id;
101    }
102
103    /**
104     * Create the table on the renderer
105     */
106    public function render()
107    {
108
109        // abort early if there are no results at all (not filtered)
110        if (!$this->resultCount && !$this->isDynamicallyFiltered() && $this->simplenone) {
111            $this->startScope();
112            $this->renderer->cdata($this->helper->getLang('none'));
113            $this->finishScope();
114            return;
115        }
116
117        $this->startScope();
118        $this->renderActiveFilters();
119
120        $rendercontext = array(
121            'table' => $this,
122            'renderer' => $this->renderer,
123            'search' => $this->searchConfig,
124            'columns' => $this->columns,
125            'data' => $this->result
126        );
127
128        \Doku_Event::createAndTrigger(
129            'PLUGIN_STRUCT_RENDER_AGGREGATION_TABLE',
130            $rendercontext,
131            array($this, 'renderTable')
132        );
133
134        // export handle
135        $this->renderExportControls();
136        $this->finishScope();
137    }
138
139    /**
140     * Render the default aggregation table
141     */
142    public function renderTable($rendercontext)
143    {
144        $this->renderer->table_open();
145
146        // header
147        $this->renderer->tablethead_open();
148        $this->renderColumnHeaders();
149        $this->renderDynamicFilters();
150        $this->renderer->tablethead_close();
151
152        if ($this->resultCount) {
153            // actual data
154            $this->renderer->tabletbody_open();
155            $this->renderResult();
156            $this->renderer->tabletbody_close();
157
158            // footer (tfoot is develonly currently)
159            if (method_exists($this->renderer, 'tabletfoot_open')) $this->renderer->tabletfoot_open();
160            $this->renderSums();
161            $this->renderPagingControls();
162            if (method_exists($this->renderer, 'tabletfoot_close')) $this->renderer->tabletfoot_close();
163        } else {
164            // nothing found
165            $this->renderEmptyResult();
166        }
167
168        // table close
169        $this->renderer->table_close();
170    }
171
172    /**
173     * Adds additional info to document and renderer in XHTML mode
174     *
175     * @see finishScope()
176     */
177    protected function startScope()
178    {
179        // unique identifier for this aggregation
180        $this->renderer->info['struct_table_hash'] = md5(var_export($this->data, true));
181
182        // wrapping div
183        if ($this->mode != 'xhtml') return;
184        $this->renderer->doc .= "<div class=\"structaggregation\">";
185    }
186
187    /**
188     * Closes the table and anything opened in startScope()
189     *
190     * @see startScope()
191     */
192    protected function finishScope()
193    {
194        // remove identifier from renderer again
195        if (isset($this->renderer->info['struct_table_hash'])) {
196            unset($this->renderer->info['struct_table_hash']);
197        }
198
199        // wrapping div
200        if ($this->mode != 'xhtml') return;
201        $this->renderer->doc .= '</div>';
202    }
203
204    /**
205     * Displays info about the currently applied filters
206     */
207    protected function renderActiveFilters()
208    {
209        if ($this->mode != 'xhtml') return;
210        $dynamic = $this->searchConfig->getDynamicParameters();
211        $filters = $dynamic->getFilters();
212        if (!$filters) return;
213
214        $fltrs = array();
215        foreach ($filters as $column => $filter) {
216            list($comp, $value) = $filter;
217
218            // display the filters in a human readable format
219            foreach ($this->columns as $col) {
220                if ($column === $col->getFullQualifiedLabel()) {
221                    $column = $col->getTranslatedLabel();
222                }
223            }
224            $fltrs[] = sprintf('"%s" %s "%s"', $column, $this->helper->getLang("comparator $comp"), $value);
225        }
226
227        $this->renderer->doc .= '<div class="filter">';
228        $this->renderer->doc .= '<h4>' . sprintf($this->helper->getLang('tablefilteredby'), hsc(implode(' & ', $fltrs))) . '</h4>';
229        $this->renderer->doc .= '<div class="resetfilter">';
230        $this->renderer->internallink($this->id, $this->helper->getLang('tableresetfilter'));
231        $this->renderer->doc .= '</div>';
232        $this->renderer->doc .= '</div>';
233    }
234
235    /**
236     * Shows the column headers with links to sort by column
237     */
238    protected function renderColumnHeaders()
239    {
240        $this->renderer->tablerow_open();
241
242        // additional column for row numbers
243        if (!empty($this->data['rownumbers'])) {
244            $this->renderer->tableheader_open();
245            $this->renderer->cdata('#');
246            $this->renderer->tableheader_close();
247        }
248
249        // show all headers
250        foreach ($this->columns as $num => $column) {
251            $header = '';
252            if (isset($this->data['headers'][$num])) {
253                $header = $this->data['headers'][$num];
254            }
255
256            // use field label if no header was set
257            if (blank($header)) {
258                if (is_a($column, 'dokuwiki\plugin\struct\meta\Column')) {
259                    $header = $column->getTranslatedLabel();
260                } else {
261                    $header = 'column ' . $num; // this should never happen
262                }
263            }
264
265            // simple mode first
266            if ($this->mode != 'xhtml') {
267                $this->renderer->tableheader_open();
268                $this->renderer->cdata($header);
269                $this->renderer->tableheader_close();
270                continue;
271            }
272
273            // still here? create custom header for more flexibility
274
275            // width setting, widths are prevalidated, no escape needed
276            $width = '';
277            if (isset($this->data['widths'][$num]) && $this->data['widths'][$num] != '-') {
278                $width = ' style="min-width: ' . $this->data['widths'][$num] . ';' .
279                         'max-width: ' . $this->data['widths'][$num] . ';"';
280            }
281
282            // prepare data attribute for inline edits
283            if (
284                !is_a($column, '\dokuwiki\plugin\struct\meta\PageColumn') &&
285                !is_a($column, '\dokuwiki\plugin\struct\meta\RevisionColumn')
286            ) {
287                $data = 'data-field="' . hsc($column->getFullQualifiedLabel()) . '"';
288            } else {
289                $data = '';
290            }
291
292            // sort indicator and link
293            $sortclass = '';
294            $sorts = $this->searchConfig->getSorts();
295            $dynamic = $this->searchConfig->getDynamicParameters();
296            $dynamic->setSort($column, true);
297            if (isset($sorts[$column->getFullQualifiedLabel()])) {
298                list(/*colname*/, $currentSort) = $sorts[$column->getFullQualifiedLabel()];
299                if ($currentSort) {
300                    $sortclass = 'sort-down';
301                    $dynamic->setSort($column, false);
302                } else {
303                    $sortclass = 'sort-up';
304                }
305            }
306            $link = wl($this->id, $dynamic->getURLParameters());
307
308            // output XHTML header
309            $this->renderer->doc .= "<th $width $data>";
310            $this->renderer->doc .= '<a href="' . $link . '" class="' . $sortclass . '" title="' . $this->helper->getLang('sort') . '">' . hsc($header) . '</a>';
311            $this->renderer->doc .= '</th>';
312        }
313
314        $this->renderer->tablerow_close();
315    }
316
317    /**
318     * Is the result set currently dynamically filtered?
319     * @return bool
320     */
321    protected function isDynamicallyFiltered()
322    {
323        if ($this->mode != 'xhtml') return false;
324        if (!$this->data['dynfilters']) return false;
325
326        $dynamic = $this->searchConfig->getDynamicParameters();
327        return (bool) $dynamic->getFilters();
328    }
329
330    /**
331     * Add input fields for dynamic filtering
332     */
333    protected function renderDynamicFilters()
334    {
335        if ($this->mode != 'xhtml') return;
336        if (!$this->data['dynfilters']) return;
337        if (is_a($this->renderer, 'renderer_plugin_dw2pdf')) {
338            return;
339        }
340        global $conf;
341
342        $this->renderer->doc .= '<tr class="dataflt">';
343
344        // add extra column for row numbers
345        if ($this->data['rownumbers']) {
346            $this->renderer->doc .= '<th></th>';
347        }
348
349        // each column gets a form
350        foreach ($this->columns as $column) {
351            $this->renderer->doc .= '<th>';
352            {
353                $form = new \Doku_Form(array('method' => 'GET', 'action' => wl($this->id)));
354                unset($form->_hidden['sectok']); // we don't need it here
355                if (!$conf['userewrite']) $form->addHidden('id', $this->id);
356
357                // current value
358                $dynamic = $this->searchConfig->getDynamicParameters();
359                $filters = $dynamic->getFilters();
360            if (isset($filters[$column->getFullQualifiedLabel()])) {
361                list(, $current) = $filters[$column->getFullQualifiedLabel()];
362                $dynamic->removeFilter($column);
363            } else {
364                $current = '';
365            }
366
367                // Add current request params
368                $params = $dynamic->getURLParameters();
369            foreach ($params as $key => $val) {
370                $form->addHidden($key, $val);
371            }
372
373                // add input field
374                $key = $column->getFullQualifiedLabel() . $column->getType()->getDefaultComparator();
375                $form->addElement(form_makeField('text', SearchConfigParameters::$PARAM_FILTER . '[' . $key . ']', $current, ''));
376                $this->renderer->doc .= $form->getForm();
377            }
378            $this->renderer->doc .= '</th>';
379        }
380        $this->renderer->doc .= '</tr>';
381    }
382
383    /**
384     * Display the actual table data
385     */
386    protected function renderResult()
387    {
388        foreach ($this->result as $rownum => $row) {
389            $data = array(
390                'id' => $this->id,
391                'mode' => $this->mode,
392                'renderer' => $this->renderer,
393                'searchConfig' => $this->searchConfig,
394                'data' => $this->data,
395                'rownum' => &$rownum,
396                'row' => &$row,
397            );
398            $evt = new \Doku_Event('PLUGIN_STRUCT_AGGREGATIONTABLE_RENDERRESULTROW', $data);
399            if ($evt->advise_before()) {
400                $this->renderResultRow($rownum, $row);
401            }
402            $evt->advise_after();
403        }
404    }
405
406    /**
407     * Render a single result row
408     *
409     * @param int $rownum
410     * @param array $row
411     */
412    protected function renderResultRow($rownum, $row)
413    {
414        $this->renderer->tablerow_open();
415
416        // add data attribute for inline edit
417        if ($this->mode == 'xhtml') {
418            $pid = $this->resultPIDs[$rownum];
419            $rid = $this->resultRids[$rownum];
420            $rev = $this->resultRevs[$rownum];
421            $this->renderer->doc = substr(rtrim($this->renderer->doc), 0, -1); // remove closing '>'
422            $this->renderer->doc .= ' data-pid="' . hsc($pid) . '" data-rev="' . $rev . '" data-rid="' . $rid . '">';
423        }
424
425        // row number column
426        if (!empty($this->data['rownumbers'])) {
427            $this->renderer->tablecell_open();
428            $searchConfigConf = $this->searchConfig->getConf();
429            $this->renderer->cdata($rownum + $searchConfigConf['offset'] + 1);
430            $this->renderer->tablecell_close();
431        }
432
433        /** @var Value $value */
434        foreach ($row as $colnum => $value) {
435            $align = isset($this->data['align'][$colnum]) ?  $this->data['align'][$colnum] : null;
436            $this->renderer->tablecell_open(1, $align);
437            $value->render($this->renderer, $this->mode);
438            $this->renderer->tablecell_close();
439
440            // summarize
441            if ($this->data['summarize'] && is_numeric($value->getValue())) {
442                if (!isset($this->sums[$colnum])) {
443                    $this->sums[$colnum] = 0;
444                }
445                $this->sums[$colnum] += $value->getValue();
446            }
447        }
448        $this->renderer->tablerow_close();
449    }
450
451    /**
452     * Renders an information row for when no results were found
453     */
454    protected function renderEmptyResult()
455    {
456        $this->renderer->tablerow_open();
457        $this->renderer->tablecell_open(count($this->columns) + $this->data['rownumbers'], 'center');
458        $this->renderer->cdata($this->helper->getLang('none'));
459        $this->renderer->tablecell_close();
460        $this->renderer->tablerow_close();
461    }
462
463    /**
464     * Add sums if wanted
465     */
466    protected function renderSums()
467    {
468        if (empty($this->data['summarize'])) return;
469
470        $this->renderer->info['struct_table_meta'] = true;
471        if ($this->mode == 'xhtml') {
472            /** @noinspection PhpMethodParametersCountMismatchInspection */
473            $this->renderer->tablerow_open('summarize');
474        } else {
475            $this->renderer->tablerow_open();
476        }
477
478        if ($this->data['rownumbers']) {
479            $this->renderer->tableheader_open();
480            $this->renderer->tableheader_close();
481        }
482
483        $len = count($this->columns);
484        for ($i = 0; $i < $len; $i++) {
485            $this->renderer->tableheader_open(1, $this->data['align'][$i]);
486            if (!empty($this->sums[$i])) {
487                $this->renderer->cdata('∑ ');
488                $this->columns[$i]->getType()->renderValue($this->sums[$i], $this->renderer, $this->mode);
489            } else {
490                if ($this->mode == 'xhtml') {
491                    $this->renderer->doc .= '&nbsp;';
492                }
493            }
494            $this->renderer->tableheader_close();
495        }
496        $this->renderer->tablerow_close();
497        $this->renderer->info['struct_table_meta'] = false;
498    }
499
500    /**
501     * Adds paging controls to the table
502     */
503    protected function renderPagingControls()
504    {
505        if (empty($this->data['limit'])) return;
506        if ($this->mode != 'xhtml') return;
507
508        $this->renderer->info['struct_table_meta'] = true;
509        $this->renderer->tablerow_open();
510        $this->renderer->tableheader_open((count($this->columns) + ($this->data['rownumbers'] ? 1 : 0)));
511        $offset = $this->data['offset'];
512
513        // prev link
514        if ($offset) {
515            $prev = $offset - $this->data['limit'];
516            if ($prev < 0) {
517                $prev = 0;
518            }
519
520            $dynamic = $this->searchConfig->getDynamicParameters();
521            $dynamic->setOffset($prev);
522            $link = wl($this->id, $dynamic->getURLParameters());
523            $this->renderer->doc .= '<a href="' . $link . '" class="prev">' . $this->helper->getLang('prev') . '</a>';
524        }
525
526        // next link
527        if ($this->resultCount > $offset + $this->data['limit']) {
528            $next = $offset + $this->data['limit'];
529            $dynamic = $this->searchConfig->getDynamicParameters();
530            $dynamic->setOffset($next);
531            $link = wl($this->id, $dynamic->getURLParameters());
532            $this->renderer->doc .= '<a href="' . $link . '" class="next">' . $this->helper->getLang('next') . '</a>';
533        }
534
535        $this->renderer->tableheader_close();
536        $this->renderer->tablerow_close();
537        $this->renderer->info['struct_table_meta'] = true;
538    }
539
540    /**
541     * Adds CSV export controls
542     */
543    protected function renderExportControls()
544    {
545        if ($this->mode != 'xhtml') return;
546        if (empty($this->data['csv'])) return;
547        if (!$this->resultCount) return;
548
549        $dynamic = $this->searchConfig->getDynamicParameters();
550        $params = $dynamic->getURLParameters();
551        $params['hash'] = $this->renderer->info['struct_table_hash'];
552
553        // FIXME apply dynamic filters
554        $link = exportlink($this->id, 'struct_csv', $params);
555
556        $this->renderer->doc .= '<a href="' . $link . '" class="export mediafile mf_csv">' . $this->helper->getLang('csvexport') . '</a>';
557    }
558}
559