Complex classes like ModelAdmin often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
While breaking up the class, it is a good idea to analyze how other classes use ModelAdmin, and based on these observations, apply Extract Interface, too.
| 1 | <?php |
||
| 15 | abstract class ModelAdmin extends LeftAndMain { |
||
| 16 | |||
| 17 | private static $url_rule = '/$ModelClass/$Action'; |
||
| 18 | |||
| 19 | /** |
||
| 20 | * List of all managed {@link DataObject}s in this interface. |
||
| 21 | * |
||
| 22 | * Simple notation with class names only: |
||
| 23 | * <code> |
||
| 24 | * array('MyObjectClass','MyOtherObjectClass') |
||
| 25 | * </code> |
||
| 26 | * |
||
| 27 | * Extended notation with options (e.g. custom titles): |
||
| 28 | * <code> |
||
| 29 | * array( |
||
| 30 | * 'MyObjectClass' => array('title' => "Custom title") |
||
| 31 | * ) |
||
| 32 | * </code> |
||
| 33 | * |
||
| 34 | * Available options: |
||
| 35 | * - 'title': Set custom titles for the tabs or dropdown names |
||
| 36 | * |
||
| 37 | * @config |
||
| 38 | * @var array|string |
||
| 39 | */ |
||
| 40 | private static $managed_models = null; |
||
| 41 | |||
| 42 | /** |
||
| 43 | * Override menu_priority so that ModelAdmin CMSMenu objects |
||
| 44 | * are grouped together directly above the Help menu item. |
||
| 45 | * @var float |
||
| 46 | */ |
||
| 47 | private static $menu_priority = -0.5; |
||
| 48 | |||
| 49 | private static $menu_icon = 'framework/admin/images/menu-icons/16x16/db.png'; |
||
| 50 | |||
| 51 | private static $allowed_actions = array( |
||
| 52 | 'ImportForm', |
||
| 53 | 'SearchForm', |
||
| 54 | ); |
||
| 55 | |||
| 56 | private static $url_handlers = array( |
||
| 57 | '$ModelClass/$Action' => 'handleAction' |
||
| 58 | ); |
||
| 59 | |||
| 60 | /** |
||
| 61 | * @var String |
||
| 62 | */ |
||
| 63 | protected $modelClass; |
||
| 64 | |||
| 65 | /** |
||
| 66 | * Change this variable if you don't want the Import from CSV form to appear. |
||
| 67 | * This variable can be a boolean or an array. |
||
| 68 | * If array, you can list className you want the form to appear on. i.e. array('myClassOne','myClassTwo') |
||
| 69 | */ |
||
| 70 | public $showImportForm = true; |
||
| 71 | |||
| 72 | /** |
||
| 73 | * Change this variable if you don't want the search form to appear. |
||
| 74 | * This variable can be a boolean or an array. |
||
| 75 | * If array, you can list className you want the form to appear on. i.e. array('myClassOne','myClassTwo') |
||
| 76 | */ |
||
| 77 | public $showSearchForm = true; |
||
| 78 | |||
| 79 | /** |
||
| 80 | * List of all {@link DataObject}s which can be imported through |
||
| 81 | * a subclass of {@link BulkLoader} (mostly CSV data). |
||
| 82 | * By default {@link CsvBulkLoader} is used, assuming a standard mapping |
||
| 83 | * of column names to {@link DataObject} properties/relations. |
||
| 84 | * |
||
| 85 | * e.g. "BlogEntry" => "BlogEntryCsvBulkLoader" |
||
| 86 | * |
||
| 87 | * @config |
||
| 88 | * @var array |
||
| 89 | */ |
||
| 90 | private static $model_importers = null; |
||
| 91 | |||
| 92 | /** |
||
| 93 | * Amount of results showing on a single page. |
||
| 94 | * |
||
| 95 | * @config |
||
| 96 | * @var int |
||
| 97 | */ |
||
| 98 | private static $page_length = 30; |
||
| 99 | |||
| 100 | /** |
||
| 101 | * Initialize the model admin interface. Sets up embedded jquery libraries and requisite plugins. |
||
| 102 | */ |
||
| 103 | public function init() { |
||
| 104 | parent::init(); |
||
| 105 | |||
| 106 | $models = $this->getManagedModels(); |
||
| 107 | |||
| 108 | if($this->getRequest()->param('ModelClass')) { |
||
| 109 | $this->modelClass = $this->unsanitiseClassName($this->getRequest()->param('ModelClass')); |
||
| 110 | } else { |
||
| 111 | reset($models); |
||
| 112 | $this->modelClass = key($models); |
||
| 113 | } |
||
| 114 | |||
| 115 | // security check for valid models |
||
| 116 | if(!array_key_exists($this->modelClass, $models)) { |
||
| 117 | user_error('ModelAdmin::init(): Invalid Model class', E_USER_ERROR); |
||
| 118 | } |
||
| 119 | |||
| 120 | Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/javascript/ModelAdmin.js'); |
||
| 121 | } |
||
| 122 | |||
| 123 | public function Link($action = null) { |
||
| 124 | if(!$action) $action = $this->sanitiseClassName($this->modelClass); |
||
| 125 | return parent::Link($action); |
||
| 126 | } |
||
| 127 | |||
| 128 | public function getEditForm($id = null, $fields = null) { |
||
| 129 | $list = $this->getList(); |
||
| 130 | $exportButton = new GridFieldExportButton('buttons-before-left'); |
||
| 131 | $exportButton->setExportColumns($this->getExportFields()); |
||
| 132 | $listField = GridField::create( |
||
| 133 | $this->sanitiseClassName($this->modelClass), |
||
| 134 | false, |
||
| 135 | $list, |
||
| 136 | $fieldConfig = GridFieldConfig_RecordEditor::create($this->stat('page_length')) |
||
| 137 | ->addComponent($exportButton) |
||
| 138 | ->removeComponentsByType('GridFieldFilterHeader') |
||
| 139 | ->addComponents(new GridFieldPrintButton('buttons-before-left')) |
||
| 140 | ); |
||
| 141 | |||
| 142 | // Validation |
||
| 143 | if(singleton($this->modelClass)->hasMethod('getCMSValidator')) { |
||
| 144 | $detailValidator = singleton($this->modelClass)->getCMSValidator(); |
||
| 145 | $listField->getConfig()->getComponentByType('GridFieldDetailForm')->setValidator($detailValidator); |
||
|
|
|||
| 146 | } |
||
| 147 | |||
| 148 | $form = CMSForm::create( |
||
| 149 | $this, |
||
| 150 | 'EditForm', |
||
| 151 | new FieldList($listField), |
||
| 152 | new FieldList() |
||
| 153 | )->setHTMLID('Form_EditForm'); |
||
| 154 | $form->setResponseNegotiator($this->getResponseNegotiator()); |
||
| 155 | $form->addExtraClass('cms-edit-form cms-panel-padded center'); |
||
| 156 | $form->setTemplate($this->getTemplatesWithSuffix('_EditForm')); |
||
| 157 | $editFormAction = Controller::join_links($this->Link($this->sanitiseClassName($this->modelClass)), 'EditForm'); |
||
| 158 | $form->setFormAction($editFormAction); |
||
| 159 | $form->setAttribute('data-pjax-fragment', 'CurrentForm'); |
||
| 160 | |||
| 161 | $this->extend('updateEditForm', $form); |
||
| 162 | |||
| 163 | return $form; |
||
| 164 | } |
||
| 165 | |||
| 166 | /** |
||
| 167 | * Define which fields are used in the {@link getEditForm} GridField export. |
||
| 168 | * By default, it uses the summary fields from the model definition. |
||
| 169 | * |
||
| 170 | * @return array |
||
| 171 | */ |
||
| 172 | public function getExportFields() { |
||
| 173 | return singleton($this->modelClass)->summaryFields(); |
||
| 174 | } |
||
| 175 | |||
| 176 | /** |
||
| 177 | * @return SearchContext |
||
| 178 | */ |
||
| 179 | public function getSearchContext() { |
||
| 180 | $context = singleton($this->modelClass)->getDefaultSearchContext(); |
||
| 181 | |||
| 182 | // Namespace fields, for easier detection if a search is present |
||
| 183 | foreach($context->getFields() as $field) $field->setName(sprintf('q[%s]', $field->getName())); |
||
| 184 | foreach($context->getFilters() as $filter) $filter->setFullName(sprintf('q[%s]', $filter->getFullName())); |
||
| 185 | |||
| 186 | $this->extend('updateSearchContext', $context); |
||
| 187 | |||
| 188 | return $context; |
||
| 189 | } |
||
| 190 | |||
| 191 | /** |
||
| 192 | * @return Form|bool |
||
| 193 | */ |
||
| 194 | public function SearchForm() { |
||
| 195 | if(!$this->showSearchForm || |
||
| 196 | (is_array($this->showSearchForm) && !in_array($this->modelClass, $this->showSearchForm)) |
||
| 197 | ) { |
||
| 198 | return false; |
||
| 199 | } |
||
| 200 | $context = $this->getSearchContext(); |
||
| 201 | $form = new Form($this, "SearchForm", |
||
| 202 | $context->getSearchFields(), |
||
| 203 | new FieldList( |
||
| 204 | Object::create('FormAction', 'search', _t('MemberTableField.APPLY_FILTER', 'Apply Filter')) |
||
| 205 | ->setUseButtonTag(true)->addExtraClass('ss-ui-action-constructive'), |
||
| 206 | Object::create('ResetFormAction','clearsearch', _t('ModelAdmin.RESET','Reset')) |
||
| 207 | ->setUseButtonTag(true) |
||
| 208 | ), |
||
| 209 | new RequiredFields() |
||
| 210 | ); |
||
| 211 | $form->setFormMethod('get'); |
||
| 212 | $form->setFormAction($this->Link($this->sanitiseClassName($this->modelClass))); |
||
| 213 | $form->addExtraClass('cms-search-form'); |
||
| 214 | $form->disableSecurityToken(); |
||
| 215 | $form->loadDataFrom($this->getRequest()->getVars()); |
||
| 216 | |||
| 217 | $this->extend('updateSearchForm', $form); |
||
| 218 | |||
| 219 | return $form; |
||
| 220 | } |
||
| 221 | |||
| 222 | public function getList() { |
||
| 223 | $context = $this->getSearchContext(); |
||
| 224 | $params = $this->getRequest()->requestVar('q'); |
||
| 225 | |||
| 226 | if(is_array($params)) { |
||
| 227 | $params = ArrayLib::array_map_recursive('trim', $params); |
||
| 228 | } |
||
| 229 | |||
| 230 | // Parse all DateFields to handle user input non ISO 8601 dates |
||
| 231 | foreach($context->getFields() as $field) { |
||
| 232 | if($field instanceof DatetimeField) { |
||
| 233 | $params[$field->getName()] = date('Y-m-d', strtotime($params[$field->getName()])); |
||
| 234 | } |
||
| 235 | } |
||
| 236 | |||
| 237 | $list = $context->getResults($params); |
||
| 238 | |||
| 239 | $this->extend('updateList', $list); |
||
| 240 | |||
| 241 | return $list; |
||
| 242 | } |
||
| 243 | |||
| 244 | |||
| 245 | /** |
||
| 246 | * Returns managed models' create, search, and import forms |
||
| 247 | * @uses SearchContext |
||
| 248 | * @uses SearchFilter |
||
| 249 | * @return SS_List of forms |
||
| 250 | */ |
||
| 251 | protected function getManagedModelTabs() { |
||
| 252 | $models = $this->getManagedModels(); |
||
| 253 | $forms = new ArrayList(); |
||
| 254 | |||
| 255 | foreach($models as $class => $options) { |
||
| 256 | $forms->push(new ArrayData(array ( |
||
| 257 | 'Title' => $options['title'], |
||
| 258 | 'ClassName' => $class, |
||
| 259 | 'Link' => $this->Link($this->sanitiseClassName($class)), |
||
| 260 | 'LinkOrCurrent' => ($class == $this->modelClass) ? 'current' : 'link' |
||
| 261 | ))); |
||
| 262 | } |
||
| 263 | |||
| 264 | return $forms; |
||
| 265 | } |
||
| 266 | |||
| 267 | /** |
||
| 268 | * Sanitise a model class' name for inclusion in a link |
||
| 269 | * @return string |
||
| 270 | */ |
||
| 271 | protected function sanitiseClassName($class) { |
||
| 272 | return str_replace('\\', '-', $class); |
||
| 273 | } |
||
| 274 | |||
| 275 | /** |
||
| 276 | * Unsanitise a model class' name from a URL param |
||
| 277 | * @return string |
||
| 278 | */ |
||
| 279 | protected function unsanitiseClassName($class) { |
||
| 280 | return str_replace('-', '\\', $class); |
||
| 281 | } |
||
| 282 | |||
| 283 | /** |
||
| 284 | * @return array Map of class name to an array of 'title' (see {@link $managed_models}) |
||
| 285 | */ |
||
| 286 | public function getManagedModels() { |
||
| 287 | $models = $this->stat('managed_models'); |
||
| 288 | if(is_string($models)) { |
||
| 289 | $models = array($models); |
||
| 290 | } |
||
| 291 | if(!count($models)) { |
||
| 292 | user_error( |
||
| 293 | 'ModelAdmin::getManagedModels(): |
||
| 294 | You need to specify at least one DataObject subclass in public static $managed_models. |
||
| 295 | Make sure that this property is defined, and that its visibility is set to "public"', |
||
| 296 | E_USER_ERROR |
||
| 297 | ); |
||
| 298 | } |
||
| 299 | |||
| 300 | // Normalize models to have their model class in array key |
||
| 301 | foreach($models as $k => $v) { |
||
| 302 | if(is_numeric($k)) { |
||
| 303 | $models[$v] = array('title' => singleton($v)->i18n_singular_name()); |
||
| 304 | unset($models[$k]); |
||
| 305 | } |
||
| 306 | } |
||
| 307 | |||
| 308 | return $models; |
||
| 309 | } |
||
| 310 | |||
| 311 | /** |
||
| 312 | * Returns all importers defined in {@link self::$model_importers}. |
||
| 313 | * If none are defined, we fall back to {@link self::managed_models} |
||
| 314 | * with a default {@link CsvBulkLoader} class. In this case the column names of the first row |
||
| 315 | * in the CSV file are assumed to have direct mappings to properties on the object. |
||
| 316 | * |
||
| 317 | * @return array Map of model class names to importer instances |
||
| 318 | */ |
||
| 319 | public function getModelImporters() { |
||
| 320 | $importerClasses = $this->stat('model_importers'); |
||
| 321 | |||
| 322 | // fallback to all defined models if not explicitly defined |
||
| 323 | if(is_null($importerClasses)) { |
||
| 324 | $models = $this->getManagedModels(); |
||
| 325 | foreach($models as $modelName => $options) { |
||
| 326 | $importerClasses[$modelName] = 'CsvBulkLoader'; |
||
| 327 | } |
||
| 328 | } |
||
| 329 | |||
| 330 | $importers = array(); |
||
| 331 | foreach($importerClasses as $modelClass => $importerClass) { |
||
| 332 | $importers[$modelClass] = new $importerClass($modelClass); |
||
| 333 | } |
||
| 334 | |||
| 335 | return $importers; |
||
| 336 | } |
||
| 337 | |||
| 338 | /** |
||
| 339 | * Generate a CSV import form for a single {@link DataObject} subclass. |
||
| 340 | * |
||
| 341 | * @return Form|bool |
||
| 342 | */ |
||
| 343 | public function ImportForm() { |
||
| 344 | $modelSNG = singleton($this->modelClass); |
||
| 345 | $modelName = $modelSNG->i18n_singular_name(); |
||
| 346 | // check if a import form should be generated |
||
| 347 | if(!$this->showImportForm || |
||
| 348 | (is_array($this->showImportForm) && !in_array($this->modelClass, $this->showImportForm)) |
||
| 349 | ) { |
||
| 350 | return false; |
||
| 351 | } |
||
| 352 | |||
| 353 | $importers = $this->getModelImporters(); |
||
| 354 | if(!$importers || !isset($importers[$this->modelClass])) return false; |
||
| 355 | |||
| 356 | if(!$modelSNG->canCreate(Member::currentUser())) return false; |
||
| 357 | |||
| 358 | $fields = new FieldList( |
||
| 359 | new HiddenField('ClassName', _t('ModelAdmin.CLASSTYPE'), $this->modelClass), |
||
| 360 | new FileField('_CsvFile', false) |
||
| 361 | ); |
||
| 362 | |||
| 363 | // get HTML specification for each import (column names etc.) |
||
| 364 | $importerClass = $importers[$this->modelClass]; |
||
| 365 | $importer = new $importerClass($this->modelClass); |
||
| 366 | $spec = $importer->getImportSpec(); |
||
| 367 | $specFields = new ArrayList(); |
||
| 368 | foreach($spec['fields'] as $name => $desc) { |
||
| 369 | $specFields->push(new ArrayData(array('Name' => $name, 'Description' => $desc))); |
||
| 370 | } |
||
| 371 | $specRelations = new ArrayList(); |
||
| 372 | foreach($spec['relations'] as $name => $desc) { |
||
| 373 | $specRelations->push(new ArrayData(array('Name' => $name, 'Description' => $desc))); |
||
| 374 | } |
||
| 375 | $specHTML = $this->customise(array( |
||
| 376 | 'ClassName' => $this->sanitiseClassName($this->modelClass), |
||
| 377 | 'ModelName' => Convert::raw2att($modelName), |
||
| 378 | 'Fields' => $specFields, |
||
| 379 | 'Relations' => $specRelations, |
||
| 380 | ))->renderWith('ModelAdmin_ImportSpec'); |
||
| 381 | |||
| 382 | $fields->push(new LiteralField("SpecFor{$modelName}", $specHTML)); |
||
| 383 | $fields->push( |
||
| 384 | new CheckboxField('EmptyBeforeImport', _t('ModelAdmin.EMPTYBEFOREIMPORT', 'Replace data'), |
||
| 385 | false) |
||
| 386 | ); |
||
| 387 | |||
| 388 | $actions = new FieldList( |
||
| 389 | new FormAction('import', _t('ModelAdmin.IMPORT', 'Import from CSV')) |
||
| 390 | ); |
||
| 391 | |||
| 392 | $form = new Form( |
||
| 393 | $this, |
||
| 394 | "ImportForm", |
||
| 395 | $fields, |
||
| 396 | $actions |
||
| 397 | ); |
||
| 398 | $form->setFormAction( |
||
| 399 | Controller::join_links($this->Link($this->sanitiseClassName($this->modelClass)), 'ImportForm') |
||
| 400 | ); |
||
| 401 | |||
| 402 | $this->extend('updateImportForm', $form); |
||
| 403 | |||
| 404 | return $form; |
||
| 405 | } |
||
| 406 | |||
| 407 | /** |
||
| 408 | * Imports the submitted CSV file based on specifications given in |
||
| 409 | * {@link self::model_importers}. |
||
| 410 | * Redirects back with a success/failure message. |
||
| 411 | * |
||
| 412 | * @todo Figure out ajax submission of files via jQuery.form plugin |
||
| 413 | * |
||
| 414 | * @param array $data |
||
| 415 | * @param Form $form |
||
| 416 | * @param SS_HTTPRequest $request |
||
| 417 | * @return bool|null |
||
| 418 | */ |
||
| 419 | public function import($data, $form, $request) { |
||
| 464 | |||
| 465 | /** |
||
| 466 | * @return ArrayList |
||
| 467 | */ |
||
| 468 | public function Breadcrumbs($unlinked = false) { |
||
| 484 | |||
| 485 | /** |
||
| 486 | * overwrite the static page_length of the admin panel, |
||
| 487 | * should be called in the project _config file. |
||
| 488 | * |
||
| 489 | * @deprecated 4.0 Use "ModelAdmin.page_length" config setting |
||
| 490 | */ |
||
| 491 | public static function set_page_length($length){ |
||
| 495 | |||
| 496 | /** |
||
| 497 | * Return the static page_length of the admin, default as 30 |
||
| 498 | * |
||
| 499 | * @deprecated 4.0 Use "ModelAdmin.page_length" config setting |
||
| 500 | */ |
||
| 501 | public static function get_page_length(){ |
||
| 505 | |||
| 506 | } |
||
| 507 |
Let’s take a look at an example:
In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.
Available Fixes
Change the type-hint for the parameter:
Add an additional type-check:
Add the method to the interface: