Merge pull request #236 from silverstripe-big-o/pull/SSF-25

SSF-25: ModelAdmin
This commit is contained in:
Sam Minnée 2012-03-09 15:23:20 -08:00
commit 603a984ad4
16 changed files with 413 additions and 1303 deletions

View File

@ -30,7 +30,7 @@
*/
abstract class ModelAdmin extends LeftAndMain {
static $url_rule = '/$Action';
static $url_rule = '/$ModelClass/$Action';
/**
* List of all managed {@link DataObject}s in this interface.
@ -49,51 +49,24 @@ abstract class ModelAdmin extends LeftAndMain {
*
* Available options:
* - 'title': Set custom titles for the tabs or dropdown names
* - 'collection_controller': Set a custom class to use as a collection controller for this model
* - 'record_controller': Set a custom class to use as a record controller for this model
*
* @var array|string
*/
public static $managed_models = null;
/**
* More actions are dynamically added in {@link defineMethods()} below.
*/
public static $allowed_actions = array(
'add',
'edit',
'delete',
'import',
'renderimportform',
'handleList',
'handleItem',
'ImportForm'
'ImportForm',
'SearchForm',
);
/**
* @param string $collection_controller_class Override for controller class
*/
public static $collection_controller_class = "ModelAdmin_CollectionController";
/**
* @param string $collection_controller_class Override for controller class
*/
public static $record_controller_class = "ModelAdmin_RecordController";
/**
* Forward control to the default action handler
*/
public static $url_handlers = array(
'$Action' => 'handleAction'
'$ModelClass/$Action' => 'handleAction'
);
/**
* Model object currently in manipulation queue. Used for updating Link to point
* to the correct generic data object in generated URLs.
*
* @var string
* @var String
*/
private $currentModel = false;
protected $modelClass;
/**
* Change this variable if you don't want the Import from CSV form to appear.
@ -120,20 +93,7 @@ abstract class ModelAdmin extends LeftAndMain {
* @var int
*/
public static $page_length = 30;
/**
* Class name of the form field used for the results list. Overloading this in subclasses
* can let you customise the results table field.
*/
protected $resultsTableClassName = 'GridField';
/**
* Return {@link $this->resultsTableClassName}
*/
public function resultsTableClassName() {
return $this->resultsTableClassName;
}
/**
* Initialize the model admin interface. Sets up embedded jquery libraries and requisite plugins.
*
@ -141,103 +101,101 @@ abstract class ModelAdmin extends LeftAndMain {
*/
public function init() {
parent::init();
$models = $this->getManagedModels();
$this->modelClass = (isset($this->urlParams['ModelClass'])) ? $this->urlParams['ModelClass'] : $models[0];
// security check for valid models
if(isset($this->urlParams['Action']) && !in_array($this->urlParams['Action'], $this->getManagedModels())) {
//user_error('ModelAdmin::init(): Invalid Model class', E_USER_ERROR);
if(!in_array($this->modelClass, $models)) {
user_error('ModelAdmin::init(): Invalid Model class', E_USER_ERROR);
}
Requirements::css(SAPPHIRE_ADMIN_DIR . '/css/silverstripe.tabs.css'); // follows the jQuery UI theme conventions
Requirements::javascript(SAPPHIRE_DIR . '/thirdparty/jquery/jquery.js');
Requirements::javascript(SAPPHIRE_DIR . '/thirdparty/jquery-livequery/jquery.livequery.js');
Requirements::javascript(SAPPHIRE_DIR . '/thirdparty/jquery-ui/jquery-ui.js');
Requirements::javascript(SAPPHIRE_ADMIN_DIR . '/javascript/ModelAdmin.js');
Requirements::javascript(SAPPHIRE_ADMIN_DIR . '/javascript/ModelAdmin.History.js');
}
/**
* overwrite the static page_length of the admin panel,
* should be called in the project _config file.
*/
static function set_page_length($length){
self::$page_length = $length;
}
/**
* Return the static page_length of the admin, default as 30
*/
static function get_page_length(){
return self::$page_length;
}
/**
* Return the class name of the collection controller
*
* @param string $model model name to get the controller for
* @return string the collection controller class
*/
function getCollectionControllerClass($model) {
$models = $this->getManagedModels();
if(isset($models[$model]['collection_controller'])) {
$class = $models[$model]['collection_controller'];
} else {
$class = $this->stat('collection_controller_class');
function getEditForm($id = null) {
$list = $this->getList();
$listField = Object::create('GridField',
$this->modelClass,
false,
$list,
$fieldConfig = GridFieldConfig_RecordEditor::create($this->stat('page_length'))
->addComponent(new GridFieldExportButton())
->removeComponentsByType('GridFieldFilter')
);
// Validation
if(singleton($this->modelClass)->hasMethod('getCMSValidator')) {
$detailValidator = singleton($this->modelClass)->getCMSValidator();
$listField->getConfig()->getComponentByType('GridFieldDetailForm')->setValidator($detailValidator);
}
$form = new Form(
$this,
'EditForm',
new FieldList($listField),
new FieldList()
);
$form->addExtraClass('cms-edit-form cms-panel-padded center');
$form->setTemplate($this->getTemplatesWithSuffix('_EditForm'));
$form->setFormAction(Controller::join_links($this->Link($this->modelClass), 'EditForm'));
$this->extend('updateEditForm', $form);
return $class;
return $form;
}
/**
* @return SearchContext
*/
public function getSearchContext() {
$context = singleton($this->modelClass)->getDefaultSearchContext();
// Namespace fields, for easier detection if a search is present
foreach($context->getFields() as $field) $field->setName(sprintf('q[%s]', $field->getName()));
foreach($context->getFilters() as $filter) $filter->setFullName(sprintf('q[%s]', $filter->getFullName()));
$this->extend('updateSearchContext', $context);
return $context;
}
/**
* @return Form
*/
public function SearchForm() {
$context = $this->getSearchContext();
$form = new Form($this, "SearchForm",
$context->getSearchFields(),
new FieldList(
Object::create('ResetFormAction','clearsearch', _t('ModelAdmin.CLEAR_SEARCH','Clear Search'))
->setUseButtonTag(true)->addExtraClass('ss-ui-action-minor'),
Object::create('FormAction', 'search', _t('MemberTableField.SEARCH', 'Search'))
->setUseButtonTag(true)
),
new RequiredFields()
);
$form->setFormMethod('get');
$form->setFormAction($this->Link($this->modelClass));
$form->addExtraClass('cms-search-form');
$form->disableSecurityToken();
$form->loadDataFrom($this->request->getVars());
$this->extend('updateSearchForm', $form);
return $form;
}
/**
* Return the class name of the record controller
*
* @param string $model model name to get the controller for
* @return string the record controller class
*/
function getRecordControllerClass($model) {
$models = $this->getManagedModels();
if(isset($models[$model]['record_controller'])) {
$class = $models[$model]['record_controller'];
} else {
$class = $this->stat('record_controller_class');
}
return $class;
}
/**
* Add mappings for generic form constructors to automatically delegate to a scaffolded form object.
*/
function defineMethods() {
parent::defineMethods();
foreach($this->getManagedModels() as $class => $options) {
if(is_numeric($class)) $class = $options;
$this->addWrapperMethod($class, 'bindModelController');
self::$allowed_actions[] = $class;
}
}
/**
* Base scaffolding method for returning a generic model instance.
*/
public function bindModelController($model, $request = null) {
$class = $this->getCollectionControllerClass($model);
return new $class($this, $model);
}
/**
* This method can be overloaded to specify the UI by which the search class is chosen.
*
* It can create a tab strip or a dropdown. The dropdown is useful when there are a large number of classes.
* By default, it will show a tabs for 1-3 classes, and a dropdown for 4 or more classes.
*
* @return String: 'tabs' or 'dropdown'
*/
public function SearchClassSelector() {
return sizeof($this->getManagedModels()) > 3 ? 'dropdown' : 'tabs';
public function getList() {
$context = $this->getSearchContext();
$params = $this->request->requestVar('q');
$list = $context->getResults($params);
$this->extend('updateList', $list);
return $list;
}
/**
* Returns managed models' create, search, and import forms
@ -245,7 +203,7 @@ abstract class ModelAdmin extends LeftAndMain {
* @uses SearchFilter
* @return SS_List of forms
*/
protected function getModelForms() {
protected function getManagedModelTabs() {
$models = $this->getManagedModels();
$forms = new ArrayList();
@ -254,7 +212,8 @@ abstract class ModelAdmin extends LeftAndMain {
$forms->push(new ArrayData(array (
'Title' => (is_array($options) && isset($options['title'])) ? $options['title'] : singleton($class)->i18n_singular_name(),
'ClassName' => $class,
'Content' => $this->$class()->getModelSidebar()
'Link' => $this->Link($class),
'LinkOrCurrent' => ($class == $this->modelClass) ? 'current' : 'link'
)));
}
@ -287,177 +246,28 @@ abstract class ModelAdmin extends LeftAndMain {
* with a default {@link CsvBulkLoader} class. In this case the column names of the first row
* in the CSV file are assumed to have direct mappings to properties on the object.
*
* @return array
* @return array Map of model class names to importer instances
*/
function getModelImporters() {
$importers = $this->stat('model_importers');
$importerClasses = $this->stat('model_importers');
// fallback to all defined models if not explicitly defined
if(is_null($importers)) {
if(is_null($importerClasses)) {
$models = $this->getManagedModels();
foreach($models as $modelName => $options) {
if(is_numeric($modelName)) $modelName = $options;
$importers[$modelName] = 'CsvBulkLoader';
$importerClasses[$modelName] = 'CsvBulkLoader';
}
}
$importers = array();
foreach($importerClasses as $modelClass => $importerClass) {
$importers[$modelClass] = new $importerClass($modelClass);
}
return $importers;
}
}
/**
* Handles a managed model class and provides default collection filtering behavior.
*
* @package cms
* @subpackage core
*/
class ModelAdmin_CollectionController extends Controller {
public $parentController;
protected $modelClass;
public $showImportForm = null;
static $url_handlers = array(
'$Action' => 'handleActionOrID'
);
function __construct($parent, $model) {
$this->parentController = $parent;
$this->modelClass = $model;
parent::__construct();
}
/**
* Appends the model class to the URL.
*
* @param string $action
* @return string
*/
function Link($action = null) {
return $this->parentController->Link(Controller::join_links($this->modelClass, $action));
}
/**
* Return the class name of the model being managed.
*
* @return unknown
*/
function getModelClass() {
return $this->modelClass;
}
/**
* Delegate to different control flow, depending on whether the
* URL parameter is a number (record id) or string (action).
*
* @param unknown_type $request
* @return unknown
*/
function handleActionOrID($request) {
if (is_numeric($request->param('Action'))) {
return $this->handleID($request);
} else {
return $this->handleAction($request);
}
}
/**
* Delegate to the RecordController if a valid numeric ID appears in the URL
* segment.
*
* @param SS_HTTPRequest $request
* @return RecordController
*/
public function handleID($request) {
$class = $this->parentController->getRecordControllerClass($this->getModelClass());
return new $class($this, $request);
}
// -----------------------------------------------------------------------------------------------------------------
/**
* Get a combination of the Search, Import and Create forms that can be inserted into a {@link ModelAdmin} sidebar.
*
* @return string
*/
public function getModelSidebar() {
return $this->renderWith('ModelSidebar');
}
/**
* Get a search form for a single {@link DataObject} subclass.
*
* @return Form
*/
public function SearchForm() {
$SNG_model = singleton($this->modelClass);
$context = $SNG_model->getDefaultSearchContext();
$fields = $context->getSearchFields();
$columnSelectionField = $this->ColumnSelectionField();
$fields->push($columnSelectionField);
$validator = ($SNG_model->hasMethod('getCMSValidator')) ? $SNG_model->getCMSValidator() : new RequiredFields();
$clearAction = new ResetFormAction('clearsearch', _t('ModelAdmin.CLEAR_SEARCH','Clear Search'));
$form = new Form($this, "SearchForm",
$fields,
new FieldList(
new FormAction('search', _t('MemberTableField.SEARCH', 'Search')),
$clearAction
),
$validator
);
//$form->setFormAction(Controller::join_links($this->Link(), "search"));
$form->setFormMethod('get');
$form->setHTMLID("Form_SearchForm_" . $this->modelClass);
$form->disableSecurityToken();
$clearAction->setUseButtonTag(true);
$clearAction->addExtraClass('ss-ui-action-minor');
return $form;
}
/**
* Create a form that consists of one button
* that directs to a give model's Add form
*/
public function CreateForm() {
$modelName = $this->modelClass;
$SNG_model = singleton($modelName);
if($this->hasMethod('alternatePermissionCheck')) {
if(!$this->alternatePermissionCheck()) return false;
} else {
if(!$SNG_model->canCreate(Member::currentUser())) return false;
}
$buttonLabel = sprintf(_t('ModelAdmin.CREATEBUTTON', "Create '%s'", PR_MEDIUM, "Create a new instance from a model class"), $SNG_model->i18n_singular_name());
$validator = ($SNG_model->hasMethod('getCMSValidator')) ? $SNG_model->getCMSValidator() : new RequiredFields();
$createButton = FormAction::create('add', $buttonLabel)->addExtraClass('ss-ui-action-constructive')->setAttribute('data-icon', 'accept');
$form = new Form($this, "CreateForm",
new FieldList(),
new FieldList($createButton),
$validator
);
$createButton->dontEscape = true;
$form->setHTMLID("Form_CreateForm_" . $this->modelClass);
return $form;
}
/**
* Checks if a CSV import form should be generated by a className criteria or in general for ModelAdmin.
*/
function showImportForm() {
if($this->showImportForm === null) return $this->parentController->showImportForm;
else return $this->showImportForm;
}
/**
* Generate a CSV import form for a single {@link DataObject} subclass.
*
@ -466,8 +276,11 @@ class ModelAdmin_CollectionController extends Controller {
public function ImportForm() {
$modelName = $this->modelClass;
// check if a import form should be generated
if(!$this->showImportForm() || (is_array($this->showImportForm()) && !in_array($modelName,$this->showImportForm()))) return false;
$importers = $this->parentController->getModelImporters();
if(!$this->showImportForm || (is_array($this->showImportForm) && !in_array($modelName,$this->showImportForm))) {
return false;
}
$importers = $this->getModelImporters();
if(!$importers || !isset($importers[$modelName])) return false;
if(!singleton($modelName)->canCreate(Member::currentUser())) return false;
@ -508,7 +321,10 @@ class ModelAdmin_CollectionController extends Controller {
$fields,
$actions
);
$form->setHTMLID("Form_ImportForm_" . $this->modelClass);
$form->setFormAction(Controller::join_links($this->Link($this->modelClass), 'ImportForm'));
$this->extend('updateImportForm', $form);
return $form;
}
@ -524,14 +340,12 @@ class ModelAdmin_CollectionController extends Controller {
* @param SS_HTTPRequest $request
*/
function import($data, $form, $request) {
if(!$this->showImportForm || (is_array($this->showImportForm) && !in_array($this->modelClass,$this->showImportForm))) {
return false;
}
$modelName = $data['ClassName'];
if(!$this->showImportForm() || (is_array($this->showImportForm()) && !in_array($modelName,$this->showImportForm()))) return false;
$importers = $this->parentController->getModelImporters();
$importerClass = $importers[$modelName];
$loader = new $importerClass($data['ClassName']);
$importers = $this->getModelImporters();
$loader = $importers[$this->modelClass];
// File wasn't properly uploaded, show a reminder to the user
if(
@ -566,467 +380,38 @@ class ModelAdmin_CollectionController extends Controller {
$form->sessionMessage($message, 'good');
$this->redirectBack();
}
/**
* Return the columns available in the column selection field.
* Overload this to make other columns available
*/
public function columnsAvailable() {
return singleton($this->modelClass)->summaryFields();
}
/**
* Return the columns selected by default in the column selection field.
* Overload this to make other columns selected by default
*/
public function columnsSelectedByDefault() {
return array_keys(singleton($this->modelClass)->summaryFields());
}
/**
* Give the flexibilility to show variouse combination of columns in the search result table
*/
public function ColumnSelectionField() {
$model = singleton($this->modelClass);
$source = $this->columnsAvailable();
// select all fields by default
$value = $this->columnsSelectedByDefault();
// Reorder the source so that you read items down the column and then across
$columnisedSource = array();
$keys = array_keys($source);
$midPoint = ceil(sizeof($source)/2);
for($i=0;$i<$midPoint;$i++) {
$key1 = $keys[$i];
$columnisedSource[$key1] = $model->fieldLabel($source[$key1]);
// If there are an odd number of items, the last item will be unset
if(isset($keys[$i+$midPoint])) {
$key2 = $keys[$i+$midPoint];
$columnisedSource[$key2] = $model->fieldLabel($source[$key2]);
}
}
$checkboxes = new CheckboxSetField("ResultAssembly", false, $columnisedSource, $value);
$field = new CompositeField(
new LiteralField(
"ToggleResultAssemblyLink",
sprintf("<a class=\"form_frontend_function toggle_result_assembly\" href=\"#\">%s</a>",
_t('ModelAdmin.CHOOSE_COLUMNS', 'Select result columns...')
)
),
$checkboxesBlock = new CompositeField(
$checkboxes,
new LiteralField("ClearDiv", "<div class=\"clear\"></div>"),
new LiteralField(
"TickAllAssemblyLink",
sprintf(
"<a class=\"form_frontend_function tick_all_result_assembly\" href=\"#\">%s</a>",
_t('ModelAdmin.SELECTALL', 'select all')
)
),
new LiteralField(
"UntickAllAssemblyLink",
sprintf(
"<a class=\"form_frontend_function untick_all_result_assembly\" href=\"#\">%s</a>",
_t('ModelAdmin.SELECTNONE', 'select none')
)
)
)
);
$field->addExtraClass("ResultAssemblyBlock");
$checkboxesBlock->addExtraClass("hidden");
return $field;
}
/**
* Action to render a data object collection, using the model context to provide filters
* and paging.
*
* @return string
*/
function search($request, $form) {
// Get the results form to be rendered
$resultsForm = $this->ResultsForm(array_merge($form->getData(), $request));
return $resultsForm->forTemplate();
}
/**
* Gets the search query generated on the SearchContext from
* {@link DataObject::getDefaultSearchContext()},
* and the current GET parameters on the request.
*
* @return SQLQuery
*/
function getSearchQuery($searchCriteria) {
$context = singleton($this->modelClass)->getDefaultSearchContext();
return $context->getQuery($searchCriteria);
}
/**
* Returns all columns used for tabular search results display.
* Defaults to all fields specified in {@link DataObject->summaryFields()}.
*
* @param array $searchCriteria Limit fields by populating the 'ResultsAssembly' key
* @param boolean $selectedOnly Limit by 'ResultsAssempty
*/
function getResultColumns($searchCriteria, $selectedOnly = true) {
$model = singleton($this->modelClass);
$summaryFields = $this->columnsAvailable();
if($selectedOnly && isset($searchCriteria['ResultAssembly'])) {
$resultAssembly = $searchCriteria['ResultAssembly'];
if(!is_array($resultAssembly)) {
$explodedAssembly = split(' *, *', $resultAssembly);
$resultAssembly = array();
foreach($explodedAssembly as $item) $resultAssembly[$item] = true;
}
return array_intersect_key($summaryFields, $resultAssembly);
} else {
return $summaryFields;
}
}
/**
* Creates and returns the result table field for resultsForm.
* Uses {@link resultsTableClassName()} to initialise the formfield.
* Method is called from {@link ResultsForm}.
*
* @param array $searchCriteria passed through from ResultsForm
*
* @return GridField
*/
function getResultsTable($searchCriteria) {
$className = $this->parentController->resultsTableClassName();
$datalist = $this->getSearchQuery($searchCriteria);
$numItemsPerPage = $this->parentController->stat('page_length');
$tf = Object::create($className,
$this->modelClass,
false,
$datalist,
$fieldConfig = GridFieldConfig_RecordEditor::create($numItemsPerPage)
->addComponent(new GridFieldExportButton())->removeComponentsByType('GridFieldFilterHeader')
)->setDisplayFields($this->getResultColumns($searchCriteria));
return $tf;
}
/**
* Shows results from the "search" action in a TableListField.
*
* @uses getResultsTable()
*
* @return Form
*/
function ResultsForm($searchCriteria) {
if($searchCriteria instanceof SS_HTTPRequest) $searchCriteria = $searchCriteria->getVars();
$tf = $this->getResultsTable($searchCriteria);
// implemented as a form to enable further actions on the resultset
// (serverside sorting, export as CSV, etc)
$form = new Form(
$this,
'ResultsForm',
new FieldList(
new HeaderField('SearchResults', _t('ModelAdmin.SEARCHRESULTS','Search Results'), 2),
$tf
),
new FieldList()
);
// Include the search criteria on the results form URL, but not dodgy variables like those below
$filteredCriteria = $searchCriteria;
unset($filteredCriteria['ctf']);
unset($filteredCriteria['url']);
unset($filteredCriteria['action_search']);
$form->setFormAction($this->Link() . '/ResultsForm?' . http_build_query($filteredCriteria));
return $form;
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Create a new model record.
*
* @param unknown_type $request
* @return unknown
*/
function add($request) {
return new SS_HTTPResponse(
$this->AddForm()->forTemplate(),
200,
sprintf(
_t('ModelAdmin.ADDFORM', "Fill out this form to add a %s to the database."),
$this->modelClass
)
);
}
/**
* Returns a form suitable for adding a new model, falling back on the default edit form.
*
* Caution: The add-form shows a DataObject's {@link DataObject->getCMSFields()} method on a record
* that doesn't exist in the database yet, hence has no ID. This means the {@link DataObject->getCMSFields()}
* implementation has to ensure that no fields are added which would rely on a
* record ID being present, e.g. {@link HasManyComplexTableField}.
*
* Example:
* <code>
* function getCMSFields() {
* $fields = parent::getCMSFields();
* if($this->exists()) {
* $ctf = new HasManyComplexTableField($this, 'MyRelations', 'MyRelation');
* $fields->addFieldToTab('Root.Main', $ctf);
* }
* return $fields;
* }
* </code>
*
* @return Form
*/
public function AddForm() {
$newRecord = new $this->modelClass();
if($newRecord->canCreate()){
if($newRecord->hasMethod('getCMSAddFormFields')) {
$fields = $newRecord->getCMSAddFormFields();
} else {
$fields = $newRecord->getCMSFields();
}
$validator = ($newRecord->hasMethod('getCMSValidator')) ? $newRecord->getCMSValidator() : new RequiredFields();
$actions = new FieldList (
FormAction::create("doCreate", _t('ModelAdmin.ADDBUTTON', "Add"))
->addExtraClass('ss-ui-action-constructive')->setAttribute('data-icon', 'accept')
);
$form = new Form($this, "AddForm", $fields, $actions, $validator);
$form->loadDataFrom($newRecord);
$form->addExtraClass('cms-edit-form');
return $form;
}
}
function doCreate($data, $form, $request) {
$className = $this->getModelClass();
$model = new $className();
// We write before saveInto, since this will let us save has-many and many-many relationships :-)
$model->write();
$form->saveInto($model);
$model->write();
if($this->isAjax()) {
$class = $this->parentController->getRecordControllerClass($this->getModelClass());
$recordController = new $class($this, $request, $model->ID);
return new SS_HTTPResponse(
$recordController->EditForm()->forTemplate(),
200,
sprintf(
_t('ModelAdmin.LOADEDFOREDITING', "Loaded '%s' for editing."),
$model->Title
)
);
} else {
Director::redirect(Controller::join_links($this->Link(), $model->ID , 'edit'));
}
}
/**
* @return ArrayList
*/
public function Breadcrumbs(){
return new ArrayList();
}
}
public function Breadcrumbs($unlinked = false) {
$items = parent::Breadcrumbs($unlinked);
/**
* Handles operations on a single record from a managed model.
*
* @package cms
* @subpackage core
* @todo change the parent controller varname to indicate the model scaffolding functionality in ModelAdmin
*/
class ModelAdmin_RecordController extends Controller {
protected $parentController;
protected $currentRecord;
static $allowed_actions = array('edit', 'view', 'EditForm', 'ViewForm');
function __construct($parentController, $request, $recordID = null) {
$this->parentController = $parentController;
$modelName = $parentController->getModelClass();
$recordID = ($recordID) ? $recordID : $request->param('Action');
$this->currentRecord = DataObject::get_by_id($modelName, $recordID);
parent::__construct();
}
/**
* Link fragment - appends the current record ID to the URL.
*/
public function Link($action = null) {
return $this->parentController->Link(Controller::join_links($this->currentRecord->ID, $action));
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Edit action - shows a form for editing this record
*/
function edit($request) {
if ($this->currentRecord) {
if($this->isAjax()) {
$this->response->setBody($this->EditForm()->forTemplate());
$this->response->setStatusCode(
200,
sprintf(
_t('ModelAdmin.LOADEDFOREDITING', "Loaded '%s' for editing."),
$this->currentRecord->Title
)
);
return $this->response;
} else {
// This is really quite ugly; to fix will require a change in the way that customise() works. :-(
return $this->parentController->parentController->customise(array(
'Content' => $this->parentController->parentController->customise(array(
'EditForm' => $this->EditForm()
))->renderWith(array("{$this->class}_Content",'ModelAdmin_Content', 'LeftAndMain_Content'))
))->renderWith(array('ModelAdmin', 'LeftAndMain'));
}
// Show the class name rather than ModelAdmin title as root node
$models = $this->getManagedModels();
$modelSpec = ArrayLib::is_associative($models) ? $models[$this->modelClass] : null;
if(is_array($modelSpec) && isset($modelSpec['title'])) {
$items[0]->Title = $modelSpec['title'];
} else {
return _t('ModelAdmin.ITEMNOTFOUND', "I can't find that item");
$items[0]->Title = singleton($this->modelClass)->i18n_singular_name();
}
return $items;
}
/**
* Returns a form for editing the attached model
* overwrite the static page_length of the admin panel,
* should be called in the project _config file.
*/
public function EditForm() {
$fields = $this->currentRecord->getCMSFields();
$fields->push(new HiddenField("ID"));
if($this->currentRecord->hasMethod('Link')) {
$fields->push(new LiteralField('SilverStripeNavigator', $this->getSilverStripeNavigator()));
}
$validator = ($this->currentRecord->hasMethod('getCMSValidator')) ? $this->currentRecord->getCMSValidator() : new RequiredFields();
$actions = $this->currentRecord->getCMSActions();
if($this->currentRecord->canEdit(Member::currentUser())){
if(!$actions->fieldByName('action_doSave') && !$actions->fieldByName('action_save')) {
$actions->push(
FormAction::create("doSave", _t('ModelAdmin.SAVE', "Save"))
->addExtraClass('ss-ui-action-constructive')->setAttribute('data-icon', 'accept')
);
}
}else{
$fields = $fields->makeReadonly();
}
if($this->currentRecord->canDelete(Member::currentUser())) {
if(!$actions->fieldByName('action_doDelete')) {
$actions->unshift(
FormAction::create('doDelete', _t('ModelAdmin.DELETE', 'Delete'))
->addExtraClass('ss-ui-action-destructive')->setAttribute('data-icon', 'delete')
);
}
}
$form = new Form($this, "EditForm", $fields, $actions, $validator);
$form->loadDataFrom($this->currentRecord);
$form->addExtraClass('cms-edit-form');
return $form;
static function set_page_length($length){
self::$page_length = $length;
}
/**
* Postback action to save a record
*
* @param array $data
* @param Form $form
* @param SS_HTTPRequest $request
* @return mixed
*/
function doSave($data, $form, $request) {
$form->saveInto($this->currentRecord);
try {
$this->currentRecord->write();
} catch(ValidationException $e) {
$form->sessionMessage($e->getResult()->message(), 'bad');
}
// Behaviour switched on .
if($this->parentController->isAjax()) {
return $this->edit($request);
} else {
$this->parentController->redirectBack();
}
}
/**
* Delete the current record
* Return the static page_length of the admin, default as 30
*/
public function doDelete($data, $form, $request) {
if($this->currentRecord->canDelete(Member::currentUser())) {
$this->currentRecord->delete();
Director::redirect($this->parentController->Link('SearchForm?action=search'));
} else {
$this->parentController->redirectBack();
}
return;
}
static function get_page_length(){
return self::$page_length;
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Renders the record view template.
*
* @param SS_HTTPRequest $request
* @return mixed
*/
function view($request) {
if($this->currentRecord) {
$form = $this->ViewForm();
return $form->forTemplate();
} else {
return _t('ModelAdmin.ITEMNOTFOUND');
}
}
/**
* Returns a form for viewing the attached model
*
* @return Form
*/
public function ViewForm() {
$fields = $this->currentRecord->getCMSFields();
$form = new Form($this, "EditForm", $fields, new FieldList());
$form->loadDataFrom($this->currentRecord);
$form->makeReadonly();
return $form;
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////
function index() {
Director::redirect(Controller::join_links($this->Link(), 'edit'));
}
function getCurrentRecord(){
return $this->currentRecord;
}
}
}

View File

@ -265,11 +265,11 @@ body.cms { overflow: hidden; }
.cms-edit-form .cms-content-header-tabs .ui-tabs-nav .ui-state-default a, .cms-edit-form .cms-content-header-tabs .ui-tabs-nav .ui-widget-content .ui-state-default a, .cms-edit-form .cms-content-header-tabs .ui-tabs-nav .ui-widget-header .ui-state-default a { color: #1f1f1f; }
/** -------------------------------------------- Tabs -------------------------------------------- */
.ui-tabs .cms-content-header .ui-tabs-nav li, .cms-dialog .ui-tabs-nav li { margin: 0; }
.ui-tabs .cms-content-header .ui-tabs-nav li a, .cms-dialog .ui-tabs-nav li a { font-weight: bold; line-height: 16px; padding: 12px 20px 11px; }
.ui-tabs .cms-content-header .ui-tabs-nav .ui-state-default, .ui-tabs .cms-content-header .ui-tabs-nav .ui-widget-content .ui-state-default, .ui-tabs .cms-content-header .ui-tabs-nav .ui-widget-header .ui-state-default, .cms-dialog .ui-tabs-nav .ui-state-default, .cms-dialog .ui-tabs-nav .ui-widget-content .ui-state-default, .cms-dialog .ui-tabs-nav .ui-widget-header .ui-state-default { background-color: #b0bec7; background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4gPHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJncmFkIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeDE9IjUwJSIgeTE9IjAlIiB4Mj0iNTAlIiB5Mj0iMTAwJSI+PHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iI2IwYmVjNyIvPjxzdG9wIG9mZnNldD0iMTAwJSIgc3RvcC1jb2xvcj0iIzhjYTFhZSIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9InVybCgjZ3JhZCkiIC8+PC9zdmc+IA=='); background-size: 100%; background-image: -webkit-gradient(linear, 50% 0%, 50% 100%, color-stop(0%, #b0bec7), color-stop(100%, #8ca1ae)); background-image: -webkit-linear-gradient(#b0bec7, #8ca1ae); background-image: -moz-linear-gradient(#b0bec7, #8ca1ae); background-image: -o-linear-gradient(#b0bec7, #8ca1ae); background-image: -ms-linear-gradient(#b0bec7, #8ca1ae); background-image: linear-gradient(#b0bec7, #8ca1ae); border-right-color: #a6a6a6; border-left-color: #d9d9d9; border-bottom: none; text-shadow: white 0 1px 0; }
.ui-tabs .cms-content-header .ui-tabs-nav .ui-state-active, .ui-tabs .cms-content-header .ui-tabs-nav .ui-widget-content .ui-state-active, .ui-tabs .cms-content-header .ui-tabs-nav .ui-widget-header .ui-state-active, .cms-dialog .ui-tabs-nav .ui-state-active, .cms-dialog .ui-tabs-nav .ui-widget-content .ui-state-active, .cms-dialog .ui-tabs-nav .ui-widget-header .ui-state-active { background: #eceff1; border-right-color: #a6a6a6; border-left-color: #a6a6a6; margin-right: -1px; margin-left: -1px; z-index: 2; }
.ui-tabs .cms-content-header .ui-tabs-nav .ui-state-active a, .ui-tabs .cms-content-header .ui-tabs-nav .ui-widget-content .ui-state-active a, .ui-tabs .cms-content-header .ui-tabs-nav .ui-widget-header .ui-state-active a, .cms-dialog .ui-tabs-nav .ui-state-active a, .cms-dialog .ui-tabs-nav .ui-widget-content .ui-state-active a, .cms-dialog .ui-tabs-nav .ui-widget-header .ui-state-active a { border-bottom: none; }
.cms-content-header .ui-tabs-nav li, .cms-dialog .ui-tabs-nav li { margin: 0; }
.cms-content-header .ui-tabs-nav li a, .cms-dialog .ui-tabs-nav li a { font-weight: bold; line-height: 16px; padding: 12px 20px 11px; }
.cms-content-header .ui-tabs-nav .ui-state-default, .cms-content-header .ui-tabs-nav .ui-widget-content .ui-state-default, .cms-content-header .ui-tabs-nav .ui-widget-header .ui-state-default, .cms-dialog .ui-tabs-nav .ui-state-default, .cms-dialog .ui-tabs-nav .ui-widget-content .ui-state-default, .cms-dialog .ui-tabs-nav .ui-widget-header .ui-state-default { background-color: #b0bec7; background-image: -webkit-gradient(linear, 50% 0%, 50% 100%, color-stop(0%, #b0bec7), color-stop(100%, #8ca1ae)); background-image: -webkit-linear-gradient(#b0bec7, #8ca1ae); background-image: -moz-linear-gradient(#b0bec7, #8ca1ae); background-image: -o-linear-gradient(#b0bec7, #8ca1ae); background-image: -ms-linear-gradient(#b0bec7, #8ca1ae); background-image: linear-gradient(#b0bec7, #8ca1ae); border-right-color: #a6a6a6; border-left-color: #d9d9d9; border-bottom: none; text-shadow: white 0 1px 0; }
.cms-content-header .ui-tabs-nav .ui-state-active, .cms-content-header .ui-tabs-nav .ui-widget-content .ui-state-active, .cms-content-header .ui-tabs-nav .ui-widget-header .ui-state-active, .cms-dialog .ui-tabs-nav .ui-state-active, .cms-dialog .ui-tabs-nav .ui-widget-content .ui-state-active, .cms-dialog .ui-tabs-nav .ui-widget-header .ui-state-active { background: #eceff1; border-right-color: #a6a6a6; border-left-color: #a6a6a6; margin-right: -1px; margin-left: -1px; z-index: 2; }
.cms-content-header .ui-tabs-nav .ui-state-active a, .cms-content-header .ui-tabs-nav .ui-widget-content .ui-state-active a, .cms-content-header .ui-tabs-nav .ui-widget-header .ui-state-active a, .cms-dialog .ui-tabs-nav .ui-state-active a, .cms-dialog .ui-tabs-nav .ui-widget-content .ui-state-active a, .cms-dialog .ui-tabs-nav .ui-widget-header .ui-state-active a { border-bottom: none; }
.CMSPagesController .cms-content-header-tabs .ui-tabs-nav li a { font-weight: bold; line-height: 16px; padding: 12px 20px 11px; text-indent: -9999em; }
.CMSPagesController .cms-content-header-tabs .ui-tabs-nav li a.content-treeview { background: url(../images/content-header-tabs-sprite.png) no-repeat 2px 0px; }
@ -338,7 +338,7 @@ body.cms { overflow: hidden; }
/* -------------------------------------------------------- Content Tools is the sidebar on the left of the main content panel */
.cms-content-tools { background-color: #dde3e7; width: 192px; border-right: 1px solid #bfcad2; overflow-y: auto; overflow-x: hidden; z-index: 70; -moz-box-shadow: rgba(107, 120, 123, 0.5) 0 0 4px; -webkit-box-shadow: rgba(107, 120, 123, 0.5) 0 0 4px; -o-box-shadow: rgba(107, 120, 123, 0.5) 0 0 4px; box-shadow: rgba(107, 120, 123, 0.5) 0 0 4px; float: left; position: relative; }
.cms-content-tools .cms-panel-header { margin: 0 0 7px; line-height: 24px; border-bottom: 1px solid rgba(201, 205, 206, 0.8); -webkit-box-shadow: 0 1px 0 rgba(228, 230, 230, 0.8); -moz-box-shadow: 0 1px 0 rgba(228, 230, 230, 0.8); -o-box-shadow: 0 1px 0 rgba(228, 230, 230, 0.8); box-shadow: 0 1px 0 rgba(228, 230, 230, 0.8); }
.cms-content-tools .cms-panel-header { clear: both; margin: 0 0 7px; line-height: 24px; border-bottom: 1px solid rgba(201, 205, 206, 0.8); -webkit-box-shadow: 0 1px 0 rgba(228, 230, 230, 0.8); -moz-box-shadow: 0 1px 0 rgba(228, 230, 230, 0.8); -o-box-shadow: 0 1px 0 rgba(228, 230, 230, 0.8); box-shadow: 0 1px 0 rgba(228, 230, 230, 0.8); }
.cms-content-tools .cms-panel-content { width: 176px; padding: 8px 8px; overflow: auto; height: 100%; }
.cms-content-tools .cms-content-header { background-color: #748d9d; background-image: url('data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4gPHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJncmFkIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgeDE9IjUwJSIgeTE9IjAlIiB4Mj0iNTAlIiB5Mj0iMTAwJSI+PHN0b3Agb2Zmc2V0PSIwJSIgc3RvcC1jb2xvcj0iI2IwYmVjNyIvPjxzdG9wIG9mZnNldD0iMTAwJSIgc3RvcC1jb2xvcj0iIzc0OGQ5ZCIvPjwvbGluZWFyR3JhZGllbnQ+PC9kZWZzPjxyZWN0IHg9IjAiIHk9IjAiIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIGZpbGw9InVybCgjZ3JhZCkiIC8+PC9zdmc+IA=='); background-size: 100%; background-image: -webkit-gradient(linear, 50% 0%, 50% 100%, color-stop(0%, #b0bec7), color-stop(100%, #748d9d)); background-image: -webkit-linear-gradient(#b0bec7, #748d9d); background-image: -moz-linear-gradient(#b0bec7, #748d9d); background-image: -o-linear-gradient(#b0bec7, #748d9d); background-image: -ms-linear-gradient(#b0bec7, #748d9d); background-image: linear-gradient(#b0bec7, #748d9d); }
.cms-content-tools .cms-content-header h2 { text-shadow: #5c7382 -1px -1px 0; width: 176px; color: white; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; o-text-overflow: ellipsis; }
@ -475,6 +475,9 @@ body.cms-dialog { overflow: auto; background: url("../images/textures/bg_cms_mai
.htmleditorfield-mediaform .ss-htmleditorfield-file .overview .action-delete { display: inline-block; }
.htmleditorfield-mediaform .ss-htmleditorfield-file .details { padding: 16px; }
/** -------------------------------------------- Search forms (used in AssetAdmin, ModelAdmin, etc) -------------------------------------------- */
.cms-search-form { overflow: auto; margin-bottom: 16px; }
/** -------------------------------------------- Step labels -------------------------------------------- */
.step-label > * { display: inline-block; vertical-align: top; }
.step-label .flyout { height: 18px; font-size: 14px; font-weight: bold; -moz-border-radius-topleft: 3px; -webkit-border-top-left-radius: 3px; -o-border-top-left-radius: 3px; -ms-border-top-left-radius: 3px; -khtml-border-top-left-radius: 3px; border-top-left-radius: 3px; -moz-border-radius-bottomleft: 3px; -webkit-border-bottom-left-radius: 3px; -o-border-bottom-left-radius: 3px; -ms-border-bottom-left-radius: 3px; -khtml-border-bottom-left-radius: 3px; border-bottom-left-radius: 3px; background-color: #667980; padding: 4px 3px 4px 6px; text-align: center; text-shadow: none; color: #fff; }
@ -663,20 +666,5 @@ li.class-ErrorPage > a .jstree-pageicon { background-position: 0 -112px; }
.cms-menu-list.collapsed li .text, .cms-menu-list.collapsed li .toggle-children { display: none; }
.cms-menu-list.collapsed li > li { display: none; }
/** -------------------------------------------- ModelAdmin -------------------------------------------- */
.ModelAdmin .ResultAssemblyBlock { display: none; }
.ModelAdmin .cms-content-tools h3.cms-panel-header { display: none; }
.ModelAdmin .cms-content-tools #SearchForm_holder ul.ui-tabs-nav { overflow: hidden; }
.ModelAdmin .cms-content-tools #SearchForm_holder ul.ui-tabs-nav li { max-width: 99%; }
.ModelAdmin .cms-content-tools #SearchForm_holder ul.ui-tabs-nav li a { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 85%; }
.ModelAdmin .cms-content-tools #SearchForm_holder #ModelClassSelector { margin-bottom: 2px; }
.ModelAdmin .cms-content-tools #SearchForm_holder #ModelClassSelector select { width: 96%; }
.ModelAdmin .cms-content-tools #SearchForm_holder div.tab { border: 1px solid #aaa; margin-top: -1px; background: #fff; padding: 8px; }
.ModelAdmin .cms-content-tools #SearchForm_holder div.tab h3 { margin-top: 16px; margin-bottom: 10px; border-bottom: 1px solid rgba(201, 205, 206, 0.8); -webkit-box-shadow: 0 1px 0 rgba(228, 230, 230, 0.8); -moz-box-shadow: 0 1px 0 rgba(228, 230, 230, 0.8); -o-box-shadow: 0 1px 0 rgba(228, 230, 230, 0.8); box-shadow: 0 1px 0 rgba(228, 230, 230, 0.8); }
.ModelAdmin .cms-content-tools #SearchForm_holder div.tab form input { margin: 0px; }
.ModelAdmin .cms-content-tools #SearchForm_holder div.tab form .field { border-bottom: 0px; margin-bottom: 6px; }
.ModelAdmin .cms-content-tools #SearchForm_holder div.tab form .Actions { max-width: 100%; overflow: hidden; }
.ModelAdmin .cms-content-tools #SearchForm_holder div.tab form .Actions button.ss-ui-action-minor { display: none; }
.SecurityAdmin .cms-edit-form .cms-content-header h2 { display: none; }
.SecurityAdmin .permissioncheckboxset .optionset li, .SecurityAdmin .permissioncheckboxsetfield_readonly .optionset li { float: none; width: auto; }

View File

@ -1,180 +0,0 @@
/**
* File: ModelAdmin.History.js
*/
(function($) {
$.entwine('ss', function($){
/**
* Class: .ModelAdmin
*
* A simple ajax browser history implementation tailored towards
* navigating through search results and different forms loaded into
* the ModelAdmin right panels. The logic listens to search and form loading
* events, keeps track of the loaded URLs, and will display graphical back/forward
* buttons where appropriate. A search action will cause the history to be reset.
*
* Note: The logic does not replay save operations or hook into any form actions.
*
* Available Events:
* - historyAdd
* - historyStart
* - historyGoFoward
* - historyGoBack
*
* Todo:
* Switch tab state when re-displaying search forms
* Reload search parameters into forms
*/
$('.ModelAdmin').entwine({
/**
* Variable: History
*/
History: [],
/**
* Variable: Future
*/
Future: [],
onmatch: function() {
var self = this;
this._super();
// generate markup
this.find('#right').prepend(
'<div class="historyNav">'
+ '<a href="#" class="back">&lt; ' + ss.i18n._t('ModelAdmin.HISTORYBACK', 'back') + '</a>'
+ '<a href="#" class="forward">' + ss.i18n._t('ModelAdmin.HISTORYFORWARD', 'forward') + ' &gt;</a>'
+ '</div>'
).find('.back,.forward').hide();
this.find('.historyNav .back').live('click', function() {
self.goBack();
return false;
});
this.find('.historyNav .forward').live('click', function() {
self.goForward();
return false;
});
},
/**
* Function: redraw
*/
redraw: function() {
this.find('.historyNav .forward').toggle(Boolean(this.getFuture().length > 0));
this.find('.historyNav .back').toggle(Boolean(this.getHistory().length > 1));
this._super();
},
/**
* Function: startHistory
*
* Parameters:
* (String) url - ...
* (Object) data - ...
*/
startHistory: function(url, data) {
this.trigger('historyStart', {url: url, data: data});
this.setHistory([]);
this.addHistory(url, data);
},
/**
* Add an item to the history, to be accessed by goBack and goForward
*/
addHistory: function(url, data) {
this.trigger('historyAdd', {url: url, data: data});
// Combine data into URL
if(data) {
if(url.indexOf('?') == -1) url += '?' + $.param(data);
else url += '&' + $.param(data);
}
// Add to history
this.getHistory().push(url);
// Reset future
this.setFuture([]);
this.redraw();
},
/**
* Function: goBack
*/
goBack: function() {
if(this.getHistory() && this.getHistory().length) {
if(this.getFuture() == null) this.setFuture([]);
var currentPage = this.getHistory().pop();
var previousPage = this.getHistory()[this.getHistory().length-1];
this.getFuture().push(currentPage);
this.trigger('historyGoBack', {url:previousPage});
// load new location
$('.cms-content').loadForm(previousPage);
this.redraw();
}
},
/**
* Function: goForward
*/
goForward: function() {
if(this.getFuture() && this.getFuture().length) {
if(this.getFuture() == null) this.setFuture([]);
var nextPage = this.getFuture().pop();
this.getHistory().push(nextPage);
this.trigger('historyGoForward', {url:nextPage});
// load new location
$('.cms-content').loadForm(nextPage);
this.redraw();
}
}
});
/**
* Class: #SearchForm_holder form
*
* A search action will cause the history to be reset.
*/
$('#SearchForm_holder form').entwine({
onmatch: function() {
var self = this;
this.bind('beforeSubmit', function(e) {
$('.ModelAdmin').startHistory(
self.attr('action'),
self.serializeArray()
);
});
this._super();
}
});
/**
* Class: form[name=Form_ResultsForm] tbody td a
*
* We have to apply this to the result table buttons instead of the
* more generic form loading.
*/
$('form[name=Form_ResultsForm] tbody td a').entwine({
onclick: function(e) {
$('.ModelAdmin').addHistory(this.attr('href'));
}
});
});
})(jQuery);

View File

@ -15,7 +15,9 @@
this.find('ul').addClass('ui-tabs-nav ui-helper-reset ui-helper-clearfix ui-widget-header ui-corner-all');
this.find('li').addClass('ui-state-default ui-corner-top');
// TODO Figure out selected tab
this.find('li:first').selectIt();
var selected = this.find('li.current');
if(!selected.length) selected = this.find('li:first');
selected.selectIt();
}
});

View File

@ -1,78 +0,0 @@
/** --------------------------------------------
* ModelAdmin
* -------------------------------------------- */
.ModelAdmin {
// Disable by default, will be replaced by more intuitive column selection in new data grid
.ResultAssemblyBlock {
display: none;
}
.cms-content-tools {
h3.cms-panel-header {
display:none;
}
#SearchForm_holder {
ul.ui-tabs-nav {
overflow:hidden;
li {
max-width:99%;
a {
white-space: nowrap;
overflow: hidden;
text-overflow:ellipsis;
//above 3 lines can also be achieved with mixin below
//@include ellipsis;
max-width:85%;
}
}
}
#ModelClassSelector {
margin-bottom:2px;
select {
width:96%;
}
}
div.tab {
border:1px solid #aaa; //following color from the jquery smoothness theme
margin-top:-1px;
background:#fff; //backround is kept white to follow tabs
padding:$grid-x;
h3 {
margin-top:16px;
margin-bottom:10px;
@include doubleborder(bottom, $color-light-separator, lighten($color-light-separator, 10%))
}
form {
input {
margin:0px;
}
.field {
border-bottom:0px;
margin-bottom:6px;
}
.Actions {
max-width:100%;
overflow:hidden;
button.ss-ui-action-minor {
//removing the "clear search" button
display:none;
}
input.action {
//experimenting with text-overlow:ellipsis on action buttons
//currently disabled as this ignores padding-left on the buttons in Firefox
//and makes the text appear in front of the icon in Firefox 10
//See a screenshot here: http://db.tt/iBi39rRt
//white-space: nowrap;
//overflow: hidden;
//text-overflow:ellipsis;
//max-width:100%;
}
}
}
}
}
}
}

View File

@ -179,7 +179,7 @@ body.cms {
/** --------------------------------------------
* Tabs
* -------------------------------------------- */
.ui-tabs .cms-content-header .ui-tabs-nav, .cms-dialog .ui-tabs-nav {
.cms-content-header .ui-tabs-nav, .cms-dialog .ui-tabs-nav {
li {
margin:0;
a {
@ -568,6 +568,7 @@ body.cms {
position: relative;
.cms-panel-header {
clear: both;
margin: 0 0 $grid-y - 1;
line-height: $grid-y * 3;
@ -1237,6 +1238,14 @@ body.cms-dialog {
}
/** --------------------------------------------
* Search forms (used in AssetAdmin, ModelAdmin, etc)
* -------------------------------------------- */
.cms-search-form {
overflow: auto;
margin-bottom: $grid-y*2;
}
/** --------------------------------------------
* Step labels
* -------------------------------------------- */

View File

@ -1,18 +1,30 @@
<div class="cms-content center $BaseCSSClasses" data-layout-type="border">
<div class="cms-content-header north">
<div><h2>
<% if SectionTitle %>
$SectionTitle
<% else %>
<% _t('ModelAdmin.Title', 'Data Models') %>
<% end_if %>
</h2></div>
<div>
<h2>
<% if SectionTitle %>
$SectionTitle
<% else %>
<% _t('ModelAdmin.Title', 'Data Models') %>
<% end_if %>
</h2>
<div class="cms-content-header-tabs ss-ui-tabs-nav">
<ul>
<% control ManagedModelTabs %>
<li class="tab-$ClassName $LinkOrCurrent">
<a href="$Link" class="cms-panel-link">$Title</a>
</li>
<% end_control %>
</ul>
</div>
</div>
</div>
$Tools
<div class="cms-content-fields center ui-widget-content">
<div class="cms-content-fields center ui-widget-content" data-layout-type="border">
$Tools
$EditForm
</div>

View File

@ -1,5 +0,0 @@
<% if Results %>
$Form
<% else %>
<p><% sprintf(_t('ModelAdmin.NORESULTS', 'No results'), $ModelPluralName) %></p>
<% end_if %>

View File

@ -1,33 +1,12 @@
<div class="cms-content-tools west cms-panel cms-panel-layout" data-expandOnClick="true" data-layout-type="border">
<div class="cms-content-tools west cms-panel cms-panel-layout collapsed" id="cms-content-tools" data-expandOnClick="true" data-layout-type="border">
<div class="cms-panel-content center">
<h3 class="cms-panel-header"><% _t('Filter', 'Filter') %></h3>
<div id="SearchForm_holder" class="leftbottom ss-tabset">
<% if SearchClassSelector = tabs %>
<ul>
<% control ModelForms %>
<li class="$FirstLast"><a id="tab-ModelAdmin_$Title.HTMLATT" href="#Form_$ClassName">$Title</a></li>
<% end_control %>
</ul>
<% end_if %>
<h3 class="cms-panel-header"><% _t('FILTER', 'Filter') %></h3>
$SearchForm
<% if SearchClassSelector = dropdown %>
<div id="ModelClassSelector" class="ui-widget-container">
Search for:
<select>
<% control ModelForms %>
<option value="Form_$ClassName">$Title</option>
<% end_control %>
</select>
</div>
<% end_if %>
<% control ModelForms %>
<div class="tab" id="Form_$ClassName">
$Content
</div>
<% end_control %>
</div>
<h3 class="cms-panel-header"><% _t('IMPORT', 'Import') %></h3>
$ImportForm
</div>
<div class="cms-panel-content-collapsed">
<h3 class="cms-panel-header"><% _t('FILTER', 'Filter') %></h3>
</div>
</div>

View File

@ -37,7 +37,8 @@ unfortunately there is no clear upgrade path for every interface detail.
As a starting point, have a look at the new templates in `cms/templates`
and `sapphire/admin/templates`, as well as the new [jQuery.entwine](https://github.com/hafriedlander/jquery.entwine)
based JavaScript logic. Have a look at the new ["Extending the CMS" guide](../howto/extending-the-cms),
["CSS" guide](../topics/css) and ["JavaScript" guide](../topics/javascript) to get you started.
["CSS" guide](../topics/css), ["JavaScript" guide](../topics/javascript) and
["CMS Architecture" guide](/reference/cms-architecture) to get you started.
### New tree library ###
@ -60,6 +61,17 @@ which will help users understand the new "Add page" dialog.
For example, a `TeamPage` type could be described as "Lists all team members, linking to their profiles".
Note: This property is optional (defaults to an empty string), but its usage is highly encouraged.
### New ModelAdmin interface, removed sub-controllers
ModelAdmin has been substanially rewritten to natively support the `[api:GridField]` API
for more flexible data presentation (replacing `[api:ComplexTableField]`),
and the `[api:DataList]` API for more expressive querying.
If you have overwritten any methods in the class, customized templates,
or implemented your own `$collection_controller_class`/`$record_controller_class` controllers,
please refer to the new [ModelAdmin documentation](/reference/modeladmin)
on details for how to achieve the same goals in the new class.
### Stylesheet preprocessing via SCSS and the "compass" module ###
CSS files in the `cms` and `sapphire/admin` modules are now generated through

View File

@ -2,111 +2,221 @@
## Introduction
*Replaces GenericDataAdmin in Silverstripe 2.3*
Provides a simple way to utilize the SilverStripe CMS UI with your own data models,
and create searchable list and edit views of them, and even providing import and export of your data.
The ModelAdmin provides a simple way to utilize the SilverStripe CMS UI with your own custom data models. The
ModelAdmin uses the `[api:DataObject]`'s Scaffolding to create the search fields, forms, and displayed data within the
CMS.
It uses the framework's knowledge about the model to provide sensible defaults,
allowing you to get started in a couple of lines of code,
while still providing a solid base for customization.
In order to customize the ModelAdmin CMS interface you will need to understand how `[api:DataObject]` works.
The interface is mainly powered by the `[GridField](/topics/grid-field)` class,
which can also be used in other CMS areas (e.g. to manage a relation on a `SiteTree`
record in the standard CMS interface).
## Requirements
## Setup
*Requires Silverstripe 2.3*
## Usage
### Step 1
Extend ModelAdmin with a custom class for your admin area, and edit the `$managed_models` property with the list of
data objects you want to scaffold an interface for:
:::php
class MyCatalogAdmin extends ModelAdmin {
public static $managed_models = array( //since 2.3.2
'Product',
'Category'
);
static $url_segment = 'products'; // will be linked as /admin/products
static $menu_title = 'My Product Admin';
}
To add the ModelAdmin to your CMS menu, you simply need to define a couple of statics on your ModelAdmin subclass. See
`[api:LeftAndMain]` on how to make your menu title translatable.
### Step 2
Add a `$searchable_fields` (See `[api:ModelAdmin::$searchable_fields]`) property to your data
models, to define the fields and filters for the search interface:
Datamodel `Product`:
Let's assume we want to manage a simple product listing as a sample data model:
A product can have a name, price, and a category.
:::php
class Product extends DataObject {
static $db = array(
'Name' => 'Varchar',
'ProductCode' => 'Varchar',
'Description' => 'Text',
'Price' => 'Currency'
);
static $has_one = array(
'Category' => 'Category'
);
static $searchable_fields = array(
'Name',
'ProductCode'
);
static $db = array('Name' => 'Varchar', 'ProductCode' => 'Varchar', 'Price' => 'Currency');
static $has_one = array('Category' => 'Category');
}
class Category extends DataObject {
static $db = array('Title' => 'Text');
static $has_many = array('Products' => 'Product');
}
Datamodel `Category`:
To create your own `ModelAdmin`, simply extend the base class,
and edit the `$managed_models` property with the list of
data objects you want to scaffold an interface for.
The class can manage multiple models in parallel, if required.
We'll name it `MyAdmin`, but the class name can be anything you want.
:::php
<?php
class Category extends DataObject {
static $db = array(
'Title' => 'Text'
class MyAdmin extends ModelAdmin {
public static $managed_models = array('Product','Category'); // Can manage multiple models
static $url_segment = 'products'; // Linked as /admin/products/
static $menu_title = 'My Product Admin';
}
This will automatically add a new menu entry to the CMS, and you're ready to go!
Try opening http://localhost/admin/products/?flush=all.
## Search Fields
ModelAdmin uses the `[SearchContext](/reference/searchcontext)` class to provide
a search form, as well as get the searched results. Every DataObject can have its own context,
based on the fields which should be searchable. The class makes a guess at how those fields
should be searched, e.g. showing a checkbox for any boolean fields in your `$db` definition.
To remove, add or modify searchable fields, define a new `[$searchable_fields](api:DataObject::$searchable_fields)`
static on your model class (see `[SearchContext](/reference/searchcontext)` docs for details).
:::php
class Product extends DataObject {
// ...
static $searchable_fields = array(
'Name',
'ProductCode'
// leaves out the 'Price' field, removing it from the search
);
}
?>
For a more sophisticated customization, for example configuring the form fields
for the search form, override `[api:DataObject->getCustomSearchContext()]` on your model class.
### Step 3
You can now log in to the main CMS admin and manage your data objects, with no extra implementation required.
## Result Columns
![](_images/modeladmin_edit.png)
The results are shown in a tabular listing, powered by the `[GridField](/topics/grid-field)`,
more specifically the `[api:GridFieldDataColumns]` component.
It looks for a `[api:DataObject::$summary_fields]` static on your model class,
where you can add or remove columns, or change their title.
![](_images/modeladmin_results.png)
:::php
class Product extends DataObject {
// ...
static $summary_fields = array(
'Name' => 'Name',
'Price' => 'Cost', // renames the column to "Cost"
// leaves out the 'ProductCode' field, removing the column
);
}
### Note about has_one
## Results Customization
Scaffolding **has_one** relationships in your ModelAdmin relies on a column in the related model to be named **Title**
or **Name** of a string type (varchar, char, etc). These will be pulled in to the dropdown when creating a new object.
The results are retrieved from `[api:SearchContext->getResults()]`,
based on the parameters passed through the search form.
If no search parameters are given, the results will show every record.
Results are a `[api:DataList]` instance, so can be customized by additional
SQL filters, joins, etc (see [datamodel](/topics/datamodel) for more info).
If you are seeing a list of ID#s when creating new objects, ensure you have one of those two in the related model.
For example, we might want to exclude all products without prices in our sample `MyAdmin` implementation.
## Searchable Fields
:::php
class MyAdmin extends ModelAdmin {
// ...
function getList() {
$list = parent::getList();
// Always limit by model class, in case you're managing multiple
if($this->modelClass == 'Product') {
$list->exclude('Price', '0');
}
return $list;
}
}
You can customize the fields which are searchable for each managed DataObject class, as well as the ways in which the
fields are searched (e.g. "partial match", "fulltext", etc.) using `$searchable_fields`.
You can also customize the search behaviour directly on your `ModelAdmin` instance.
For example, we might want to have a checkbox which limits search results to expensive products (over $100).
* See `[api:DataObject]`
:::php
class MyAdmin extends ModelAdmin {
// ...
function getSearchContext() {
$context = parent::getSearchContext();
if($this->modelClass == 'Product') {
$context->getFields()->push(new CheckboxField('q[ExpensiveOnly]', 'Only expensive stuff'));
}
return $context;
}
function getList() {
$list = parent::getList();
$params = $this->request->requestVar('q'); // use this to access search parameters
if($this->modelClass == 'Product' && isset($params['ExpensiveOnly']) && $params['ExpensiveOnly']) {
$list->exclude('Price:LessThan', '100');
}
return $list;
}
}
![](_images/modeladmin_search.png)
## Managing Relationships
## Summary Fields
Has-one relationships are simply implemented as a `[api:DropdownField]` by default.
Consider replacing it with a more powerful interface in case you have many records
(through customizing `[api:DataObject->getCMSFields]`).
Summary Fields are the columns which are shown in the `[api:TableListField]` when viewing DataObjects. These can be
customized for each `[api:DataObject]`'s search results using `$summary_fields`.
Has-many and many-many relationships are usually handled via the `[GridField](/topics/grid-field)` class,
more specifically the `[api:GridFieldAddExistingAutocompleter]` and `[api:GridFieldRelationDelete]` components.
They provide a list/detail interface within a single record edited in your ModelAdmin.
* See `[api:DataObject]`
## Permissions
`ModelAdmin` respects the permissions set on the model, through methods on your `DataObject` implementations:
`canView()`, `canEdit()`, `canDelete()`, and `canCreate`.
In terms of access control to the interface itself, every `ModelAdmin` subclass
creates its own "[permission code](/reference/permissions)", which can be assigned
to groups through the `admin/security` management interface. To further limit
permission, either override checks in `ModelAdmin->init()`, or define
more permission codes through the `ModelAdmin::$required_permission_codes` static.
## Data Import
The `ModelAdmin` class provides import of CSV files through the `[api:CsvBulkLoader]` API.
which has support for column mapping, updating existing records,
and identifying relationships - so its a powerful tool to get your data into a SilverStripe database.
By default, each model management interface allows uploading a CSV file
with all columns autodetected. To override with a more specific importer implementation,
use the `[api:ModelAdmin::$model_importers] static.
## Data Export
Export is also available, although at the moment only to the CSV format,
through a button at the end of a results list. You can also export search results.
It is handled through the `[api:GridFieldExportButton]` component.
To customize the exported columns, override the edit form implementation in your `ModelAdmin`:
:::php
class MyAdmin extends ModelAdmin {
// ...
function getEditForm($id = null) {
$form = parent::getEditForm($id);
if($this->modelClass == 'Product') {
$grid = $form->Fields()->fieldByName('Product');
$grid->getConfig()->getComponentByType('GridFieldExporter')
->setExportColumns(array(
// Excludes 'ProductCode' from the export
'Name' => 'Name',
'Price' => 'Price'
));
}
return $form;
}
}
## Extending existing ModelAdmins
Sometimes you'll work with ModelAdmins from other modules, e.g. the product management
of an ecommerce module. To customize this, you can always subclass. But there's
also another tool at your disposal: The `[api:Extension]` API.
:::php
class MyAdminExtension extends Extension {
function updateEditForm(&$form) {
$form->Fields()->push(/* ... */)
}
}
// mysite/_config.php
Object::add_extension('MyAdmin', 'MyAdminExtension');
The following extension points are available: `updateEditForm()`, `updateSearchContext()`,
`updateSearchForm()`, `updateList()`, `updateImportForm`.
## Customizing the interface
Interfaces like `ModelAdmin` can be customized in many ways:
* JavaScript behaviour (e.g. overwritten jQuery.entwine rules)
* CSS styles
* HTML markup through templates
In general, use your `ModelAdmin->init()` method to add additional requirements
through the `[Requirements](/reference/requirements)` API.
For an introduction how to customize the CMS templates, see our [CMS Architecture Guide](/reference/cms-architecture).
## Related

View File

@ -15,10 +15,6 @@ the $fields constructor parameter.
`[api:SearchContext]` is mainly used by `[api:ModelAdmin]`, our generic data administration interface. Another
implementation can be found in generic frontend search forms through the [genericviews](http://silverstripe.org/generic-views-module) module.
## Requirements
* *SilverStripe 2.3*
## Usage
Getting results

View File

@ -123,22 +123,17 @@ class FormScaffolder extends Object {
$this->obj->fieldLabel($relationship)
);
}
$relationshipFields = singleton($component)->summaryFields();
$foreignKey = $this->obj->getRemoteJoinField($relationship);
$fieldClass = (isset($this->fieldClasses[$relationship])) ? $this->fieldClasses[$relationship] : 'ComplexTableField';
$ctf = new $fieldClass(
$this,
$fieldClass = (isset($this->fieldClasses[$relationship])) ? $this->fieldClasses[$relationship] : 'GridField';
$grid = Object::create($fieldClass,
$relationship,
null,
$relationshipFields,
"getCMSFields"
$this->obj->fieldLabel($relationship),
$this->obj->$relationship(),
GridFieldConfig_RelationEditor::create()
);
$ctf->setPermissions(TableListField::permissions_for_object($component));
if($this->tabbed) {
$fields->addFieldToTab("Root.$relationship", $ctf);
$fields->addFieldToTab("Root.$relationship", $grid);
} else {
$fields->push($ctf);
$fields->push($grid);
}
}
}
@ -152,22 +147,17 @@ class FormScaffolder extends Object {
);
}
$relationshipFields = singleton($component)->summaryFields();
$fieldClass = (isset($this->fieldClasses[$relationship])) ? $this->fieldClasses[$relationship] : 'ComplexTableField';
$ctf = new $fieldClass(
$this,
$fieldClass = (isset($this->fieldClasses[$relationship])) ? $this->fieldClasses[$relationship] : 'GridField';
$grid = Object::create($fieldClass,
$relationship,
$this->obj->fieldLabel($relationship),
$this->obj->$relationship(),
$relationshipFields,
"getCMSFields"
GridFieldConfig_RelationEditor::create()
);
$ctf->setPermissions(TableListField::permissions_for_object($component));
$ctf->popupClass = "ScaffoldingComplexTableField_Popup";
if($this->tabbed) {
$fields->addFieldToTab("Root.$relationship", $ctf);
$fields->addFieldToTab("Root.$relationship", $grid);
} else {
$fields->push($ctf);
$fields->push($grid);
}
}
}

View File

@ -1,93 +0,0 @@
<?php
/**
* Used by ModelAdmin scaffolding, to manage many-many relationships.
*
* @package forms
* @subpackage fields-relational
*/
class ScaffoldingComplexTableField_Popup extends ComplexTableField_Popup {
public static $allowed_actions = array(
'filter', 'record', 'httpSubmission', 'handleAction', 'handleField'
);
function __construct($controller, $name, $fields, $validator, $readonly, $dataObject) {
$this->dataObject = $dataObject;
Requirements::clear();
$actions = new FieldList();
if(!$readonly) {
$actions->push(
$saveAction = new FormAction("saveComplexTableField", "Save")
);
$saveAction->addExtraClass('save');
}
$fields->push(new HiddenField("ComplexTableField_Path", Director::absoluteBaseURL()));
parent::__construct($controller, $name, $fields, $validator, $readonly, $dataObject);
}
/**
* Handle a generic action passed in by the URL mapping.
*
* @param SS_HTTPRequest $request
*/
public function handleAction($request) {
$action = str_replace("-","_",$request->param('Action'));
if(!$this->action) $this->action = 'index';
if($this->checkAccessAction($action)) {
if($this->hasMethod($action)) {
$result = $this->$action($request);
// Method returns an array, that is used to customise the object before rendering with a template
if(is_array($result)) {
return $this->getViewer($action)->process($this->customise($result));
// Method returns a string / object, in which case we just return that
} else {
return $result;
}
// There is no method, in which case we just render this object using a (possibly alternate) template
} else {
return $this->getViewer($action)->process($this);
}
} else {
return $this->httpError(403, "Action '$action' isn't allowed on class $this->class");
}
}
/**
* Action to render results for an autocomplete filter.
*
* @param SS_HTTPRequest $request
* @return void
*/
function filter($request) {
//$model = singleton($this->modelClass);
$context = $this->dataObject->getDefaultSearchContext();
$value = $request->getVar('q');
$results = $context->getResults(array("Name"=>$value));
header("Content-Type: text/plain");
foreach($results as $result) {
echo $result->Name . "\n";
}
}
/**
* Action to populate edit box with a single data object via Ajax query
*/
function record($request) {
$type = $request->getVar('type');
$value = $request->getVar('value');
if ($type && $value) {
$record = DataObject::get_one($this->dataObject->class, "\"$type\" = '$value'");
header("Content-Type: text/plain");
echo json_encode(array("record"=>$record->toMap()));
}
}
}

View File

@ -1,24 +0,0 @@
window.onload = function() {
resourcePath = jQuery('form').attr('action');
jQuery("fieldset input:first").attr('autocomplete', 'off').autocomplete({list: ["mark rickerby", "maxwell sparks"]});
jQuery("fieldset input:first").bind('activate.autocomplete', function(e){
type = jQuery("fieldset input:first").attr('name');
value = jQuery("fieldset input:first").val();
jQuery.getJSON(resourcePath + '/record', {'type':type, 'value':value}, function(data) {
jQuery('form input').each(function(i, elm){
if(elm.name in data.record) {
val = data.record[elm.name];
if (val != null) elm.setAttribute('value', val);
}
});
});
});
};

View File

@ -37,8 +37,15 @@ class ForeignKey extends Int {
$field = new UploadField($relationName, $title);
} else {
$titleField = (singleton($hasOneClass)->hasField('Title')) ? "Title" : "Name";
$map = DataList::create($hasOneClass)->map("ID", $titleField);
$field = new DropdownField($this->name, $title, $map, null, null, ' ');
$list = DataList::create($hasOneClass);
// Don't scaffold a dropdown for large tables, as making the list concrete
// might exceed the available PHP memory in creating too many DataObject instances
if($list->count() < 100) {
$field = new DropdownField($this->name, $title, $list->map("ID", $titleField), null, null, ' ');
} else {
$field = new NumericField($this->name, $title);
}
}
return $field;