Search moodle.org's
Developer Documentation

See Release Notes
Long Term Support Release

  • Bug fixes for general core bugs in 4.1.x will end 13 November 2023 (12 months).
  • Bug fixes for security issues in 4.1.x will end 10 November 2025 (36 months).
  • PHP version: minimum PHP 7.4.0 Note: minimum PHP version has increased since Moodle 4.0. PHP 8.0.x is supported too.

Differences Between: [Versions 400 and 401] [Versions 401 and 403]

   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  declare(strict_types=1);
  18  
  19  namespace core_reportbuilder\table;
  20  
  21  use core\output\notification;
  22  use html_writer;
  23  use moodle_exception;
  24  use moodle_url;
  25  use stdClass;
  26  use core_reportbuilder\manager;
  27  use core_reportbuilder\local\models\report;
  28  use core_reportbuilder\local\report\column;
  29  use core_reportbuilder\output\column_aggregation_editable;
  30  use core_reportbuilder\output\column_heading_editable;
  31  
  32  /**
  33   * Custom report dynamic table class
  34   *
  35   * @package     core_reportbuilder
  36   * @copyright   2021 David Matamoros <davidmc@moodle.com>
  37   * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  38   */
  39  class custom_report_table extends base_report_table {
  40  
  41      /** @var string Unique ID prefix for the table */
  42      private const UNIQUEID_PREFIX = 'custom-report-table-';
  43  
  44      /** @var bool Whether report is being edited (we don't want user filters/sorting to be applied when editing) */
  45      protected const REPORT_EDITING = true;
  46  
  47      /** @var float $querytimestart Time we began executing table SQL */
  48      private $querytimestart = 0.0;
  49  
  50      /**
  51       * Table constructor. Note that the passed unique ID value must match the pattern "custom-report-table-(\d+)" so that
  52       * dynamic updates continue to load the same report
  53       *
  54       * @param string $uniqueid
  55       * @param string $download
  56       * @throws moodle_exception For invalid unique ID
  57       */
  58      public function __construct(string $uniqueid, string $download = '') {
  59          if (!preg_match('/^' . self::UNIQUEID_PREFIX . '(?<id>\d+)$/', $uniqueid, $matches)) {
  60              throw new moodle_exception('invalidcustomreportid', 'core_reportbuilder', '', null, $uniqueid);
  61          }
  62  
  63          parent::__construct($uniqueid);
  64  
  65          $this->define_baseurl(new moodle_url('/reportbuilder/edit.php', ['id' => $matches['id']]));
  66  
  67          // Load the report persistent, and accompanying report instance.
  68          $this->persistent = new report($matches['id']);
  69          $this->report = manager::get_report_from_persistent($this->persistent);
  70  
  71          $fields = $groupby = [];
  72          $maintable = $this->report->get_main_table();
  73          $maintablealias = $this->report->get_main_table_alias();
  74          $joins = $this->report->get_joins();
  75          [$where, $params] = $this->report->get_base_condition();
  76  
  77          $this->set_attribute('data-region', 'reportbuilder-table');
  78          $this->set_attribute('class', $this->attributes['class'] . ' reportbuilder-table');
  79  
  80          // Download options.
  81          $this->showdownloadbuttonsat = [TABLE_P_BOTTOM];
  82          $this->is_downloading($download ?? null, $this->persistent->get_formatted_name());
  83  
  84          // Retrieve all report columns, exit early if there are none. Defining empty columns prevents errors during out().
  85          $columns = $this->get_active_columns();
  86          if (empty($columns)) {
  87              $this->init_sql("{$maintablealias}.*", "{{$maintable}} {$maintablealias}", $joins, '1=0', []);
  88              $this->define_columns([0]);
  89              return;
  90          }
  91  
  92          // If we are aggregating any columns, we should group by the remaining ones.
  93          $aggregatedcolumns = array_filter($columns, static function(column $column): bool {
  94              return !empty($column->get_aggregation());
  95          });
  96  
  97          // Also take account of the report setting to show unique rows (only if no columns are being aggregated).
  98          $hasaggregatedcolumns = !empty($aggregatedcolumns);
  99          $showuniquerows = !$hasaggregatedcolumns && $this->persistent->get('uniquerows');
 100  
 101          $columnheaders = $columnsattributes = [];
 102          foreach ($columns as $column) {
 103              $columnheading = $column->get_persistent()->get_formatted_heading($this->report->get_context());
 104              $columnheaders[$column->get_column_alias()] = $columnheading !== '' ? $columnheading : $column->get_title();
 105  
 106              // We need to determine for each column whether we should group by it's fields, to support aggregation.
 107              $columnaggregation = $column->get_aggregation();
 108              if ($showuniquerows || ($hasaggregatedcolumns && empty($columnaggregation))) {
 109                  $groupby = array_merge($groupby, $column->get_groupby_sql());
 110              }
 111  
 112              // Add each columns fields, joins and params to our report.
 113              $fields = array_merge($fields, $column->get_fields());
 114              $joins = array_merge($joins, $column->get_joins());
 115              $params = array_merge($params, $column->get_params());
 116  
 117              // Disable sorting for some columns.
 118              if (!$column->get_is_sortable()) {
 119                  $this->no_sorting($column->get_column_alias());
 120              }
 121  
 122              // Add column attributes needed for card view.
 123              $settings = $this->report->get_settings_values();
 124              $showfirsttitle = $settings['cardview_showfirsttitle'] ?? false;
 125              $visiblecolumns = max($settings['cardview_visiblecolumns'] ?? 1, count($this->columns));
 126              if ($showfirsttitle || $column->get_persistent()->get('columnorder') > 1) {
 127                  $column->add_attributes(['data-cardtitle' => $columnheaders[$column->get_column_alias()]]);
 128              }
 129              if ($column->get_persistent()->get('columnorder') > $visiblecolumns) {
 130                  $column->add_attributes(['data-cardviewhidden' => '']);
 131              }
 132  
 133              // Generate column attributes to be included in each cell.
 134              $columnsattributes[$column->get_column_alias()] = $column->get_attributes();
 135          }
 136  
 137          $this->define_columns(array_keys($columnheaders));
 138          $this->define_headers(array_values($columnheaders));
 139  
 140          // Add column attributes to the table.
 141          $this->set_columnsattributes($columnsattributes);
 142  
 143          // Table configuration.
 144          $this->initialbars(false);
 145          $this->collapsible(false);
 146          $this->pageable(true);
 147          $this->set_default_per_page($this->report->get_default_per_page());
 148  
 149          // Initialise table SQL properties.
 150          $this->set_report_editing(static::REPORT_EDITING);
 151  
 152          $fieldsql = implode(', ', $fields);
 153          $this->init_sql($fieldsql, "{{$maintable}} {$maintablealias}", $joins, $where, $params, $groupby);
 154      }
 155  
 156      /**
 157       * Return a new instance of the class for given report ID
 158       *
 159       * @param int $reportid
 160       * @param string $download
 161       * @return static
 162       */
 163      public static function create(int $reportid, string $download = ''): self {
 164          return new static(self::UNIQUEID_PREFIX . $reportid, $download);
 165      }
 166  
 167      /**
 168       * Get user preferred sort columns, overriding those of parent. If user has no preferences then use report defaults
 169       *
 170       * @return array
 171       */
 172      public function get_sort_columns(): array {
 173          $sortcolumns = parent::get_sort_columns();
 174  
 175          if ($this->editing || empty($sortcolumns)) {
 176              $sortcolumns = [];
 177              $columns = $this->get_active_columns();
 178  
 179              // We need to sort the columns by the configured sorting order.
 180              usort($columns, static function(column $a, column $b): int {
 181                  return ($a->get_persistent()->get('sortorder') < $b->get_persistent()->get('sortorder')) ? -1 : 1;
 182              });
 183  
 184              foreach ($columns as $column) {
 185                  $persistent = $column->get_persistent();
 186                  if ($column->get_is_sortable() && $persistent->get('sortenabled')) {
 187                      $sortcolumns[$column->get_column_alias()] = $persistent->get('sortdirection');
 188                  }
 189              }
 190          }
 191  
 192          return $sortcolumns;
 193      }
 194  
 195      /**
 196       * Format each row of returned data, executing defined callbacks for the row and each column
 197       *
 198       * @param array|stdClass $row
 199       * @return array
 200       */
 201      public function format_row($row) {
 202          $columns = $this->get_active_columns();
 203  
 204          $formattedrow = [];
 205          foreach ($columns as $column) {
 206              $formattedrow[$column->get_column_alias()] = $column->format_value((array) $row);
 207          }
 208  
 209          return $formattedrow;
 210      }
 211  
 212      /**
 213       * Download is disabled when editing the report
 214       *
 215       * @return string
 216       */
 217      public function download_buttons(): string {
 218          return '';
 219      }
 220  
 221      /**
 222       * Get the columns of the custom report, returned instances being valid and available for the user
 223       *
 224       * @return column[]
 225       */
 226      protected function get_active_columns(): array {
 227          return $this->report->get_active_columns();
 228      }
 229  
 230      /**
 231       * Override parent method for printing headers so we can render our custom controls in each cell
 232       */
 233      public function print_headers() {
 234          global $OUTPUT, $PAGE;
 235  
 236          $columns = $this->get_active_columns();
 237          if (empty($columns)) {
 238              return;
 239          }
 240  
 241          $columns = array_values($columns);
 242          $renderer = $PAGE->get_renderer('core');
 243  
 244          echo html_writer::start_tag('thead');
 245          echo html_writer::start_tag('tr');
 246  
 247          foreach ($this->headers as $index => $title) {
 248              $column = $columns[$index];
 249  
 250              $headingeditable = new column_heading_editable(0, $column->get_persistent());
 251              $aggregationeditable = new column_aggregation_editable(0, $column->get_persistent());
 252  
 253              // Render table header cell, with all editing controls.
 254              $headercell = $OUTPUT->render_from_template('core_reportbuilder/table_header_cell', [
 255                  'entityname' => $this->report->get_entity_title($column->get_entity_name()),
 256                  'name' => $column->get_title(),
 257                  'headingeditable' => $headingeditable->render($renderer),
 258                  'aggregationeditable' => $aggregationeditable->render($renderer),
 259                  'movetitle' => get_string('movecolumn', 'core_reportbuilder', $column->get_title()),
 260              ]);
 261  
 262              echo html_writer::tag('th', $headercell, [
 263                  'class' => 'border-right border-left',
 264                  'scope' => 'col',
 265                  'data-region' => 'column-header',
 266                  'data-column-id' => $column->get_persistent()->get('id'),
 267                  'data-column-name' => $column->get_title(),
 268                  'data-column-position' => $index + 1,
 269              ]);
 270          }
 271  
 272          echo html_writer::end_tag('tr');
 273          echo html_writer::end_tag('thead');
 274      }
 275  
 276      /**
 277       * Override print_nothing_to_display to ensure that column headers are always added.
 278       */
 279      public function print_nothing_to_display() {
 280          global $OUTPUT;
 281  
 282          $this->start_html();
 283          $this->print_headers();
 284          echo html_writer::end_tag('table');
 285          echo html_writer::end_tag('div');
 286          $this->wrap_html_finish();
 287  
 288          // With the live editing disabled we need to notify user that data is shown only in preview mode.
 289          if ($this->editing && !self::show_live_editing()) {
 290              $notificationmsg = get_string('customreportsliveeditingdisabled', 'core_reportbuilder');
 291              $notificationtype = notification::NOTIFY_WARNING;
 292          } else {
 293              $notificationmsg = get_string('nothingtodisplay');
 294              $notificationtype = notification::NOTIFY_INFO;
 295          }
 296  
 297          $notification = (new notification($notificationmsg, $notificationtype, false))
 298              ->set_extra_classes(['mt-3']);
 299          echo $OUTPUT->render($notification);
 300  
 301          echo $this->get_dynamic_table_html_end();
 302      }
 303  
 304      /**
 305       * Provide additional table debugging during editing
 306       */
 307      public function wrap_html_finish(): void {
 308          global $OUTPUT;
 309  
 310          if ($this->editing && debugging('', DEBUG_DEVELOPER)) {
 311              $params = array_map(static function(string $param, $value): array {
 312                  return ['param' => $param, 'value' => var_export($value, true)];
 313              }, array_keys($this->sql->params), $this->sql->params);
 314  
 315              echo $OUTPUT->render_from_template('core_reportbuilder/local/report/debug', [
 316                  'query' => $this->get_table_sql(),
 317                  'params' => $params,
 318                  'duration' => $this->querytimestart ?
 319                      format_time($this->querytimestart - microtime(true)) : null,
 320              ]);
 321          }
 322      }
 323  
 324      /**
 325       * Override get_row_cells_html to add an extra cell with the toggle button for card view.
 326       *
 327       * @param string $rowid
 328       * @param array $row
 329       * @param array|null $suppresslastrow
 330       * @return string
 331       */
 332      public function get_row_cells_html(string $rowid, array $row, ?array $suppresslastrow): string {
 333          $html = parent::get_row_cells_html($rowid, $row, $suppresslastrow);
 334  
 335          // Add extra 'td' in the row with card toggle button (only visible in card view).
 336          $visiblecolumns = $this->report->get_settings_values()['cardview_visiblecolumns'] ?? 1;
 337          if ($visiblecolumns < count($this->columns)) {
 338              $buttonicon = html_writer::tag('i', '', ['class' => 'fa fa-angle-down']);
 339  
 340              // We need a cleaned version (without tags/entities) of the first row column to use as toggle button.
 341              $rowfirstcolumn = strip_tags((string) reset($row));
 342              $buttontitle = $rowfirstcolumn !== ''
 343                  ? get_string('showhide', 'core_reportbuilder', html_entity_decode($rowfirstcolumn, ENT_COMPAT))
 344                  : get_string('showhidecard', 'core_reportbuilder');
 345  
 346              $button = html_writer::tag('button', $buttonicon, [
 347                  'type' => 'button',
 348                  'class' => 'btn collapsed',
 349                  'title' => $buttontitle,
 350                  'data-toggle' => 'collapse',
 351                  'data-action' => 'toggle-card'
 352              ]);
 353              $html .= html_writer::tag('td', $button, ['class' => 'card-toggle d-none']);
 354          }
 355          return $html;
 356      }
 357  
 358      /**
 359       * Overriding this method to handle live editing setting.
 360       * @param int $pagesize
 361       * @param bool $useinitialsbar
 362       * @param string $downloadhelpbutton
 363       */
 364      public function out($pagesize, $useinitialsbar, $downloadhelpbutton = '') {
 365          $this->pagesize = $pagesize;
 366          $this->setup();
 367  
 368          // If the live editing setting is disabled, we not need to fetch custom report data except in preview mode.
 369          if (!$this->editing || self::show_live_editing()) {
 370              $this->querytimestart = microtime(true);
 371              $this->query_db($pagesize, $useinitialsbar);
 372              $this->build_table();
 373              $this->close_recordset();
 374          }
 375  
 376          $this->finish_output();
 377      }
 378  
 379      /**
 380       * Whether or not report data should be included in the table while in editing mode
 381       *
 382       * @return bool
 383       */
 384      private static function show_live_editing(): bool {
 385          global $CFG;
 386  
 387          return !empty($CFG->customreportsliveediting);
 388      }
 389  }