Version: current

       1 <?php
       2 /**
       3  * Generates a three-pane UI for editing model classes,
       4  * with an automatically generated search panel, tabular results
       5  * and edit forms.
       6  * Relies on data such as {@link DataObject::$db} and {@DataObject::getCMSFields()}
       7  * to scaffold interfaces "out of the box", while at the same time providing
       8  * flexibility to customize the default output.
       9  *
      10  * Add a route (note - this doc is not currently in sync with the code, need to update)
      11  * <code>
      12  * Director::addRules(50, array('admin/mymodel/$Class/$Action/$ID' => 'MyModelAdmin'));
      13  * </code>
      14  *
      15  * @todo saving logic (should mostly use Form->saveInto() and iterate over relations)
      16  * @todo ajax form loading and saving
      17  * @todo ajax result display
      18  * @todo relation formfield scaffolding (one tab per relation) - relations don't have DBField sublclasses, we do
      19  *  we define the scaffold defaults. can be ComplexTableField instances for a start.
      20  * @todo has_many/many_many relation autocomplete field (HasManyComplexTableField doesn't work well with larger datasets)
      21  *
      22  * Long term TODOs:
      23  * @todo Hook into RESTful interface on DataObjects (yet to be developed)
      24  * @todo Permission control via datamodel and Form class
      25  *
      26  * @uses SearchContext
      27  *
      28  * @package cms
      29  * @subpackage core
      30  */
      31 abstract class ModelAdmin extends LeftAndMain {
      32
      33     static $url_rule = '/$Action';
      34
      35     /**
      36      * List of all managed {@link DataObject}s in this interface.
      37      *
      38      * Simple notation with class names only:
      39      * <code>
      40      * array('MyObjectClass','MyOtherObjectClass')
      41      * </code>
      42      *
      43      * Extended notation with options (e.g. custom titles):
      44      * <code>
      45      * array(
      46      *   'MyObjectClass' => array('title' => "Custom title")
      47      * )
      48      * </code>
      49      *
      50      * Available options:
      51      * - 'title': Set custom titles for the tabs or dropdown names
      52      * - 'collection_controller': Set a custom class to use as a collection controller for this model
      53      * - 'record_controller': Set a custom class to use as a record controller for this model
      54      *
      55      * @var array|string
      56      */
      57     public static $managed_models = null;
      58
      59     /**
      60      * More actions are dynamically added in {@link defineMethods()} below.
      61      */
      62     public static $allowed_actions = array(
      63         'add',
      64         'edit',
      65         'delete',
      66         'import',
      67         'renderimportform',
      68         'handleList',
      69         'handleItem',
      70         'ImportForm'
      71     );
      72
      73     /**
      74      * @param string $collection_controller_class Override for controller class
      75      */
      76     public static $collection_controller_class = "ModelAdmin_CollectionController";
      77
      78     /**
      79      * @param string $collection_controller_class Override for controller class
      80      */
      81     public static $record_controller_class = "ModelAdmin_RecordController";
      82
      83     /**
      84      * Forward control to the default action handler
      85      */
      86     public static $url_handlers = array(
      87         '$Action' => 'handleAction'
      88     );
      89
      90     /**
      91      * Model object currently in manipulation queue. Used for updating Link to point
      92      * to the correct generic data object in generated URLs.
      93      *
      94      * @var string
      95      */
      96     private $currentModel = false;
      97
      98     /**
      99      * List of all {@link DataObject}s which can be imported through
     100      * a subclass of {@link BulkLoader} (mostly CSV data).
     101      * By default {@link CsvBulkLoader} is used, assuming a standard mapping
     102      * of column names to {@link DataObject} properties/relations.
     103      *
     104      * e.g. "BlogEntry" => "BlogEntryCsvBulkLoader"
     105      *
     106      * @var array
     107      */
     108     public static $model_importers = null;
     109
     110     /**
     111      * Amount of results showing on a single page.
     112      *
     113      * @var int
     114      */
     115     public static $page_length = 30;
     116
     117     /**
     118      * Class name of the form field used for the results list.  Overloading this in subclasses
     119      * can let you customise the results table field.
     120      */
     121     protected $resultsTableClassName = 'TableListField';
     122
     123     /**
     124      * Return {@link $this->resultsTableClassName}
     125      */
     126     public function resultsTableClassName() {
     127         return $this->resultsTableClassName;
     128     }
     129
     130     /**
     131      * Initialize the model admin interface. Sets up embedded jquery libraries and requisite plugins.
     132      *
     133      * @todo remove reliance on urlParams
     134      */
     135     public function init() {
     136         parent::init();
     137
     138         if($this->getRequest()->requestVar("Locale")) {
     139             $this->Locale = $this->getRequest()->requestVar("Locale");
     140         } elseif($this->getRequest()->requestVar("locale")) {
     141             $this->Locale = $this->getRequest()->requestVar("locale");
     142         } else {
     143             $this->Locale = Translatable::default_locale();
     144         }
     145         Translatable::set_current_locale($this->Locale);
     146
     147         // security check for valid models
     148         if(isset($this->urlParams['Action']) && !in_array($this->urlParams['Action'], $this->getManagedModels())) {
     149             //user_error('ModelAdmin::init(): Invalid Model class', E_USER_ERROR);
     150         }
     151
     152         Requirements::css(CMS_DIR . '/css/ModelAdmin.css'); // standard layout formatting for management UI
     153         Requirements::css(CMS_DIR . '/css/silverstripe.tabs.css'); // follows the jQuery UI theme conventions
     154
     155         Requirements::javascript(THIRDPARTY_DIR . '/jquery/jquery.js');
     156         Requirements::javascript(THIRDPARTY_DIR . '/jquery-form/jquery.form.js');
     157         Requirements::javascript(THIRDPARTY_DIR . '/jquery-effen/jquery.fn.js');
     158         Requirements::javascript(SAPPHIRE_DIR . '/javascript/jquery_improvements.js');
     159         Requirements::javascript(CMS_DIR . '/javascript/ModelAdmin.js');
     160     }
     161
     162     /**
     163      * overwrite the static page_length of the admin panel,
     164      * should be called in the project _config file.
     165      */
     166     static function set_page_length($length){
     167         self::$page_length = $length;
     168     }
     169
     170     /**
     171      * Return the static page_length of the admin, default as 30
     172      */
     173     static function get_page_length(){
     174         return self::$page_length;
     175     }
     176
     177     /**
     178      * Return the class name of the collection controller
     179      *
     180      * @param string $model model name to get the controller for
     181      * @return string the collection controller class
     182      */
     183     function getCollectionControllerClass($model) {
     184         $models = $this->getManagedModels();
     185
     186         if(isset($models[$model]['collection_controller'])) {
     187             $class = $models[$model]['collection_controller'];
     188         } else {
     189             $class = $this->stat('collection_controller_class');
     190         }
     191
     192         return $class;
     193     }
     194
     195     /**
     196      * Return the class name of the record controller
     197      *
     198      * @param string $model model name to get the controller for
     199      * @return string the record controller class
     200      */
     201     function getRecordControllerClass($model) {
     202         $models = $this->getManagedModels();
     203
     204         if(isset($models[$model]['record_controller'])) {
     205             $class = $models[$model]['record_controller'];
     206         } else {
     207             $class = $this->stat('record_controller_class');
     208         }
     209
     210         return $class;
     211     }
     212
     213     /**
     214      * Add mappings for generic form constructors to automatically delegate to a scaffolded form object.
     215      */
     216     function defineMethods() {
     217         parent::defineMethods();
     218         foreach($this->getManagedModels() as $class => $options) {
     219             if(is_numeric($class)) $class = $options;
     220             $this->addWrapperMethod($class, 'bindModelController');
     221             self::$allowed_actions[] = $class;
     222         }
     223     }
     224
     225     /**
     226      * Base scaffolding method for returning a generic model instance.
     227      */
     228     public function bindModelController($model, $request = null) {
     229         $class = $this->getCollectionControllerClass($model);
     230         return new $class($this, $model);
     231     }
     232
     233     /**
     234      * Returns all languages with languages already used appearing first.
     235      * Called by the SSViewer when rendering the template.
     236      */
     237     function LangSelector() {
     238         $member = Member::currentUser();
     239         $dropdown = new LanguageDropdownField(
     240             'LangSelector',
     241             'Language',
     242             array(),
     243             'SiteTree',
     244             'Locale-English',
     245             singleton('SiteTree')
     246         );
     247         $dropdown->setValue(Translatable::get_current_locale());
     248         return $dropdown;
     249     }
     250
     251     /**
     252      * @return boolean
     253      */
     254     function IsTranslatableEnabled($model=false) {
     255         if ($model) {
     256             return SS_Object::has_extension($model, 'Translatable');
     257         }
     258         if (count($this->getManagedModels()) == 1) {
     259             if (!is_numeric(array_shift(array_keys($this->getManagedModels())))) {
     260                 return SS_Object::has_extension(array_shift(array_keys($this->getManagedModels())), 'Translatable');
     261             } else {
     262                 return SS_Object::has_extension(array_shift($this->getManagedModels()), 'Translatable');
     263             }
     264         }
     265         return false;
     266     }
     267
     268     /**
     269      * This method can be overloaded to specify the UI by which the search class is chosen.
     270      *
     271      * It can create a tab strip or a dropdown.  The dropdown is useful when there are a large number of classes.
     272      * By default, it will show a tabs for 1-3 classes, and a dropdown for 4 or more classes.
     273      *
     274      * @return String: 'tabs' or 'dropdown'
     275      */
     276     public function SearchClassSelector() {
     277         return sizeof($this->getManagedModels()) > 2 ? 'dropdown' : 'tabs';
     278     }
     279
     280     /**
     281      * Returns managed models' create, search, and import forms
     282      * @uses SearchContext
     283      * @uses SearchFilter
     284      * @return DataObjectSet of forms
     285      */
     286     protected function getModelForms() {
     287         $models = $this->getManagedModels();
     288         $forms  = new DataObjectSet();
     289
     290         foreach($models as $class => $options) {
     291             if(is_numeric($class)) $class = $options;
     292             $forms->push(new ArrayData(array (
     293                 'Title'     => (is_array($options) && isset($options['title'])) ? $options['title'] : singleton($class)->i18n_plural_name(),
     294                 'ClassName' => $class,
     295                 'Content'   => $this->$class()->getModelSidebar()
     296             )));
     297         }
     298
     299         return $forms;
     300     }
     301
     302     /**
     303      * @return array
     304      */
     305     function getManagedModels() {
     306         $models = $this->stat('managed_models');
     307         if(is_string($models)) {
     308             $models = array($models);
     309         }
     310         if(!count($models)) {
     311             user_error(
     312                 'ModelAdmin::getManagedModels():
     313                 You need to specify at least one DataObject subclass in public static $managed_models.
     314                 Make sure that this property is defined, and that its visibility is set to "public"',
     315                 E_USER_ERROR
     316             );
     317         }
     318
     319         return $models;
     320     }
     321
     322     /**
     323      * Returns all importers defined in {@link self::$model_importers}.
     324      * If none are defined, we fall back to {@link self::managed_models}
     325      * with a default {@link CsvBulkLoader} class. In this case the column names of the first row
     326      * in the CSV file are assumed to have direct mappings to properties on the object.
     327      *
     328      * @return array
     329      */
     330      function getModelImporters() {
     331         $importers = $this->stat('model_importers');
     332
     333         // fallback to all defined models if not explicitly defined
     334         if(is_null($importers)) {
     335             $models = $this->getManagedModels();
     336             foreach($models as $modelName => $options) {
     337                 if(is_numeric($modelName)) $modelName = $options;
     338                 $importers[$modelName] = 'CsvBulkLoader';
     339             }
     340         }
     341
     342         return $importers;
     343     }
     344
     345 }
     346
     347 /**
     348  * Handles a managed model class and provides default collection filtering behavior.
     349  *
     350  * @package cms
     351  * @subpackage core
     352  */
     353 class ModelAdmin_CollectionController extends Controller {
     354     public $parentController;
     355     protected $modelClass;
     356
     357     static $url_handlers = array(
     358         '$Action' => 'handleActionOrID'
     359     );
     360
     361     function __construct($parent, $model) {
     362         $this->parentController = $parent;
     363         $this->modelClass = $model;
     364
     365         parent::__construct();
     366     }
     367
     368     /**
     369      * Appends the model class to the URL.
     370      *
     371      * @param string $action
     372      * @return string
     373      */
     374     function Link($action = null) {
     375         return $this->parentController->Link(Controller::join_links($this->modelClass, $action));
     376     }
     377
     378     /**
     379      * Return the class name of the model being managed.
     380      *
     381      * @return unknown
     382      */
     383     function getModelClass() {
     384         return $this->modelClass;
     385     }
     386
     387     /**
     388      * Delegate to different control flow, depending on whether the
     389      * URL parameter is a number (record id) or string (action).
     390      *
     391      * @param unknown_type $request
     392      * @return unknown
     393      */
     394     function handleActionOrID($request) {
     395         if (is_numeric($request->param('Action'))) {
     396             return $this->handleID($request);
     397         } else {
     398             return $this->handleAction($request);
     399         }
     400     }
     401
     402     /**
     403      * Delegate to the RecordController if a valid numeric ID appears in the URL
     404      * segment.
     405      *
     406      * @param SS_HTTPRequest $request
     407      * @return RecordController
     408      */
     409     public function handleID($request) {
     410         $class = $this->parentController->getRecordControllerClass($this->getModelClass());
     411         return new $class($this, $request);
     412     }
     413
     414     // -----------------------------------------------------------------------------------------------------------------
     415
     416     /**
     417      * Get a combination of the Search, Import and Create forms that can be inserted into a {@link ModelAdmin} sidebar.
     418      *
     419      * @return string
     420      */
     421     public function getModelSidebar() {
     422         return $this->renderWith('ModelSidebar');
     423     }
     424
     425     /**
     426      * Get a search form for a single {@link DataObject} subclass.
     427      *
     428      * @return Form
     429      */
     430     public function SearchForm() {
     431         $context = singleton($this->modelClass)->getDefaultSearchContext();
     432         $fields = $context->getSearchFields();
     433         if ($fields) {
     434             foreach($fields as $field) {
     435                 if ($field->is_a('DropdownField')) { // delete empty fields (sublacces of DropdownField)
     436                     if (!$field->getSource()) {
     437                         $fields->removeByName($field->Name());
     438                     } else {
     439                         $dbObj = singleton($this->modelClass)->dbObject($field->Name());
     440                         if ($dbObj->is_a('Enum')) {
     441                             $map = array();
     442                             if ($field->getHasEmptyDefault()) {
     443                                 $map[''] = $field->getEmptyString();
     444                             }
     445                             foreach($dbObj->enumValues() as $value) {
     446                                 $map[$value] = _t("{$this->modelClass}.".$field->Name()."_{$value}", $value);
     447                             }
     448                             $field->setSource($map);
     449                         }
     450                     }
     451                 }
     452             }
     453         }
     454         $columnSelectionField = $this->ColumnSelectionField();
     455         $fields->push($columnSelectionField);
     456         if ($this->parentController->IsTranslatableEnabled($this->modelClass)) {
     457             $fields->push(new HiddenField('Locale','Locale', Translatable::get_current_locale()));
     458         }
     459         $validator = new RequiredFields();
     460         $validator->setJavascriptValidationHandler('none');
     461
     462         $form = new Form($this, "SearchForm",
     463             $fields,
     464             new FieldSet(
     465                 new FormAction('search', _t('MemberTableField.SEARCH', 'Search')),
     466                 $clearAction = new ResetFormAction('clearsearch', _t('ModelAdmin.CLEAR_SEARCH','Clear Search'))
     467             ),
     468             $validator
     469         );
     470         //$form->setFormAction(Controller::join_links($this->Link(), "search"));
     471         $form->setFormMethod('get');
     472         $form->setHTMLID("Form_SearchForm_" . $this->modelClass);
     473         $form->disableSecurityToken();
     474         $clearAction->useButtonTag = true;
     475         $clearAction->addExtraClass('minorAction');
     476
     477         return $form;
     478     }
     479
     480     /**
     481      * Create a form that consists of one button
     482      * that directs to a give model's Add form
     483      */
     484     public function CreateForm() {
     485         $modelName = $this->modelClass;
     486
     487         if($this->hasMethod('alternatePermissionCheck')) {
     488             if(!$this->alternatePermissionCheck()) return false;
     489         } else {
     490             if(!singleton($modelName)->canCreate(Member::currentUser())) return false;
     491         }
     492
     493         $buttonLabel = sprintf(_t('ModelAdmin.CREATEBUTTON', "Create '%s'", PR_MEDIUM, "Create a new instance from a model class"), singleton($modelName)->i18n_singular_name());
     494
     495         $fields = new FieldSet();
     496         if ($this->parentController->IsTranslatableEnabled($modelName)) {
     497             $fields->push(new HiddenField('Locale','Locale', Translatable::get_current_locale()));
     498         }
     499
     500         $form = new Form($this, "CreateForm",
     501                         $fields,
     502                         new FieldSet($createButton = new FormAction('add', $buttonLabel)),
     503                         $validator = new RequiredFields()
     504                 );
     505
     506         $createButton->dontEscape = true;
     507         $validator->setJavascriptValidationHandler('none');
     508         $form->setHTMLID("Form_CreateForm_" . $this->modelClass);
     509         return $form;
     510     }
     511
     512     /**
     513      * Generate a CSV import form for a single {@link DataObject} subclass.
     514      *
     515      * @return Form
     516      */
     517     public function ImportForm() {
     518         $modelName = $this->modelClass;
     519         $importers = $this->parentController->getModelImporters();
     520         if(!$importers || !isset($importers[$modelName])) return false;
     521
     522         if(!singleton($modelName)->canCreate(Member::currentUser())) return false;
     523
     524         $fields = new FieldSet(
     525             new HiddenField('ClassName', _t('ModelAdmin.CLASSTYPE'), $modelName),
     526             new FileField('_CsvFile', false)
     527         );
     528
     529         // get HTML specification for each import (column names etc.)
     530         $importerClass = $importers[$modelName];
     531         $importer = new $importerClass($modelName);
     532         $spec = $importer->getImportSpec();
     533         $specFields = new DataObjectSet();
     534         foreach($spec['fields'] as $name => $desc) {
     535             $specFields->push(new ArrayData(array('Name' => $name, 'Description' => $desc)));
     536         }
     537         $specRelations = new DataObjectSet();
     538         foreach($spec['relations'] as $name => $desc) {
     539             $specRelations->push(new ArrayData(array('Name' => $name, 'Description' => $desc)));
     540         }
     541         $specHTML = $this->customise(array(
     542             'ModelName' => Convert::raw2att($modelName),
     543             'ModelTitle' => singleton($modelName)->i18n_singular_name(),
     544             'Fields' => $specFields,
     545             'Relations' => $specRelations,
     546         ))->renderWith('ModelAdmin_ImportSpec');
     547
     548         $fields->push(new LiteralField("SpecFor{$modelName}", $specHTML));
     549         $fields->push(new CheckboxField('EmptyBeforeImport', _t('ModelAdmin.CLEAR_DB', 'Clear Database before import'), true));
     550
     551         $actions = new FieldSet(
     552             new FormAction('import', _t('ModelAdmin.IMPORT', 'Import from CSV'))
     553         );
     554
     555         $validator = new RequiredFields();
     556         $validator->setJavascriptValidationHandler('none');
     557
     558         $form = new Form(
     559             $this,
     560             "ImportForm",
     561             $fields,
     562             $actions,
     563             $validator
     564         );
     565         $form->setHTMLID("Form_ImportForm");
     566         return $form;
     567     }
     568
     569     /**
     570      * Imports the submitted CSV file based on specifications given in
     571      * {@link self::model_importers}.
     572      * Redirects back with a success/failure message.
     573      *
     574      * @todo Figure out ajax submission of files via jQuery.form plugin
     575      *
     576      * @param array $data
     577      * @param Form $form
     578      * @param SS_HTTPRequest $request
     579      */
     580     function import($data, $form, $request) {
     581         $modelName = $data['ClassName'];
     582         $importers = $this->parentController->getModelImporters();
     583         $importerClass = $importers[$modelName];
     584
     585         $loader = new $importerClass($data['ClassName']);
     586
     587                 // File wasn't properly uploaded, show a reminder to the user
     588         if(empty($_FILES['_CsvFile']['tmp_name'])) {
     589             $form->sessionMessage(_t('ModelAdmin.NOCSVFILE', 'Please browse for a CSV file to import'), 'good');
     590             Director::redirectBack();
     591             return false;
     592         }
     593
     594         if (!empty($data['EmptyBeforeImport']) && $data['EmptyBeforeImport']) { //clear database before import
     595             $loader->deleteExistingRecords = true;
     596         }
     597         $results = $loader->load($_FILES['_CsvFile']['tmp_name']);
     598
     599         $message = '';
     600         if($results->CreatedCount()) $message .= sprintf(
     601             _t('ModelAdmin.IMPORTEDRECORDS', "Imported %s records."),
     602             $results->CreatedCount()
     603         );
     604         if($results->UpdatedCount()) $message .= sprintf(
     605             _t('ModelAdmin.UPDATEDRECORDS', "Updated %s records."),
     606             $results->UpdatedCount()
     607         );
     608         if($results->DeletedCount()) $message .= sprintf(
     609             _t('ModelAdmin.DELETEDRECORDS', "Deleted %s records."),
     610             $results->DeletedCount()
     611         );
     612         if(!$results->CreatedCount() && !$results->UpdatedCount()) $message .= _t('ModelAdmin.NOIMPORT', "Nothing to import");
     613
     614         $form->sessionMessage($message, 'good');
     615         Director::redirectBack();
     616     }
     617
     618     /**
     619      * Return the columns available in the column selection field.
     620      * Overload this to make other columns available
     621      */
     622     public function columnsAvailable() {
     623         return singleton($this->modelClass)->summaryFields();
     624     }
     625
     626     /**
     627      * Return the columns selected by default in the column selection field.
     628      * Overload this to make other columns selected by default
     629      */
     630     public function columnsSelectedByDefault() {
     631         return array_keys(singleton($this->modelClass)->summaryFields());
     632     }
     633
     634     /**
     635      * Give the flexibilility to show variouse combination of columns in the search result table
     636      */
     637     public function ColumnSelectionField() {
     638         $model = singleton($this->modelClass);
     639         $source = $this->columnsAvailable();
     640
     641         // select all fields by default
     642         $value = $this->columnsSelectedByDefault();
     643
     644         // Reorder the source so that you read items down the column and then across
     645         $columnisedSource = array();
     646         $keys = array_keys($source);
     647         $midPoint = ceil(sizeof($source)/2);
     648         for($i=0;$i<$midPoint;$i++) {
     649             $key1 = $keys[$i];
     650             $columnisedSource[$key1] = $model->fieldLabel($source[$key1]);
     651             // If there are an odd number of items, the last item will be unset
     652             if(isset($keys[$i+$midPoint])) {
     653                 $key2 = $keys[$i+$midPoint];
     654                 $columnisedSource[$key2] = $model->fieldLabel($source[$key2]);
     655             }
     656         }
     657
     658         $checkboxes = new CheckboxSetField("ResultAssembly", false, $columnisedSource, $value);
     659
     660         $field = new CompositeField(
     661             new LiteralField(
     662                 "ToggleResultAssemblyLink",
     663                 sprintf("<a class=\"form_frontend_function toggle_result_assembly\" href=\"#\">%s</a>",
     664                     _t('ModelAdmin.CHOOSE_COLUMNS', 'Select result columns...')
     665                 )
     666             ),
     667             $checkboxesBlock = new CompositeField(
     668                 $checkboxes,
     669                 new LiteralField("ClearDiv", "<div class=\"clear\"></div>"),
     670                 new LiteralField(
     671                     "TickAllAssemblyLink",
     672                     sprintf(
     673                         "<a class=\"form_frontend_function tick_all_result_assembly\" href=\"#\">%s</a>",
     674                         _t('ModelAdmin.SELECTALL', 'select all')
     675                     )
     676                 ),
     677                 new LiteralField(
     678                     "UntickAllAssemblyLink",
     679                     sprintf(
     680                         "<a class=\"form_frontend_function untick_all_result_assembly\" href=\"#\">%s</a>",
     681                         _t('ModelAdmin.SELECTNONE', 'select none')
     682                     )
     683                 )
     684             )
     685         );
     686
     687         $field->addExtraClass("ResultAssemblyBlock");
     688         $checkboxesBlock->addExtraClass("hidden");
     689         return $field;
     690     }
     691
     692     /**
     693      * Action to render a data object collection, using the model context to provide filters
     694      * and paging.
     695      *
     696      * @return string
     697      */
     698     function search($request, $form) {
     699         // Get the results form to be rendered
     700         $resultsForm = $this->ResultsForm(array_merge($form->getData(), $request));
     701         // Before rendering, let's get the total number of results returned
     702         $tableField = $resultsForm->Fields()->fieldByName($this->modelClass);
     703         $numResults = $tableField->TotalCount();
     704
     705         if($numResults) {
     706             return new SS_HTTPResponse(
     707                 $resultsForm->forTemplate(),
     708                 200,
     709                 sprintf(
     710                     _t('ModelAdmin.FOUNDRESULTS',"Your search found %s matching items"),
     711                     $numResults
     712                 )
     713             );
     714         } else {
     715             return new SS_HTTPResponse(
     716                 $resultsForm->forTemplate(),
     717                 200,
     718                 _t('ModelAdmin.NORESULTS',"Your search didn't return any matching items")
     719             );
     720         }
     721     }
     722
     723     /**
     724      * Gets the search query generated on the SearchContext from
     725      * {@link DataObject::getDefaultSearchContext()},
     726      * and the current GET parameters on the request.
     727      *
     728      * @return SQLQuery
     729      */
     730     function getSearchQuery($searchCriteria) {
     731         $context = singleton($this->modelClass)->getDefaultSearchContext();
     732         return $context->getQuery($searchCriteria);
     733     }
     734
     735     /**
     736      * Returns all columns used for tabular search results display.
     737      * Defaults to all fields specified in {@link DataObject->summaryFields()}.
     738      *
     739      * @param array $searchCriteria Limit fields by populating the 'ResultsAssembly' key
     740      * @param boolean $selectedOnly Limit by 'ResultsAssempty
     741      */
     742     function getResultColumns($searchCriteria, $selectedOnly = true) {
     743         $model = singleton($this->modelClass);
     744
     745         $summaryFields = $this->columnsAvailable();
     746
     747         if($selectedOnly && isset($searchCriteria['ResultAssembly'])) {
     748             $resultAssembly = $searchCriteria['ResultAssembly'];
     749             if(!is_array($resultAssembly)) {
     750                 $explodedAssembly = preg_split('! *, *!', $resultAssembly);
     751                 $resultAssembly = array();
     752                 foreach($explodedAssembly as $item) $resultAssembly[$item] = true;
     753             }
     754             return array_intersect_key($summaryFields, $resultAssembly);
     755         } else {
     756             return $summaryFields;
     757         }
     758     }
     759
     760     /**
     761      * Creates and returns the result table field for resultsForm.
     762      * Uses {@link resultsTableClassName()} to initialise the formfield.
     763      * Method is called from {@link ResultsForm}.
     764      *
     765      * @param array $searchCriteria passed through from ResultsForm
     766      *
     767      * @return TableListField
     768      */
     769     function getResultsTable($searchCriteria) {
     770
     771         $summaryFields = $this->getResultColumns($searchCriteria);
     772
     773         $className = $this->parentController->resultsTableClassName();
     774         $tf = new $className(
     775             $this->modelClass,
     776             $this->modelClass,
     777             $summaryFields
     778         );
     779
     780         $tf->setCustomQuery($this->getSearchQuery($searchCriteria));
     781         $tf->setPageSize($this->parentController->stat('page_length'));
     782         $tf->setShowPagination(true);
     783         // @todo Remove records that can't be viewed by the current user
     784         $tf->setPermissions(array_merge(array('view','export'), TableListField::permissions_for_object($this->modelClass)));
     785
     786         // csv export settings (select all columns regardless of user checkbox settings in 'ResultsAssembly')
     787         $exportFields = $this->getResultColumns($searchCriteria, false);
     788         $tf->setFieldListCsv($exportFields);
     789
     790         $editLink = $this->Link() . '/$ID/edit';
     791         if ($this->parentController->IsTranslatableEnabled($this->modelClass)) {
     792             $editLink .= '?Locale=' . Translatable::get_current_locale();
     793         }
     794
     795         $url = '<a href=\"' . $editLink . '\">$value</a>';
     796
     797         $tf->setFieldFormatting(array_combine(array_keys($summaryFields), array_fill(0,count($summaryFields), $url)));
     798
     799         return $tf;
     800     }
     801
     802     /**
     803      * Shows results from the "search" action in a TableListField.
     804      *
     805      * @uses getResultsTable()
     806      *
     807      * @return Form
     808      */
     809     function ResultsForm($searchCriteria) {
     810
     811         if($searchCriteria instanceof SS_HTTPRequest) $searchCriteria = $searchCriteria->getVars();
     812
     813         $tf = $this->getResultsTable($searchCriteria);
     814
     815         // implemented as a form to enable further actions on the resultset
     816         // (serverside sorting, export as CSV, etc)
     817         $form = new Form(
     818             $this,
     819             'ResultsForm',
     820             new FieldSet(
     821                 new HeaderField('SearchResultsHeader',_t('ModelAdmin.SEARCHRESULTS','Search Results'), 2),
     822                 $tf
     823             ),
     824             new FieldSet(
     825                 new FormAction("goBack", _t('ModelAdmin.GOBACK', "Back")),
     826                 new FormAction("goForward", _t('ModelAdmin.GOFORWARD', "Forward"))
     827             )
     828         );
     829
     830         // Include the search criteria on the results form URL, but not dodgy variables like those below
     831         $filteredCriteria = $searchCriteria;
     832         unset($filteredCriteria['ctf']);
     833         unset($filteredCriteria['url']);
     834         unset($filteredCriteria['action_search']);
     835
     836         $form->setFormAction($this->Link() . '/ResultsForm?' . http_build_query($filteredCriteria));
     837         return $form;
     838     }
     839
     840
     841     /////////////////////////////////////////////////////////////////////////////////////////////////////////
     842
     843     /**
     844      * Create a new model record.
     845      *
     846      * @param unknown_type $request
     847      * @return unknown
     848      */
     849     function add($request) {
     850         return new SS_HTTPResponse(
     851             $this->AddForm()->forAjaxTemplate(),
     852             200,
     853             sprintf(
     854                 _t('ModelAdmin.ADDFORM', "Fill out this form to add a %s to the database."),
     855                 $this->modelClass
     856             )
     857         );
     858     }
     859
     860     /**
     861      * Returns a form suitable for adding a new model, falling back on the default edit form.
     862      *
     863      * Caution: The add-form shows a DataObject's {@link DataObject->getCMSFields()} method on a record
     864      * that doesn't exist in the database yet, hence has no ID. This means the {@link DataObject->getCMSFields()}
     865      * implementation has to ensure that no fields are added which would rely on a
     866      * record ID being present, e.g. {@link HasManyComplexTableField}.
     867      *
     868      * Example:
     869      * <code>
     870      * function getCMSFields() {
     871      *   $fields = parent::getCMSFields();
     872      *     if($this->exists()) {
     873      *       $ctf = new HasManyComplexTableField($this, 'MyRelations', 'MyRelation');
     874      *       $fields->addFieldToTab('Root.Main', $ctf);
     875      *     }
     876      *   return $fields;
     877      * }
     878      * </code>
     879      *
     880      * @return Form
     881      */
     882     public function AddForm() {
     883         $newRecord = new $this->modelClass();
     884
     885         if($newRecord->canCreate()){
     886             if($newRecord->hasMethod('getCMSAddFormFields')) {
     887                 $fields = $newRecord->getCMSAddFormFields();
     888             } else {
     889                 $fields = $newRecord->getCMSFields();
     890             }
     891
     892             $validator = ($newRecord->hasMethod('getCMSValidator')) ? $newRecord->getCMSValidator() : null;
     893             if(!$validator) $validator = new RequiredFields();
     894             $validator->setJavascriptValidationHandler('none');
     895
     896             $actions = new FieldSet (
     897                 new FormAction("doCreate", _t('ModelAdmin.ADDBUTTON', "Add"))
     898             );
     899
     900             $form = new Form($this, "AddForm", $fields, $actions, $validator);
     901             $form->loadDataFrom($newRecord);
     902
     903             if ($this->parentController->IsTranslatableEnabled($this->modelClass) && ($localeField = $fields->dataFieldByName('Locale'))) {
     904                 $localeField->setValue(Translatable::get_current_locale());
     905             }
     906             return $form;
     907         }
     908     }
     909
     910     function doCreate($data, $form, $request) {
     911         $className = $this->getModelClass();
     912         $model = new $className();
     913         // We write before saveInto, since this will let us save has-many and many-many relationships :-)
     914         $model->write();
     915         $form->saveInto($model);
     916         $model->write();
     917
     918         if(Director::is_ajax()) {
     919             $class = $this->parentController->getRecordControllerClass($this->getModelClass());
     920             $recordController = new $class($this, $request, $model->ID);
     921             return new SS_HTTPResponse(
     922                 $recordController->EditForm()->forAjaxTemplate(),
     923                 200,
     924                 sprintf(
     925                     _t('ModelAdmin.LOADEDFOREDITING', "Loaded '%s' for editing."),
     926                     $model->Title
     927                 )
     928             );
     929         } else {
     930             Director::redirect(Controller::join_links($this->Link(), $model->ID , 'edit'));
     931         }
     932     }
     933 }
     934
     935 /**
     936  * Handles operations on a single record from a managed model.
     937  *
     938  * @package cms
     939  * @subpackage core
     940  * @todo change the parent controller varname to indicate the model scaffolding functionality in ModelAdmin
     941  */
     942 class ModelAdmin_RecordController extends Controller {
     943     protected $parentController;
     944     protected $currentRecord;
     945
     946     static $allowed_actions = array('edit', 'view', 'EditForm', 'ViewForm');
     947
     948     function __construct($parentController, $request, $recordID = null) {
     949         $this->parentController = $parentController;
     950         $modelName = $parentController->getModelClass();
     951         $recordID = ($recordID) ? $recordID : $request->param('Action');
     952         $this->currentRecord = DataObject::get_by_id($modelName, $recordID);
     953
     954         parent::__construct();
     955     }
     956
     957     /**
     958      * Link fragment - appends the current record ID to the URL.
     959      */
     960     public function Link($action = null) {
     961         return $this->parentController->Link(Controller::join_links($this->currentRecord->ID, $action));
     962     }
     963
     964     /////////////////////////////////////////////////////////////////////////////////////////////////////////
     965
     966     /**
     967      * Edit action - shows a form for editing this record
     968      */
     969     function edit($request) {
     970         if ($this->currentRecord) {
     971             if(Director::is_ajax()) {
     972                 return new SS_HTTPResponse(
     973                     $this->EditForm()->forAjaxTemplate(),
     974                     200,
     975                     sprintf(
     976                         _t('ModelAdmin.LOADEDFOREDITING', "Loaded '%s' for editing."),
     977                         $this->currentRecord->Title
     978                     )
     979                 );
     980             } else {
     981                 // This is really quite ugly; to fix will require a change in the way that customise() works. :-(
     982                 return $this->parentController->parentController->customise(array(
     983                     'Right' => $this->parentController->parentController->customise(array(
     984                         'EditForm' => $this->EditForm()
     985                     ))->renderWith('ModelAdmin_right')
     986                 ))->renderWith(array('ModelAdmin','LeftAndMain'));
     987                 return ;
     988             }
     989         } else {
     990             return _t('ModelAdmin.ITEMNOTFOUND', "I can't find that item");
     991         }
     992     }
     993
     994     /**
     995      * Returns a form for editing the attached model
     996      */
     997     public function EditForm() {
     998         $fields = $this->currentRecord->getCMSFields();
     999         $fields->push(new HiddenField("ID"));
    1000
    1001         $validator = ($this->currentRecord->hasMethod('getCMSValidator')) ? $this->currentRecord->getCMSValidator() : new RequiredFields();
    1002         $validator->setJavascriptValidationHandler('none');
    1003
    1004         $actions = $this->currentRecord->getCMSActions();
    1005         if($this->currentRecord->canEdit(Member::currentUser())){
    1006             $actions->push(new FormAction("doSave", _t('ModelAdmin.SAVE', "Save")));
    1007         }else{
    1008             $fields = $fields->makeReadonly();
    1009         }
    1010
    1011         if($this->currentRecord->canDelete(Member::currentUser())) {
    1012             $actions->insertFirst($deleteAction = new FormAction('doDelete', _t('ModelAdmin.DELETE', 'Delete')));
    1013             $deleteAction->addExtraClass('delete');
    1014         }
    1015
    1016         $actions->insertFirst(new FormAction("goBack", _t('ModelAdmin.GOBACK', "Back")));
    1017
    1018         $form = new Form($this, "EditForm", $fields, $actions, $validator);
    1019         $form->loadDataFrom($this->currentRecord);
    1020
    1021         return $form;
    1022     }
    1023
    1024     /**
    1025      * Postback action to save a record
    1026      *
    1027      * @param array $data
    1028      * @param Form $form
    1029      * @param SS_HTTPRequest $request
    1030      * @return mixed
    1031      */
    1032     function doSave($data, $form, $request) {
    1033         $form->saveInto($this->currentRecord);
    1034
    1035         try {
    1036             $this->currentRecord->write();
    1037         } catch(ValidationException $e) {
    1038             $form->sessionMessage($e->getResult()->message(), 'bad');
    1039         }
    1040
    1041
    1042         // Behaviour switched on ajax.
    1043         if(Director::is_ajax()) {
    1044             return $this->edit($request);
    1045         } else {
    1046             Director::redirectBack();
    1047         }
    1048     }
    1049
    1050     /**
    1051      * Delete the current record
    1052      */
    1053     public function doDelete($data, $form, $request) {
    1054         if($this->currentRecord->canDelete(Member::currentUser())) {
    1055             $this->currentRecord->delete();
    1056             Director::redirect($this->parentController->Link('SearchForm?action=search'));
    1057         }
    1058         else Director::redirectBack();
    1059         return;
    1060     }
    1061
    1062     /////////////////////////////////////////////////////////////////////////////////////////////////////////
    1063
    1064     /**
    1065      * Renders the record view template.
    1066      *
    1067      * @param SS_HTTPRequest $request
    1068      * @return mixed
    1069      */
    1070     function view($request) {
    1071         if($this->currentRecord) {
    1072             $form = $this->ViewForm();
    1073             return $form->forAjaxTemplate();
    1074         } else {
    1075             return _t('ModelAdmin.ITEMNOTFOUND');
    1076         }
    1077     }
    1078
    1079     /**
    1080      * Returns a form for viewing the attached model
    1081      *
    1082      * @return Form
    1083      */
    1084     public function ViewForm() {
    1085         $fields = $this->currentRecord->getCMSFields();
    1086         $form = new Form($this, "EditForm", $fields, new FieldSet());
    1087         $form->loadDataFrom($this->currentRecord);
    1088         $form->makeReadonly();
    1089         return $form;
    1090     }
    1091
    1092     /////////////////////////////////////////////////////////////////////////////////////////////////////////
    1093
    1094     function index() {
    1095         Director::redirect(Controller::join_links($this->Link(), 'edit'));
    1096     }
    1097
    1098     function getCurrentRecord(){
    1099         return $this->currentRecord;
    1100     }
    1101
    1102 }
    1103
    1104 ?>

[Raise a SilverStripe Framework issue/bug](https://github.com/silverstripe/silverstripe-framework/issues/new)
- [Raise a SilverStripe CMS issue/bug](https://github.com/silverstripe/silverstripe-cms/issues/new)
- Please use the Silverstripe Forums to ask development related questions. -