'%$FormSchema' ]; /** * @config * @var Array Codes which are required from the current user to view this controller. * If multiple codes are provided, all of them are required. * All CMS controllers require "CMS_ACCESS_LeftAndMain" as a baseline check, * and fall back to "CMS_ACCESS_" if no permissions are defined here. * See {@link canView()} for more details on permission checks. */ private static $required_permission_codes; /** * @config * @var String Namespace for session info, e.g. current record. * Defaults to the current class name, but can be amended to share a namespace in case * controllers are logically bundled together, and mainly separated * to achieve more flexible templating. */ private static $session_namespace; /** * Register additional requirements through the {@link Requirements} class. * Used mainly to work around the missing "lazy loading" functionality * for getting css/javascript required after an ajax-call (e.g. loading the editform). * * YAML configuration example: * * LeftAndMain: * extra_requirements_javascript: * mysite/javascript/myscript.js: * * * @config * @var array */ private static $extra_requirements_javascript = array(); /** * YAML configuration example: * * LeftAndMain: * extra_requirements_css: * mysite/css/mystyle.css: * media: screen * * * @config * @var array See {@link extra_requirements_javascript} */ private static $extra_requirements_css = array(); /** * @config * @var array See {@link extra_requirements_javascript} */ private static $extra_requirements_themedCss = array(); /** * If true, call a keepalive ping every 5 minutes from the CMS interface, * to ensure that the session never dies. * * @config * @var boolean */ private static $session_keepalive_ping = true; /** * @var PjaxResponseNegotiator */ protected $responseNegotiator; /** * Gets the combined configuration of all LeafAndMain subclasses required by the client app. * * @return array * * WARNING: Experimental API */ public function getCombinedClientConfig() { $combinedClientConfig = ['sections' => []]; $cmsClassNames = CMSMenu::get_cms_classes('LeftAndMain', true, CMSMenu::URL_PRIORITY); foreach ($cmsClassNames as $className) { $combinedClientConfig['sections'][$className] = Injector::inst()->get($className)->getClientConfig(); } return Convert::raw2json($combinedClientConfig); } /** * Returns configuration required by the client app. * * @return array * * WARNING: Experimental API */ public function getClientConfig() { return [ 'route' => Config::inst()->get($this->class, 'url_segment') ]; } /** * Gets a JSON schema representing the current edit form. * * WARNING: Experimental API. * * @return SS_HTTPResponse */ public function schema($request) { $response = $this->getResponse(); $formName = $request->param('ID'); if(!$this->hasMethod("get{$formName}")) { throw new SS_HTTPResponse_Exception( 'Form not found', 400 ); } if(!$this->hasAction($formName)) { throw new SS_HTTPResponse_Exception( 'Form not accessible', 401 ); } $form = $this->{"get{$formName}"}(); $response->addHeader('Content-Type', 'application/json'); $response->setBody(Convert::raw2json($this->getSchemaForForm($form))); return $response; } /** * Returns a representation of the provided {@link Form} as structured data, * based on the request data. * * @param Form $form * @return array */ protected function getSchemaForForm(Form $form) { $request = $this->getRequest(); $schemaParts = []; $return = null; // Valid values for the "X-Formschema-Request" header are "schema" and "state". // If either of these values are set they will be stored in the $schemaParst array // and used to construct the response body. if ($schemaHeader = $request->getHeader('X-Formschema-Request')) { $schemaParts = array_filter(explode(',', $schemaHeader), function($value) { $validHeaderValues = ['schema', 'state']; return in_array(trim($value), $validHeaderValues); }); } else { $schemaParts = ['schema']; } $return = ['id' => $form->getName()]; if (in_array('schema', $schemaParts)) { $return['schema'] = $this->schema->getSchema($form); } if (in_array('state', $schemaParts)) { $return['state'] = $this->schema->getState($form); } return $return; } /** * @param Member $member * @return boolean */ public function canView($member = null) { if(!$member && $member !== FALSE) $member = Member::currentUser(); // cms menus only for logged-in members if(!$member) return false; // alternative extended checks if($this->hasMethod('alternateAccessCheck')) { $alternateAllowed = $this->alternateAccessCheck(); if($alternateAllowed === FALSE) return false; } // Check for "CMS admin" permission if(Permission::checkMember($member, "CMS_ACCESS_LeftAndMain")) return true; // Check for LeftAndMain sub-class permissions $codes = array(); $extraCodes = $this->stat('required_permission_codes'); if($extraCodes !== false) { // allow explicit FALSE to disable subclass check if($extraCodes) $codes = array_merge($codes, (array)$extraCodes); else $codes[] = "CMS_ACCESS_$this->class"; } foreach($codes as $code) if(!Permission::checkMember($member, $code)) return false; return true; } /** * @uses LeftAndMainExtension->init() * @uses LeftAndMainExtension->accessedCMS() * @uses CMSMenu */ public function init() { parent::init(); Config::inst()->update('SSViewer', 'rewrite_hash_links', false); Config::inst()->update('ContentNegotiator', 'enabled', false); // set language $member = Member::currentUser(); if(!empty($member->Locale)) i18n::set_locale($member->Locale); if(!empty($member->DateFormat)) i18n::config()->date_format = $member->DateFormat; if(!empty($member->TimeFormat)) i18n::config()->time_format = $member->TimeFormat; // can't be done in cms/_config.php as locale is not set yet CMSMenu::add_link( 'Help', _t('LeftAndMain.HELP', 'Help', 'Menu title'), $this->config()->help_link, -2, array( 'target' => '_blank' ) ); // Allow customisation of the access check by a extension // Also all the canView() check to execute Controller::redirect() if(!$this->canView() && !$this->getResponse()->isFinished()) { // When access /admin/, we should try a redirect to another part of the admin rather than be locked out $menu = $this->MainMenu(); foreach($menu as $candidate) { if( $candidate->Link && $candidate->Link != $this->Link() && $candidate->MenuItem->controller && singleton($candidate->MenuItem->controller)->canView() ) { return $this->redirect($candidate->Link); } } if(Member::currentUser()) { Session::set("BackURL", null); } // if no alternate menu items have matched, return a permission error $messageSet = array( 'default' => _t( 'LeftAndMain.PERMDEFAULT', "You must be logged in to access the administration area; please enter your credentials below." ), 'alreadyLoggedIn' => _t( 'LeftAndMain.PERMALREADY', "I'm sorry, but you can't access that part of the CMS. If you want to log in as someone else, do" . " so below." ), 'logInAgain' => _t( 'LeftAndMain.PERMAGAIN', "You have been logged out of the CMS. If you would like to log in again, enter a username and" . " password below." ), ); return Security::permissionFailure($this, $messageSet); } // Don't continue if there's already been a redirection request. if($this->redirectedTo()) return; // Audit logging hook if(empty($_REQUEST['executeForm']) && !$this->getRequest()->isAjax()) $this->extend('accessedCMS'); // Set the members html editor config if(Member::currentUser()) { HtmlEditorConfig::set_active_identifier(Member::currentUser()->getHtmlEditorConfigForCMS()); } // Set default values in the config if missing. These things can't be defined in the config // file because insufficient information exists when that is being processed $htmlEditorConfig = HtmlEditorConfig::get_active(); $htmlEditorConfig->setOption('language', i18n::get_tinymce_lang()); if(!$htmlEditorConfig->getOption('content_css')) { $cssFiles = array(); $cssFiles[] = FRAMEWORK_ADMIN_DIR . '/css/editor.css'; // Use theme from the site config if(class_exists('SiteConfig') && ($config = SiteConfig::current_site_config()) && $config->Theme) { $theme = $config->Theme; } elseif(Config::inst()->get('SSViewer', 'theme_enabled') && Config::inst()->get('SSViewer', 'theme')) { $theme = Config::inst()->get('SSViewer', 'theme'); } else { $theme = false; } if($theme) $cssFiles[] = THEMES_DIR . "/{$theme}/css/editor.css"; else if(project()) $cssFiles[] = project() . '/css/editor.css'; // Remove files that don't exist foreach($cssFiles as $k => $cssFile) { if(!file_exists(BASE_PATH . '/' . $cssFile)) unset($cssFiles[$k]); } $htmlEditorConfig->setOption('content_css', implode(',', $cssFiles)); } Requirements::customScript(" window.ss = window.ss || {}; window.ss.config = " . $this->getCombinedClientConfig() . "; "); Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/javascript/dist/bundle-lib.js', [ 'provides' => [ THIRDPARTY_DIR . '/jquery/jquery.js', THIRDPARTY_DIR . '/jquery-ui/jquery-ui.js', THIRDPARTY_DIR . '/jquery-entwine/dist/jquery.entwine-dist.js', THIRDPARTY_DIR . '/jquery-cookie/jquery.cookie.js', THIRDPARTY_DIR . '/jquery-query/jquery.query.js', THIRDPARTY_DIR . '/jquery-form/jquery.form.js', THIRDPARTY_DIR . '/jquery-ondemand/jquery.ondemand.js', THIRDPARTY_DIR . '/jquery-changetracker/lib/jquery.changetracker.js', THIRDPARTY_DIR . '/jstree/jquery.jstree.js', FRAMEWORK_ADMIN_DIR . '/thirdparty/jquery-notice/jquery.notice.js', FRAMEWORK_ADMIN_DIR . '/thirdparty/jsizes/lib/jquery.sizes.js', FRAMEWORK_ADMIN_DIR . '/thirdparty/jlayout/lib/jlayout.border.js', FRAMEWORK_ADMIN_DIR . '/thirdparty/jlayout/lib/jquery.jlayout.js', FRAMEWORK_ADMIN_DIR . '/thirdparty/chosen/chosen/chosen.jquery.js', FRAMEWORK_ADMIN_DIR . '/thirdparty/jquery-hoverIntent/jquery.hoverIntent.js', FRAMEWORK_DIR . '/javascript/dist/TreeDropdownField.js', FRAMEWORK_DIR . '/javascript/dist/DateField.js', FRAMEWORK_DIR . '/javascript/dist/HtmlEditorField.js', FRAMEWORK_DIR . '/javascript/dist/TabSet.js', FRAMEWORK_DIR . '/javascript/dist/GridField.js', FRAMEWORK_DIR . '/javascript/dist/i18n.js', FRAMEWORK_ADMIN_DIR . '/javascript/dist/sspath.js', FRAMEWORK_ADMIN_DIR . '/javascript/dist/ssui.core.js', ] ]); Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/javascript/dist/bundle-legacy.js', [ 'provides' => [ FRAMEWORK_ADMIN_DIR . '/javascript/dist/LeftAndMain.Layout.js', FRAMEWORK_ADMIN_DIR . '/javascript/dist/LeftAndMain.js', FRAMEWORK_ADMIN_DIR . '/javascript/dist/LeftAndMain.ActionTabSet.js', FRAMEWORK_ADMIN_DIR . '/javascript/dist/LeftAndMain.Panel.js', FRAMEWORK_ADMIN_DIR . '/javascript/dist/LeftAndMain.Tree.js', FRAMEWORK_ADMIN_DIR . '/javascript/dist/LeftAndMain.Content.js', FRAMEWORK_ADMIN_DIR . '/javascript/dist/LeftAndMain.EditForm.js', FRAMEWORK_ADMIN_DIR . '/javascript/dist/LeftAndMain.Menu.js', FRAMEWORK_ADMIN_DIR . '/javascript/dist/LeftAndMain.Preview.js', FRAMEWORK_ADMIN_DIR . '/javascript/dist/LeftAndMain.BatchActions.js', FRAMEWORK_ADMIN_DIR . '/javascript/dist/LeftAndMain.FieldHelp.js', FRAMEWORK_ADMIN_DIR . '/javascript/dist/LeftAndMain.FieldDescriptionToggle.js', FRAMEWORK_ADMIN_DIR . '/javascript/dist/LeftAndMain.TreeDropdownField.js' ] ]); Requirements::add_i18n_javascript(FRAMEWORK_DIR . '/javascript/lang', false, true); Requirements::add_i18n_javascript(FRAMEWORK_ADMIN_DIR . '/javascript/lang', false, true); if ($this->config()->session_keepalive_ping) { Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/javascript/dist/LeftAndMain.Ping.js'); } if (Director::isDev()) { // TODO Confuses jQuery.ondemand through document.write() Requirements::javascript(THIRDPARTY_DIR . '/jquery-entwine/src/jquery.entwine.inspector.js'); Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/javascript/dist/leaktools.js'); } Requirements::javascript(FRAMEWORK_ADMIN_DIR . '/javascript/dist/bundle-framework.js'); Requirements::css(FRAMEWORK_ADMIN_DIR . '/css/bootstrap/bootstrap.css'); Requirements::css(FRAMEWORK_ADMIN_DIR . '/thirdparty/jquery-notice/jquery.notice.css'); Requirements::css(THIRDPARTY_DIR . '/jquery-ui-themes/smoothness/jquery-ui.css'); Requirements::css(FRAMEWORK_ADMIN_DIR .'/thirdparty/chosen/chosen/chosen.css'); Requirements::css(THIRDPARTY_DIR . '/jstree/themes/apple/style.css'); Requirements::css(FRAMEWORK_DIR . '/css/TreeDropdownField.css'); Requirements::css(FRAMEWORK_ADMIN_DIR . '/css/screen.css'); Requirements::css(FRAMEWORK_DIR . '/css/GridField.css'); // Custom requirements $extraJs = $this->stat('extra_requirements_javascript'); if($extraJs) { foreach($extraJs as $file => $config) { if(is_numeric($file)) { $file = $config; } Requirements::javascript($file); } } $extraCss = $this->stat('extra_requirements_css'); if($extraCss) { foreach($extraCss as $file => $config) { if(is_numeric($file)) { $file = $config; $config = array(); } Requirements::css($file, isset($config['media']) ? $config['media'] : null); } } $extraThemedCss = $this->stat('extra_requirements_themedCss'); if($extraThemedCss) { foreach ($extraThemedCss as $file => $config) { if(is_numeric($file)) { $file = $config; $config = array(); } Requirements::themedCSS($file, isset($config['media']) ? $config['media'] : null); } } $dummy = null; $this->extend('init', $dummy); // The user's theme shouldn't affect the CMS, if, for example, they have // replaced TableListField.ss or Form.ss. Config::inst()->update('SSViewer', 'theme_enabled', false); //set the reading mode for the admin to stage Versioned::set_stage(Versioned::DRAFT); } public function handleRequest(SS_HTTPRequest $request, DataModel $model = null) { try { $response = parent::handleRequest($request, $model); } catch(ValidationException $e) { // Nicer presentation of model-level validation errors $msgs = _t('LeftAndMain.ValidationError', 'Validation error') . ': ' . $e->getMessage(); $e = new SS_HTTPResponse_Exception($msgs, 403); $errorResponse = $e->getResponse(); $errorResponse->addHeader('Content-Type', 'text/plain'); $errorResponse->addHeader('X-Status', rawurlencode($msgs)); $e->setResponse($errorResponse); throw $e; } $title = $this->Title(); if(!$response->getHeader('X-Controller')) $response->addHeader('X-Controller', $this->class); if(!$response->getHeader('X-Title')) $response->addHeader('X-Title', urlencode($title)); // Prevent clickjacking, see https://developer.mozilla.org/en-US/docs/HTTP/X-Frame-Options $originalResponse = $this->getResponse(); $originalResponse->addHeader('X-Frame-Options', 'SAMEORIGIN'); $originalResponse->addHeader('Vary', 'X-Requested-With'); return $response; } /** * Overloaded redirection logic to trigger a fake redirect on ajax requests. * While this violates HTTP principles, its the only way to work around the * fact that browsers handle HTTP redirects opaquely, no intervention via JS is possible. * In isolation, that's not a problem - but combined with history.pushState() * it means we would request the same redirection URL twice if we want to update the URL as well. * See LeftAndMain.js for the required jQuery ajaxComplete handlers. */ public function redirect($url, $code=302) { if($this->getRequest()->isAjax()) { $response = $this->getResponse(); $response->addHeader('X-ControllerURL', $url); if($this->getRequest()->getHeader('X-Pjax') && !$response->getHeader('X-Pjax')) { $response->addHeader('X-Pjax', $this->getRequest()->getHeader('X-Pjax')); } $newResponse = new LeftAndMain_HTTPResponse( $response->getBody(), $response->getStatusCode(), $response->getStatusDescription() ); foreach($response->getHeaders() as $k => $v) { $newResponse->addHeader($k, $v); } $newResponse->setIsFinished(true); $this->setResponse($newResponse); return ''; // Actual response will be re-requested by client } else { parent::redirect($url, $code); } } public function index($request) { return $this->getResponseNegotiator()->respond($request); } /** * If this is set to true, the "switchView" context in the * template is shown, with links to the staging and publish site. * * @return boolean */ public function ShowSwitchView() { return false; } //------------------------------------------------------------------------------------------// // Main controllers /** * You should implement a Link() function in your subclass of LeftAndMain, * to point to the URL of that particular controller. * * @return string */ public function Link($action = null) { // Handle missing url_segments if($this->config()->url_segment) { $segment = $this->config()->get('url_segment', Config::FIRST_SET); } else { $segment = $this->class; }; $link = Controller::join_links( $this->stat('url_base', true), $segment, '/', // trailing slash needed if $action is null! "$action" ); $this->extend('updateLink', $link); return $link; } /** * Returns the menu title for the given LeftAndMain subclass. * Implemented static so that we can get this value without instantiating an object. * Menu title is *not* internationalised. */ public static function menu_title_for_class($class) { $title = Config::inst()->get($class, 'menu_title', Config::FIRST_SET); if(!$title) $title = preg_replace('/Admin$/', '', $class); return $title; } /** * Return styling for the menu icon, if a custom icon is set for this class * * Example: static $menu-icon = '/path/to/image/'; * @param string $class * @return string */ public static function menu_icon_for_class($class) { $icon = Config::inst()->get($class, 'menu_icon', Config::FIRST_SET); if (!empty($icon)) { $class = strtolower(Convert::raw2htmlname(str_replace('\\', '-', $class))); return ".icon.icon-16.icon-{$class} { background-image: url('{$icon}'); } "; } return ''; } public function show($request) { // TODO Necessary for TableListField URLs to work properly if($request->param('ID')) $this->setCurrentPageID($request->param('ID')); return $this->getResponseNegotiator()->respond($request); } /** * Caution: Volatile API. * * @return PjaxResponseNegotiator */ public function getResponseNegotiator() { if(!$this->responseNegotiator) { $controller = $this; $this->responseNegotiator = new PjaxResponseNegotiator( array( 'CurrentForm' => function() use(&$controller) { return $controller->getEditForm()->forTemplate(); }, 'Content' => function() use(&$controller) { return $controller->renderWith($controller->getTemplatesWithSuffix('_Content')); }, 'Breadcrumbs' => function() use (&$controller) { return $controller->renderWith('CMSBreadcrumbs'); }, 'default' => function() use(&$controller) { return $controller->renderWith($controller->getViewer('show')); } ), $this->getResponse() ); } return $this->responseNegotiator; } //------------------------------------------------------------------------------------------// // Main UI components /** * Returns the main menu of the CMS. This is also used by init() * to work out which sections the user has access to. * * @param Boolean * @return SS_List */ public function MainMenu($cached = true) { if(!isset($this->_cache_MainMenu) || !$cached) { // Don't accidentally return a menu if you're not logged in - it's used to determine access. if(!Member::currentUser()) return new ArrayList(); // Encode into DO set $menu = new ArrayList(); $menuItems = CMSMenu::get_viewable_menu_items(); // extra styling for custom menu-icons $menuIconStyling = ''; if($menuItems) { foreach($menuItems as $code => $menuItem) { // alternate permission checks (in addition to LeftAndMain->canView()) if( isset($menuItem->controller) && $this->hasMethod('alternateMenuDisplayCheck') && !$this->alternateMenuDisplayCheck($menuItem->controller) ) { continue; } $linkingmode = "link"; if($menuItem->controller && get_class($this) == $menuItem->controller) { $linkingmode = "current"; } else if(strpos($this->Link(), $menuItem->url) !== false) { if($this->Link() == $menuItem->url) { $linkingmode = "current"; // default menu is the one with a blank {@link url_segment} } else if(singleton($menuItem->controller)->stat('url_segment') == '') { if($this->Link() == $this->stat('url_base').'/') { $linkingmode = "current"; } } else { $linkingmode = "current"; } } // already set in CMSMenu::populate_menu(), but from a static pre-controller // context, so doesn't respect the current user locale in _t() calls - as a workaround, // we simply call LeftAndMain::menu_title_for_class() again // if we're dealing with a controller if($menuItem->controller) { $defaultTitle = LeftAndMain::menu_title_for_class($menuItem->controller); $title = _t("{$menuItem->controller}.MENUTITLE", $defaultTitle); } else { $title = $menuItem->title; } // Provide styling for custom $menu-icon. Done here instead of in // CMSMenu::populate_menu(), because the icon is part of // the CMS right pane for the specified class as well... if($menuItem->controller) { $menuIcon = LeftAndMain::menu_icon_for_class($menuItem->controller); if (!empty($menuIcon)) $menuIconStyling .= $menuIcon; } $menu->push(new ArrayData(array( "MenuItem" => $menuItem, "AttributesHTML" => $menuItem->getAttributesHTML(), "Title" => Convert::raw2xml($title), "Code" => DBField::create_field('Text', $code), "Link" => $menuItem->url, "LinkingMode" => $linkingmode ))); } } if ($menuIconStyling) Requirements::customCSS($menuIconStyling); $this->_cache_MainMenu = $menu; } return $this->_cache_MainMenu; } public function Menu() { return $this->renderWith($this->getTemplatesWithSuffix('_Menu')); } /** * @todo Wrap in CMSMenu instance accessor * @return ArrayData A single menu entry (see {@link MainMenu}) */ public function MenuCurrentItem() { $items = $this->MainMenu(); return $items->find('LinkingMode', 'current'); } /** * Return a list of appropriate templates for this class, with the given suffix using * {@link SSViewer::get_templates_by_class()} * * @return array */ public function getTemplatesWithSuffix($suffix) { return SSViewer::get_templates_by_class(get_class($this), $suffix, 'LeftAndMain'); } public function Content() { return $this->renderWith($this->getTemplatesWithSuffix('_Content')); } public function getRecord($id) { $className = $this->stat('tree_class'); if($className && $id instanceof $className) { return $id; } else if($className && $id == 'root') { return singleton($className); } else if($className && is_numeric($id)) { return DataObject::get_by_id($className, $id); } else { return false; } } /** * @return ArrayList */ public function Breadcrumbs($unlinked = false) { $defaultTitle = LeftAndMain::menu_title_for_class($this->class); $title = _t("{$this->class}.MENUTITLE", $defaultTitle); $items = new ArrayList(array( new ArrayData(array( 'Title' => $title, 'Link' => ($unlinked) ? false : $this->Link() )) )); $record = $this->currentPage(); if($record && $record->exists()) { if($record->hasExtension('Hierarchy')) { $ancestors = $record->getAncestors(); $ancestors = new ArrayList(array_reverse($ancestors->toArray())); $ancestors->push($record); foreach($ancestors as $ancestor) { $items->push(new ArrayData(array( 'Title' => ($ancestor->MenuTitle) ? $ancestor->MenuTitle : $ancestor->Title, 'Link' => ($unlinked) ? false : Controller::join_links($this->Link('show'), $ancestor->ID) ))); } } else { $items->push(new ArrayData(array( 'Title' => ($record->MenuTitle) ? $record->MenuTitle : $record->Title, 'Link' => ($unlinked) ? false : Controller::join_links($this->Link('show'), $record->ID) ))); } } return $items; } /** * @return String HTML */ public function SiteTreeAsUL() { $html = $this->getSiteTreeFor($this->stat('tree_class')); $this->extend('updateSiteTreeAsUL', $html); return $html; } /** * Gets the current search filter for this request, if available * * @throws InvalidArgumentException * @return LeftAndMain_SearchFilter */ protected function getSearchFilter() { // Check for given FilterClass $params = $this->getRequest()->getVar('q'); if(empty($params['FilterClass'])) { return null; } // Validate classname $filterClass = $params['FilterClass']; $filterInfo = new ReflectionClass($filterClass); if(!$filterInfo->implementsInterface('LeftAndMain_SearchFilter')) { throw new InvalidArgumentException(sprintf('Invalid filter class passed: %s', $filterClass)); } return Injector::inst()->createWithArgs($filterClass, array($params)); } /** * Get a site tree HTML listing which displays the nodes under the given criteria. * * @param $className The class of the root object * @param $rootID The ID of the root object. If this is null then a complete tree will be * shown * @param $childrenMethod The method to call to get the children of the tree. For example, * Children, AllChildrenIncludingDeleted, or AllHistoricalChildren * @return String Nested unordered list with links to each page */ public function getSiteTreeFor($className, $rootID = null, $childrenMethod = null, $numChildrenMethod = null, $filterFunction = null, $nodeCountThreshold = 30) { // Filter criteria $filter = $this->getSearchFilter(); // Default childrenMethod and numChildrenMethod if(!$childrenMethod) $childrenMethod = ($filter && $filter->getChildrenMethod()) ? $filter->getChildrenMethod() : 'AllChildrenIncludingDeleted'; if(!$numChildrenMethod) { $numChildrenMethod = 'numChildren'; if($filter && $filter->getNumChildrenMethod()) { $numChildrenMethod = $filter->getNumChildrenMethod(); } } if(!$filterFunction && $filter) { $filterFunction = function($node) use($filter) { return $filter->isPageIncluded($node); }; } // Get the tree root $record = ($rootID) ? $this->getRecord($rootID) : null; $obj = $record ? $record : singleton($className); // Get the current page // NOTE: This *must* be fetched before markPartialTree() is called, as this // causes the Hierarchy::$marked cache to be flushed (@see CMSMain::getRecord) // which means that deleted pages stored in the marked tree would be removed $currentPage = $this->currentPage(); // Mark the nodes of the tree to return if ($filterFunction) $obj->setMarkingFilterFunction($filterFunction); $obj->markPartialTree($nodeCountThreshold, $this, $childrenMethod, $numChildrenMethod); // Ensure current page is exposed if($currentPage) $obj->markToExpose($currentPage); // NOTE: SiteTree/CMSMain coupling :-( if(class_exists('SiteTree')) { SiteTree::prepopulate_permission_cache('CanEditType', $obj->markedNodeIDs(), 'SiteTree::can_edit_multiple'); } // getChildrenAsUL is a flexible and complex way of traversing the tree $controller = $this; $recordController = ($this->stat('tree_class') == 'SiteTree') ? singleton('CMSPageEditController') : $this; $titleFn = function(&$child, $numChildrenMethod) use(&$controller, &$recordController, $filter) { $link = Controller::join_links($recordController->Link("show"), $child->ID); $node = LeftAndMain_TreeNode::create($child, $link, $controller->isCurrentPage($child), $numChildrenMethod, $filter); return $node->forTemplate(); }; // Limit the amount of nodes shown for performance reasons. // Skip the check if we're filtering the tree, since its not clear how many children will // match the filter criteria until they're queried (and matched up with previously marked nodes). $nodeThresholdLeaf = Config::inst()->get('Hierarchy', 'node_threshold_leaf'); if($nodeThresholdLeaf && !$filterFunction) { $nodeCountCallback = function($parent, $numChildren) use(&$controller, $className, $nodeThresholdLeaf) { if($className == 'SiteTree' && $parent->ID && $numChildren > $nodeThresholdLeaf) { return sprintf( '', _t('LeftAndMain.TooManyPages', 'Too many pages'), Controller::join_links( $controller->LinkWithSearch($controller->Link()), ' ?view=list&ParentID=' . $parent->ID ), _t( 'LeftAndMain.ShowAsList', 'show as list', 'Show large amount of pages in list instead of tree view' ) ); } }; } else { $nodeCountCallback = null; } // If the amount of pages exceeds the node thresholds set, use the callback $html = null; if($obj->ParentID && $nodeCountCallback) { $html = $nodeCountCallback($obj, $obj->$numChildrenMethod()); } // Otherwise return the actual tree (which might still filter leaf thresholds on children) if(!$html) { $html = $obj->getChildrenAsUL( "", $titleFn, singleton('CMSPagesController'), true, $childrenMethod, $numChildrenMethod, $nodeCountThreshold, $nodeCountCallback ); } // Wrap the root if needs be. if(!$rootID) { $rootLink = $this->Link('show') . '/root'; // This lets us override the tree title with an extension if($this->hasMethod('getCMSTreeTitle') && $customTreeTitle = $this->getCMSTreeTitle()) { $treeTitle = $customTreeTitle; } elseif(class_exists('SiteConfig')) { $siteConfig = SiteConfig::current_site_config(); $treeTitle = Convert::raw2xml($siteConfig->Title); } else { $treeTitle = '...'; } $html = ""; } return $html; } /** * Get a subtree underneath the request param 'ID'. * If ID = 0, then get the whole tree. */ public function getsubtree($request) { $html = $this->getSiteTreeFor( $this->stat('tree_class'), $request->getVar('ID'), null, null, null, $request->getVar('minNodeCount') ); // Trim off the outer tag $html = preg_replace('/^[\s\t\r\n]*]*>/','', $html); $html = preg_replace('/<\/ul[^>]*>[\s\t\r\n]*$/','', $html); return $html; } /** * Allows requesting a view update on specific tree nodes. * Similar to {@link getsubtree()}, but doesn't enforce loading * all children with the node. Useful to refresh views after * state modifications, e.g. saving a form. * * @return String JSON */ public function updatetreenodes($request) { $data = array(); $ids = explode(',', $request->getVar('ids')); foreach($ids as $id) { if($id === "") continue; // $id may be a blank string, which is invalid and should be skipped over $record = $this->getRecord($id); if(!$record) continue; // In case a page is no longer available $recordController = ($this->stat('tree_class') == 'SiteTree') ? singleton('CMSPageEditController') : $this; // Find the next & previous nodes, for proper positioning (Sort isn't good enough - it's not a raw offset) // TODO: These methods should really be in hierarchy - for a start it assumes Sort exists $next = $prev = null; $className = $this->stat('tree_class'); $next = DataObject::get($className) ->filter('ParentID', $record->ParentID) ->filter('Sort:GreaterThan', $record->Sort) ->first(); if (!$next) { $prev = DataObject::get($className) ->filter('ParentID', $record->ParentID) ->filter('Sort:LessThan', $record->Sort) ->reverse() ->first(); } $link = Controller::join_links($recordController->Link("show"), $record->ID); $html = LeftAndMain_TreeNode::create($record, $link, $this->isCurrentPage($record)) ->forTemplate() . ''; $data[$id] = array( 'html' => $html, 'ParentID' => $record->ParentID, 'NextID' => $next ? $next->ID : null, 'PrevID' => $prev ? $prev->ID : null ); } $this->getResponse()->addHeader('Content-Type', 'text/json'); return Convert::raw2json($data); } /** * Save handler */ public function save($data, $form) { $request = $this->getRequest(); $className = $this->stat('tree_class'); // Existing or new record? $id = $data['ID']; if(substr($id,0,3) != 'new') { $record = DataObject::get_by_id($className, $id); if($record && !$record->canEdit()) return Security::permissionFailure($this); if(!$record || !$record->ID) $this->httpError(404, "Bad record ID #" . (int)$id); } else { if(!singleton($this->stat('tree_class'))->canCreate()) return Security::permissionFailure($this); $record = $this->getNewItem($id, false); } // save form data into record $form->saveInto($record, true); $record->write(); $this->extend('onAfterSave', $record); $this->setCurrentPageID($record->ID); $this->getResponse()->addHeader('X-Status', rawurlencode(_t('LeftAndMain.SAVEDUP', 'Saved.'))); if($request->getHeader('X-Formschema-Request')) { $data = $this->getSchemaForForm($form); $response = new SS_HTTPResponse(Convert::raw2json($data)); $response->addHeader('Content-Type', 'application/json'); } else { $response = $this->getResponseNegotiator()->respond($request); } return $response; } public function delete($data, $form) { $className = $this->stat('tree_class'); $id = $data['ID']; $record = DataObject::get_by_id($className, $id); if($record && !$record->canDelete()) return Security::permissionFailure(); if(!$record || !$record->ID) $this->httpError(404, "Bad record ID #" . (int)$id); $record->delete(); $this->getResponse()->addHeader('X-Status', rawurlencode(_t('LeftAndMain.DELETED', 'Deleted.'))); return $this->getResponseNegotiator()->respond( $this->getRequest(), array('currentform' => array($this, 'EmptyForm')) ); } /** * Update the position and parent of a tree node. * Only saves the node if changes were made. * * Required data: * - 'ID': The moved node * - 'ParentID': New parent relation of the moved node (0 for root) * - 'SiblingIDs': Array of all sibling nodes to the moved node (incl. the node itself). * In case of a 'ParentID' change, relates to the new siblings under the new parent. * * @return SS_HTTPResponse JSON string with a */ public function savetreenode($request) { if (!Permission::check('SITETREE_REORGANISE') && !Permission::check('ADMIN')) { $this->getResponse()->setStatusCode( 403, _t('LeftAndMain.CANT_REORGANISE', "You do not have permission to rearange the site tree. Your change was not saved.") ); return; } $className = $this->stat('tree_class'); $statusUpdates = array('modified'=>array()); $id = $request->requestVar('ID'); $parentID = $request->requestVar('ParentID'); if($className == 'SiteTree' && $page = DataObject::get_by_id('Page', $id)){ $root = $page->getParentType(); if(($parentID == '0' || $root == 'root') && !SiteConfig::current_site_config()->canCreateTopLevel()){ $this->getResponse()->setStatusCode( 403, _t('LeftAndMain.CANT_REORGANISE', "You do not have permission to alter Top level pages. Your change was not saved.") ); return; } } $siblingIDs = $request->requestVar('SiblingIDs'); $statusUpdates = array('modified'=>array()); if(!is_numeric($id) || !is_numeric($parentID)) throw new InvalidArgumentException(); $node = DataObject::get_by_id($className, $id); if($node && !$node->canEdit()) return Security::permissionFailure($this); if(!$node) { $this->getResponse()->setStatusCode( 500, _t('LeftAndMain.PLEASESAVE', "Please Save Page: This page could not be updated because it hasn't been saved yet." ) ); return; } // Update hierarchy (only if ParentID changed) if($node->ParentID != $parentID) { $node->ParentID = (int)$parentID; $node->write(); $statusUpdates['modified'][$node->ID] = array( 'TreeTitle'=>$node->TreeTitle ); // Update all dependent pages if(class_exists('VirtualPage')) { $virtualPages = VirtualPage::get()->filter("CopyContentFromID", $node->ID); foreach($virtualPages as $virtualPage) { $statusUpdates['modified'][$virtualPage->ID] = array( 'TreeTitle' => $virtualPage->TreeTitle() ); } } $this->getResponse()->addHeader('X-Status', rawurlencode(_t('LeftAndMain.REORGANISATIONSUCCESSFUL', 'Reorganised the site tree successfully.'))); } // Update sorting if(is_array($siblingIDs)) { $counter = 0; foreach($siblingIDs as $id) { if($id == $node->ID) { $node->Sort = ++$counter; $node->write(); $statusUpdates['modified'][$node->ID] = array( 'TreeTitle' => $node->TreeTitle ); } else if(is_numeric($id)) { // Nodes that weren't "actually moved" shouldn't be registered as // having been edited; do a direct SQL update instead ++$counter; DB::prepared_query( "UPDATE \"$className\" SET \"Sort\" = ? WHERE \"ID\" = ?", array($counter, $id) ); } } $this->getResponse()->addHeader('X-Status', rawurlencode(_t('LeftAndMain.REORGANISATIONSUCCESSFUL', 'Reorganised the site tree successfully.'))); } return Convert::raw2json($statusUpdates); } public function CanOrganiseSitetree() { return !Permission::check('SITETREE_REORGANISE') && !Permission::check('ADMIN') ? false : true; } /** * Retrieves an edit form, either for display, or to process submitted data. * Also used in the template rendered through {@link Right()} in the $EditForm placeholder. * * This is a "pseudo-abstract" methoed, usually connected to a {@link getEditForm()} * method in an entwine subclass. This method can accept a record identifier, * selected either in custom logic, or through {@link currentPageID()}. * The form usually construct itself from {@link DataObject->getCMSFields()} * for the specific managed subclass defined in {@link LeftAndMain::$tree_class}. * * @param HTTPRequest $request Optionally contains an identifier for the * record to load into the form. * @return Form Should return a form regardless wether a record has been found. * Form might be readonly if the current user doesn't have the permission to edit * the record. */ /** * @return Form */ public function EditForm($request = null) { return $this->getEditForm(); } /** * Calls {@link SiteTree->getCMSFields()} * * @param Int $id * @param FieldList $fields * @return Form */ public function getEditForm($id = null, $fields = null) { if(!$id) $id = $this->currentPageID(); if(is_object($id)) { $record = $id; } else { $record = $this->getRecord($id); if($record && !$record->canView()) return Security::permissionFailure($this); } if($record) { $fields = ($fields) ? $fields : $record->getCMSFields(); if ($fields == null) { user_error( "getCMSFields() returned null - it should return a FieldList object. Perhaps you forgot to put a return statement at the end of your method?", E_USER_ERROR ); } // Add hidden fields which are required for saving the record // and loading the UI state if(!$fields->dataFieldByName('ClassName')) { $fields->push(new HiddenField('ClassName')); } $tree_class = $this->stat('tree_class'); if( $tree_class::has_extension('Hierarchy') && !$fields->dataFieldByName('ParentID') ) { $fields->push(new HiddenField('ParentID')); } // Added in-line to the form, but plucked into different view by frontend scripts. if(in_array('CMSPreviewable', class_implements($record))) { $navField = new LiteralField('SilverStripeNavigator', $this->getSilverStripeNavigator()); $navField->setAllowHTML(true); $fields->push($navField); } if($record->hasMethod('getAllCMSActions')) { $actions = $record->getAllCMSActions(); } else { $actions = $record->getCMSActions(); // add default actions if none are defined if(!$actions || !$actions->Count()) { if($record->hasMethod('canEdit') && $record->canEdit()) { $actions->push( FormAction::create('save',_t('CMSMain.SAVE','Save')) ->addExtraClass('ss-ui-action-constructive')->setAttribute('data-icon', 'accept') ); } if($record->hasMethod('canDelete') && $record->canDelete()) { $actions->push( FormAction::create('delete',_t('ModelAdmin.DELETE','Delete')) ->addExtraClass('ss-ui-action-destructive') ); } } } // Use