Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

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