From dd8349fcdd6487a0af21946bf2940ab158afa1de Mon Sep 17 00:00:00 2001 From: Will Rossiter Date: Thu, 17 Jan 2013 13:22:13 +1300 Subject: [PATCH] Initial import from cms/master --- .gitignore | 1 + LICENSE | 17 ++ README.md | 11 + _config.php | 3 + code/Report.php | 423 +++++++++++++++++++++++++++++++++ code/ReportAdmin.php | 184 ++++++++++++++ code/SideReport.php | 115 +++++++++ composer.json | 18 ++ javascript/ReportAdmin.Tree.js | 24 ++ javascript/ReportAdmin.js | 19 ++ tests/ReportTest.php | 134 +++++++++++ 11 files changed, 949 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 _config.php create mode 100644 code/Report.php create mode 100644 code/ReportAdmin.php create mode 100644 code/SideReport.php create mode 100644 composer.json create mode 100644 javascript/ReportAdmin.Tree.js create mode 100644 javascript/ReportAdmin.js create mode 100644 tests/ReportTest.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..496ee2ca --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..b1d63341 --- /dev/null +++ b/LICENSE @@ -0,0 +1,17 @@ +Copyright (c) 2007-2013, SilverStripe Limited - silverstripe.com +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of SilverStripe nor the names of its contributors may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE +GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, +STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY +OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..121e2090 --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# Reports + +## Introduction + +This module contains the API's for building Reports that are displayed in the +SilverStripe backend. This module replaces the built-in reports API from earlier +versions of SilverStripe (2.4 and 3.0). + +## Requirements + + * SilverStripe 3.2 diff --git a/_config.php b/_config.php new file mode 100644 index 00000000..58d64b9f --- /dev/null +++ b/_config.php @@ -0,0 +1,3 @@ +title; + } + + /** + * Return the description of this report. + * + * You have two ways of specifying the description: + * - overriding description(), which lets you support i18n + * - defining the $description property + */ + public function description() { + return $this->description; + } + + /** + * Return the {@link SQLQuery} that provides your report data. + */ + public function sourceQuery($params) { + if($this->hasMethod('sourceRecords')) { + return $this->sourceRecords()->dataQuery(); + } else { + user_error("Please override sourceQuery()/sourceRecords() and columns() or, if necessary, override getReportField()", E_USER_ERROR); + } + } + + /** + * Return a SS_List records for this report. + */ + public function records($params) { + if($this->hasMethod('sourceRecords')) { + return $this->sourceRecords($params, null, null); + } else { + $query = $this->sourceQuery(); + $results = new ArrayList(); + foreach($query->execute() as $data) { + $class = $this->dataClass(); + $result = new $class($data); + $results->push($result); + } + return $results; + } + } + + /** + * Return the data class for this report + */ + public function dataClass() { + return $this->dataClass; + } + + public function getLink($action = null) { + return Controller::join_links( + 'admin/reports/', + "$this->class", + '/', // trailing slash needed if $action is null! + "$action" + ); + } + + /** + * Exclude certain reports classes from the list of Reports in the CMS + * @param $reportClass Can be either a string with the report classname or an array of reports classnames + */ + static public function add_excluded_reports($reportClass) { + if (is_array($reportClass)) { + self::$excluded_reports = array_merge(self::$excluded_reports, $reportClass); + } else { + if (is_string($reportClass)) { + //add to the excluded reports, so this report doesn't get used + self::$excluded_reports[] = $reportClass; + } + } + } + + /** + * Return an array of excluded reports. That is, reports that will not be included in + * the list of reports in report admin in the CMS. + * @return array + */ + static public function get_excluded_reports() { + return self::$excluded_reports; + } + + /** + * Return the SS_Report objects making up the given list. + * @return Array of SS_Report objects + */ + static public function get_reports() { + $reports = ClassInfo::subclassesFor(get_called_class()); + + $reportsArray = array(); + if ($reports && count($reports) > 0) { + //collect reports into array with an attribute for 'sort' + foreach($reports as $report) { + if (in_array($report, self::$excluded_reports)) continue; //don't use the SS_Report superclass + $reflectionClass = new ReflectionClass($report); + if ($reflectionClass->isAbstract()) continue; //don't use abstract classes + + $reportObj = new $report; + if (method_exists($reportObj,'sort')) $reportObj->sort = $reportObj->sort(); //use the sort method to specify the sort field + $reportsArray[$report] = $reportObj; + } + } + + uasort($reportsArray, function($a, $b) { + if($a->sort == $b->sort) return 0; + else return ($a->sort < $b->sort) ? -1 : 1; + }); + + return $reportsArray; + } + + /////////////////////// UI METHODS /////////////////////// + + + /** + * Returns a FieldList with which to create the CMS editing form. + * You can use the extend() method of FieldList to create customised forms for your other + * data objects. + * + * @uses getReportField() to render a table, or similar field for the report. This + * method should be defined on the SS_Report subclasses. + * + * @return FieldList + */ + public function getCMSFields() { + $fields = new FieldList(); + + if($title = $this->title()) { + $fields->push(new LiteralField('ReportTitle', "

{$title}

")); + } + + if($description = $this->description()) { + $fields->push(new LiteralField('ReportDescription', "

" . $description . "

")); + } + + // Add search fields is available + if($this->hasMethod('parameterFields') && $fields = $this->parameterFields()) { + foreach($fields as $field) { + // Namespace fields for easier handling in form submissions + $field->setName(sprintf('filters[%s]', $field->getName())); + $field->addExtraClass('no-change-track'); // ignore in changetracker + $fields->push($field); + } + + // Add a search button + $fields->push(new FormAction('updatereport', _t('GridField.Filter'))); + } + + $fields->push($this->getReportField()); + + $this->extend('updateCMSFields', $fields); + + return $fields; + } + + public function getCMSActions() { + // getCMSActions() can be extended with updateCMSActions() on a extension + $actions = new FieldList(); + $this->extend('updateCMSActions', $actions); + return $actions; + } + + /** + * Return a field, such as a {@link GridField} that is + * used to show and manipulate data relating to this report. + * + * Generally, you should override {@link columns()} and {@link records()} to make your report, + * but if they aren't sufficiently flexible, then you can override this method. + * + * @return FormField subclass + */ + public function getReportField() { + // TODO Remove coupling with global state + $params = isset($_REQUEST['filters']) ? $_REQUEST['filters'] : array(); + $items = $this->sourceRecords($params, null, null); + + $gridFieldConfig = GridFieldConfig::create()->addComponents( + new GridFieldToolbarHeader(), + new GridFieldSortableHeader(), + new GridFieldDataColumns(), + new GridFieldPaginator(), + new GridFieldPrintButton(), + new GridFieldExportButton() + ); + $gridField = new GridField('Report',$this->title(), $items, $gridFieldConfig); + $columns = $gridField->getConfig()->getComponentByType('GridFieldDataColumns'); + $displayFields = array(); + $fieldCasting = array(); + $fieldFormatting = array(); + + // Parse the column information + foreach($this->columns() as $source => $info) { + if(is_string($info)) $info = array('title' => $info); + + if(isset($info['formatting'])) $fieldFormatting[$source] = $info['formatting']; + if(isset($info['csvFormatting'])) $csvFieldFormatting[$source] = $info['csvFormatting']; + if(isset($info['casting'])) $fieldCasting[$source] = $info['casting']; + + if(isset($info['link']) && $info['link']) { + $link = singleton('CMSPageEditController')->Link('show'); + $fieldFormatting[$source] = '$value'; + } + + $displayFields[$source] = isset($info['title']) ? $info['title'] : $source; + } + $columns->setDisplayFields($displayFields); + $columns->setFieldCasting($fieldCasting); + $columns->setFieldFormatting($fieldFormatting); + + return $gridField; + } + + /** + * @param Member $member + * @return boolean + */ + public function canView($member = null) { + if(!$member && $member !== FALSE) { + $member = Member::currentUser(); + } + + return true; + } + + + /** + * Return the name of this report, which + * is used by the templates to render the + * name of the report in the report tree, + * the left hand pane inside ReportAdmin. + * + * @return string + */ + public function TreeTitle() { + return $this->title(); + } + +} + +/** + * SS_ReportWrapper is a base class for creating report wappers. + * + * Wrappers encapsulate an existing report to alter their behaviour - they are implementations of + * the standard GoF decorator pattern. + * + * This base class ensure that, by default, wrappers behave in the same way as the report that is + * being wrapped. You should override any methods that need to behave differently in your subclass + * of SS_ReportWrapper. + * + * It also makes calls to 2 empty methods that you can override {@link beforeQuery()} and + * {@link afterQuery()} + * + * @package reports + */ +abstract class SS_ReportWrapper extends SS_Report { + protected $baseReport; + + public function __construct($baseReport) { + $this->baseReport = is_string($baseReport) ? new $baseReport : $baseReport; + $this->dataClass = $this->baseReport->dataClass(); + parent::__construct(); + } + + public function ID() { + return get_class($this->baseReport) . '_' . get_class($this); + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // Filtering + + public function parameterFields() { + return $this->baseReport->parameterFields(); + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // Columns + + public function columns() { + return $this->baseReport->columns(); + } + + /////////////////////////////////////////////////////////////////////////////////////////// + // Querying + + /** + * Override this method to perform some actions prior to querying. + */ + public function beforeQuery($params) { + } + + /** + * Override this method to perform some actions after querying. + */ + public function afterQuery() {} + + public function sourceQuery($params) { + if($this->baseReport->hasMethod('sourceRecords')) { + // The default implementation will create a fake query from our sourceRecords() method + return parent::sourceQuery($params); + + } else if($this->baseReport->hasMethod('sourceQuery')) { + $this->beforeQuery($params); + $query = $this->baseReport->sourceQuery($params); + $this->afterQuery(); + return $query; + + } else { + user_error("Please override sourceQuery()/sourceRecords() and columns() in your base report", E_USER_ERROR); + } + + } + + public function sourceRecords($params = array(), $sort = null, $limit = null) { + $this->beforeQuery($params); + $records = $this->baseReport->sourceRecords($params, $sort, $limit); + $this->afterQuery(); + return $records; + } + + + /////////////////////////////////////////////////////////////////////////////////////////// + // Pass-through + + public function title() { + return $this->baseReport->title(); + } + + public function group() { + return $this->baseReport->hasMethod('group') ? $this->baseReport->group() : 'Group'; + } + + public function sort() { + return $this->baseReport->hasMethod('sort') ? $this->baseReport->sort() : 0; + } + + public function description() { + return $this->baseReport->description(); + } + + public function canView($member = null) { + return $this->baseReport->canView($member); + } +} \ No newline at end of file diff --git a/code/ReportAdmin.php b/code/ReportAdmin.php new file mode 100644 index 00000000..04305084 --- /dev/null +++ b/code/ReportAdmin.php @@ -0,0 +1,184 @@ + 'handleAction' + ); + + /** + * Variable that describes which report we are currently viewing based on + * the URL (gets set in init method). + * + * @var string + */ + protected $reportClass; + + protected $reportObject; + + public function init() { + parent::init(); + + //set the report we are currently viewing from the URL + $this->reportClass = (isset($this->urlParams['ReportClass'])) ? $this->urlParams['ReportClass'] : null; + $allReports = SS_Report::get_reports(); + $this->reportObject = (isset($allReports[$this->reportClass])) ? $allReports[$this->reportClass] : null; + + // Set custom options for TinyMCE specific to ReportAdmin + HtmlEditorConfig::get('cms')->setOption('ContentCSS', project() . '/css/editor.css'); + HtmlEditorConfig::get('cms')->setOption('Lang', i18n::get_tinymce_lang()); + + // Always block the HtmlEditorField.js otherwise it will be sent with an ajax request + Requirements::block(FRAMEWORK_DIR . '/javascript/HtmlEditorField.js'); + Requirements::javascript(REPORTS_DIR . '/javascript/ReportAdmin.js'); + } + + /** + * Does the parent permission checks, but also + * makes sure that instantiatable subclasses of + * {@link Report} exist. By default, the CMS doesn't + * include any Reports, so there's no point in showing + * + * @param Member $member + * @return boolean + */ + public function canView($member = null) { + if(!$member && $member !== FALSE) $member = Member::currentUser(); + + if(!parent::canView($member)) return false; + + $hasViewableSubclasses = false; + foreach($this->Reports() as $report) { + if($report->canView($member)) return true; + } + + return false; + } + + /** + * Return a SS_List of SS_Report subclasses + * that are available for use. + * + * @return SS_List + */ + public function Reports() { + $output = new ArrayList(); + foreach(SS_Report::get_reports() as $report) { + if($report->canView()) $output->push($report); + } + return $output; + } + + /** + * Determine if we have reports and need + * to display the "Reports" main menu item + * in the CMS. + * + * The test for an existance of a report + * is done by checking for a subclass of + * "SS_Report" that exists. + * + * @return boolean + */ + public static function has_reports() { + return sizeof(SS_Report::get_reports()) > 0; + } + + /** + * Returns the Breadcrumbs for the ReportAdmin + * @return ArrayList + */ + public function Breadcrumbs($unlinked = false) { + $items = parent::Breadcrumbs($unlinked); + + // The root element should explicitly point to the root node. + // Uses session state for current record otherwise. + $items[0]->Link = singleton('ReportAdmin')->Link(); + + if ($this->reportObject) { + //build breadcrumb trail to the current report + $items->push(new ArrayData(array( + 'Title' => $this->reportObject->title(), + 'Link' => Controller::join_links($this->Link(), '?' . http_build_query(array('q' => $this->request->requestVar('q')))) + ))); + } + + return $items; + } + + /** + * Returns the link to the report admin section, or the specific report that is currently displayed + * @return String + */ + public function Link($action = null) { + $link = parent::Link($action); + if ($this->reportObject) $link = $this->reportObject->getLink($action); + return $link; + } + + public function providePermissions() { + $title = _t("ReportAdmin.MENUTITLE", LeftAndMain::menu_title_for_class($this->class)); + return array( + "CMS_ACCESS_ReportAdmin" => array( + 'name' => _t('CMSMain.ACCESS', "Access to '{title}' section", array('title' => $title)), + 'category' => _t('Permission.CMS_ACCESS_CATEGORY', 'CMS Access') + ) + ); + } + + public function getEditForm($id = null, $fields = null) { + $report = $this->reportObject; + if($report) { + $fields = $report->getCMSFields(); + } else { + // List all reports + $fields = new FieldList(); + $gridFieldConfig = GridFieldConfig::create()->addComponents( + new GridFieldToolbarHeader(), + new GridFieldSortableHeader(), + new GridFieldDataColumns(), + new GridFieldFooter() + ); + $gridField = new GridField('Reports',false, $this->Reports(), $gridFieldConfig); + $columns = $gridField->getConfig()->getComponentByType('GridFieldDataColumns'); + $columns->setDisplayFields(array( + 'title' => _t('ReportAdmin.ReportTitle', 'Title'), + )); + + $columns->setFieldFormatting(array( + 'title' => '$value' + )); + $gridField->addExtraClass('all-reports-gridfield'); + $fields->push($gridField); + } + + $actions = new FieldList(); + $form = new Form($this, "EditForm", $fields, $actions); + $form->addExtraClass('cms-edit-form cms-panel-padded center ' . $this->BaseCSSClasses()); + $form->loadDataFrom($this->request->getVars()); + + $this->extend('updateEditForm', $form); + + return $form; + } +} \ No newline at end of file diff --git a/code/SideReport.php b/code/SideReport.php new file mode 100644 index 00000000..61832feb --- /dev/null +++ b/code/SideReport.php @@ -0,0 +1,115 @@ +controller = $controller; + $this->report = $report; + parent::__construct(); + } + + public function group() { + return _t('SideReport.OtherGroupTitle', "Other"); + } + + public function sort() { + return 0; + } + + public function setParameters($parameters) { + $this->parameters = $parameters; + } + + public function forTemplate() { + $records = $this->report->records($this->parameters); + $columns = $this->report->columns(); + + if($records && $records->Count()) { + $result = "\n"; + } else { + $result = "

" . + _t( + 'SideReport.REPEMPTY', + 'The {title} report is empty.', + array('title' => $this->report->title()) + ) + . "

"; + } + return $result; + } + + protected function formatValue($record, $source, $info) { + // Field sources + //if(is_string($source)) { + $val = Convert::raw2xml($record->$source); + //} else { + // $val = $record->val($source[0], $source[1]); + //} + + // Casting, a la TableListField. We're deep-calling a helper method on TableListField that + // should probably be pushed elsewhere... + if(!empty($info['casting'])) { + $val = TableListField::getCastedValue($val, $info['casting']); + } + + // Formatting, a la TableListField + if(!empty($info['formatting'])) { + $format = str_replace('$value', "__VAL__", $info['formatting']); + $format = preg_replace('/\$([A-Za-z0-9-_]+)/','$record->$1', $format); + $format = str_replace('__VAL__', '$val', $format); + $val = eval('return "' . $format . '";'); + } + + $prefix = empty($info['newline']) ? "" : "
"; + + + $classClause = ""; + if(isset($info['title'])) { + $cssClass = preg_replace('/[^A-Za-z0-9]+/', '', $info['title']); + $classClause = "class=\"$cssClass\""; + } + + if(isset($info['link']) && $info['link']) { + $linkBase = singleton('CMSPageEditController')->Link('show') . '/'; + $link = ($info['link'] === true) ? $linkBase . $record->ID : $info['link']; + return $prefix . "$val"; + } else { + return $prefix . "$val"; + } + } +} + +/** + * A report wrapper that makes it easier to define slightly different behaviour for side-reports. + * + * This report wrapper will use sideReportColumns() for the report columns, instead of columns(). + * + * @package reports + */ +class SideReportWrapper extends SS_ReportWrapper { + public function columns() { + if($this->baseReport->hasMethod('sideReportColumns')) { + return $this->baseReport->sideReportColumns(); + } else { + return parent::columns(); + } + } +} \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 00000000..d3b51545 --- /dev/null +++ b/composer.json @@ -0,0 +1,18 @@ +{ + "name": "silverstripe/reports", + "type": "silverstripe-module", + "homepage": "http://silverstripe.org", + "license": "BSD-3-Clause", + "keywords": ["silverstripe", "cms", "reports"], + "authors": [{ + "name": "SilverStripe", + "homepage": "http://silverstripe.com" + }, { + "name": "The SilverStripe Community", + "homepage": "http://silverstripe.org" + }], + "require": { + "php": ">=5.3.2", + "silverstripe/framework": ">=3.2.x-dev", + } +} \ No newline at end of file diff --git a/javascript/ReportAdmin.Tree.js b/javascript/ReportAdmin.Tree.js new file mode 100644 index 00000000..a7a93aef --- /dev/null +++ b/javascript/ReportAdmin.Tree.js @@ -0,0 +1,24 @@ +/** + * File: ReportAdmin.Tree.js + */ +(function($) { + $.entwine('ss.tree', function($){ + /** + * Class: .cms-tree + * + * Tree panel. + */ + $('.cms-tree').entwine({ + onmatch: function() { + // make sure current ID of loaded form is actually selected in tree + var id = $('.cms-edit-form :input[name=ID]').val(); + if (id) this[0].setCurrentByIdx(id); + + this._super(); + }, + onunmatch: function() { + this._super(); + } + }); + }); +}(jQuery)); diff --git a/javascript/ReportAdmin.js b/javascript/ReportAdmin.js new file mode 100644 index 00000000..5d927a81 --- /dev/null +++ b/javascript/ReportAdmin.js @@ -0,0 +1,19 @@ +/** + * File: ReportAdmin.js + */ + +(function($) { + $.entwine('ss', function($){ + $('.ReportAdmin .cms-edit-form').entwine({ + onsubmit: function(e) { + var url = $.path.parseUrl(document.location.href).hrefNoSearch, + params = this.find(':input[name^=filters]').serializeArray(); + params = $.grep(params, function(param) {return (param.value);}); // filter out empty + if(params) url = $.path.addSearchParams(url, params); + $('.cms-container').loadPanel(url); + return false; + } + }); + + }); +})(jQuery); diff --git a/tests/ReportTest.php b/tests/ReportTest.php new file mode 100644 index 00000000..dada8a4d --- /dev/null +++ b/tests/ReportTest.php @@ -0,0 +1,134 @@ +assertNotNull($reports, "Reports returned"); + $previousSort = 0; + foreach($reports as $report) { + $this->assertGreaterThanOrEqual($previousSort, $report->sort, "Reports are in correct sort order"); + $previousSort = $report->sort; + } + } + + public function testExcludeReport() { + $reports = SS_Report::get_reports(); + $reportNames = array(); + foreach($reports as $report) { + $reportNames[] = $report->class; + } + $this->assertContains('ReportTest_FakeTest',$reportNames,'ReportTest_FakeTest is in reports list'); + + //exclude one report + SS_Report::add_excluded_reports('ReportTest_FakeTest'); + + $reports = SS_Report::get_reports(); + $reportNames = array(); + foreach($reports as $report) { + $reportNames[] = $report->class; + } + $this->assertNotContains('ReportTest_FakeTest',$reportNames,'ReportTest_FakeTest is NOT in reports list'); + + //exclude two reports + SS_Report::add_excluded_reports(array('ReportTest_FakeTest','ReportTest_FakeTest2')); + + $reports = SS_Report::get_reports(); + $reportNames = array(); + foreach($reports as $report) { + $reportNames[] = $report->class; + } + $this->assertNotContains('ReportTest_FakeTest',$reportNames,'ReportTest_FakeTest is NOT in reports list'); + $this->assertNotContains('ReportTest_FakeTest2',$reportNames,'ReportTest_FakeTest2 is NOT in reports list'); + } + + public function testAbstractClassesAreExcluded() { + $reports = SS_Report::get_reports(); + $reportNames = array(); + foreach($reports as $report) { + $reportNames[] = $report->class; + } + $this->assertNotContains('ReportTest_FakeTest_Abstract', + $reportNames, + 'ReportTest_FakeTest_Abstract is NOT in reports list as it is abstract'); + } +} + +/** + * @package reports + * @subpackage tests + */ +class ReportTest_FakeTest extends SS_Report implements TestOnly { + public function title() { + return 'Report title'; + } + public function columns() { + return array( + "Title" => array( + "title" => "Page Title" + ) + ); + } + public function sourceRecords($params, $sort, $limit) { + return new ArrayList(); + } + + public function sort() { + return 100; + } +} + +/** + * @package reports + * @subpackage tests + */ +class ReportTest_FakeTest2 extends SS_Report implements TestOnly { + public function title() { + return 'Report title 2'; + } + public function columns() { + return array( + "Title" => array( + "title" => "Page Title 2" + ) + ); + } + public function sourceRecords($params, $sort, $limit) { + return new ArrayList(); + } + + public function sort() { + return 98; + } +} + +/** + * @package reports + * @subpackage tests + */ +abstract class ReportTest_FakeTest_Abstract extends SS_Report implements TestOnly { + + public function title() { + return 'Report title Abstract'; + } + + public function columns() { + return array( + "Title" => array( + "title" => "Page Title Abstract" + ) + ); + } + public function sourceRecords($params, $sort, $limit) { + return new ArrayList(); + } + + public function sort() { + return 5; + } +} +