diff --git a/admin/_config.php b/admin/_config.php index d3397bec0..2e760267d 100644 --- a/admin/_config.php +++ b/admin/_config.php @@ -3,7 +3,6 @@ HtmlEditorConfig::get('cms')->setOptions(array( 'friendly_name' => 'Default CMS', 'priority' => '50', - 'mode' => 'none', // initialized through LeftAndMain.EditFor.js logic 'body_class' => 'typography', 'document_base_url' => isset($_SERVER['HTTP_HOST']) ? Director::absoluteBaseURL() : null, diff --git a/control/HTTPResponse.php b/control/HTTPResponse.php index 5489a8949..9e7c12190 100644 --- a/control/HTTPResponse.php +++ b/control/HTTPResponse.php @@ -234,12 +234,14 @@ class SS_HTTPResponse { } if(in_array($this->statusCode, self::$redirect_codes) && headers_sent($file, $line)) { - $url = $this->headers['Location']; + $url = (string)$this->headers['Location']; + $urlATT = Convert::raw2htmlatt($url); + $urlJS = Convert::raw2js($url); echo - "

Redirecting to " - . "$url... (output started on $file, line $line)

- - "; + "

Redirecting to " + . "$urlATT... (output started on $file, line $line)

+ + "; } else { $line = $file = null; if(!headers_sent($file, $line)) { diff --git a/core/manifest/TemplateLoader.php b/core/manifest/TemplateLoader.php index f1e8e6a66..4fd30f25b 100644 --- a/core/manifest/TemplateLoader.php +++ b/core/manifest/TemplateLoader.php @@ -64,27 +64,26 @@ class SS_TemplateLoader { public function findTemplates($templates, $theme = null) { $result = array(); $project = project(); - + foreach ((array) $templates as $template) { $found = false; - + if (strpos($template, '/')) { list($type, $template) = explode('/', $template, 2); } else { $type = null; } - + if ($found = $this->getManifest()->getCandidateTemplate($template, $theme)) { if ($type && isset($found[$type])) { - $found = array( - 'main' => $found[$type] - ); + $found = array( + 'main' => $found[$type] + ); } - $result = array_merge($found, $result); } } - + return $result; } diff --git a/core/manifest/TemplateManifest.php b/core/manifest/TemplateManifest.php index 47de70d56..371cb0371 100644 --- a/core/manifest/TemplateManifest.php +++ b/core/manifest/TemplateManifest.php @@ -110,17 +110,23 @@ class SS_TemplateManifest { * @return array */ public function getCandidateTemplate($name, $theme = null) { + $found = array(); $candidates = $this->getTemplate($name); - - if ($this->project && isset($candidates[$this->project])) { - $found = $candidates[$this->project]; - } else if ($theme && isset($candidates['themes'][$theme])) { + + // theme overrides modules + if ($theme && isset($candidates['themes'][$theme])) { $found = array_merge($candidates, $candidates['themes'][$theme]); - } else { - $found = $candidates; } - if(isset($found['themes'])) unset($found['themes']); - + // project overrides theme + if ($this->project && isset($candidates[$this->project])) { + $found = array_merge($found, $candidates[$this->project]); + } + + $found = ($found) ? $found : $candidates; + + if (isset($found['themes'])) unset($found['themes']); + if (isset($found[$this->project])) unset($found[$this->project]); + return $found; } diff --git a/css/UploadField.css b/css/UploadField.css index 8d5ffcdd3..d4df94d49 100644 --- a/css/UploadField.css +++ b/css/UploadField.css @@ -53,3 +53,4 @@ Used in side panels and action tabs .ss-upload .clear { clear: both; } .ss-upload .ss-uploadfield-fromcomputer input { /* since we can't really style the file input, we use this hack to make it as big as the button and hide it */ position: absolute; top: 0; right: 0; margin: 0; opacity: 0; filter: alpha(opacity=0); transform: translate(-300px, 0) scale(4); font-size: 23px; direction: ltr; cursor: pointer; height: 30px; line-height: 30px; } +.ss-upload .loader { height: 94px; background: transparent url(../admin/images/spinner.gif) no-repeat 50% 50%; } diff --git a/docs/en/changelogs/3.0.10.md b/docs/en/changelogs/3.0.10.md new file mode 100644 index 000000000..427b54498 --- /dev/null +++ b/docs/en/changelogs/3.0.10.md @@ -0,0 +1,12 @@ +# 3.0.10 + +## Overview + + * Security: Partially cached content from stage or other reading modes is no longer emitted to live + +## Upgrading + + * If relying on partial caching of content between logged in users, be aware that the cache is now automatically + segmented based on both the current member ID, and the versioned reading mode. If this is not an appropriate + method (such as if the same content is served to logged in users within partial caching) then it is necessary + to adjust the config value of `SSViewer::global_key` to something more or less sensitive. \ No newline at end of file diff --git a/docs/en/howto/gridfield-rowaction.md b/docs/en/howto/gridfield-rowaction.md index ba7e450a9..32e05b25e 100644 --- a/docs/en/howto/gridfield-rowaction.md +++ b/docs/en/howto/gridfield-rowaction.md @@ -83,10 +83,17 @@ a new instance of the class to the [api:GridFieldConfig] object. The `GridField` manipulating the `GridFieldConfig` instance if required. :::php + // option 1: creating a new GridField with the CustomAction $config = GridFieldConfig::create(); $config->addComponent(new GridFieldCustomAction()); $gridField = new GridField('Teams', 'Teams', $this->Teams(), $config); + + // option 2: adding the CustomAction to an exisitng GridField + $gridField->getConfig()->addComponent(new GridFieldCustomAction()); + +For documentation on adding a Component to a `GridField` created by `ModelAdmin` +please view the [ModelAdmin Reference](/reference/modeladmin#gridfield-customization) section `GridField Customization` Now let's go back and dive through the `GridFieldCustomAction` class in more detail. diff --git a/docs/en/reference/form-field-types.md b/docs/en/reference/form-field-types.md index 3d45812fc..5aeef002f 100644 --- a/docs/en/reference/form-field-types.md +++ b/docs/en/reference/form-field-types.md @@ -28,7 +28,7 @@ This is a highlevel overview of available `[api:FormField]` subclasses. An autom * `[api:DatetimeField]`: Combined date- and time field. * `[api:EmailField]`: Text input field with validation for correct email format according to RFC 2822. * `[api:GroupedDropdownField]`: Grouped dropdown, using tags. - * `[api:HTMLEditorField]. + * `[api:HtmlEditorField]`. * `[api:MoneyField]`: A form field that can save into a `[api:Money]` database field. * `[api:NumericField]`: Text input field with validation for numeric values. * `[api:OptionsetField]`: Set of radio buttons designed to emulate a dropdown. diff --git a/docs/en/reference/modeladmin.md b/docs/en/reference/modeladmin.md index 4f571e2a9..b6d3477b6 100644 --- a/docs/en/reference/modeladmin.md +++ b/docs/en/reference/modeladmin.md @@ -160,19 +160,42 @@ For example, we might want to have a checkbox which limits search results to exp return $list; } } + +### GridField Customization To alter how the results are displayed (via `[api:GridField]`), you can also overload the `getEditForm()` method. For example, to add a new component. :::php class MyAdmin extends ModelAdmin { + private static $managed_models = array('Product','Category'); // ... public function getEditForm($id = null, $fields = null) { $form = parent::getEditForm($id, $fields); - $gridField = $form->Fields()->fieldByName($this->sanitiseClassName($this->modelClass)); + // $gridFieldName is generated from the ModelClass, eg if the Class 'Product' + // is managed by this ModelAdmin, the GridField for it will also be named 'Product' + $gridFieldName = $this->sanitiseClassName($this->modelClass); + $gridField = $form->Fields()->fieldByName($gridFieldName); $gridField->getConfig()->addComponent(new GridFieldFilterHeader()); return $form; } } + +The above example will add the component to all `GridField`s (of all managed models). Alternatively we can also add it to only one specific `GridField`: + + :::php + class MyAdmin extends ModelAdmin { + private static $managed_models = array('Product','Category'); + // ... + public function getEditForm($id = null, $fields = null) { + $form = parent::getEditForm($id, $fields); + $gridFieldName = 'Product'; + $gridField = $form->Fields()->fieldByName($gridFieldName); + if ($gridField) { + $gridField->getConfig()->addComponent(new GridFieldFilterHeader()); + } + return $form; + } + } ## Managing Relationships diff --git a/docs/en/reference/partial-caching.md b/docs/en/reference/partial-caching.md index 5bf957596..64e5c8366 100644 --- a/docs/en/reference/partial-caching.md +++ b/docs/en/reference/partial-caching.md @@ -51,6 +51,19 @@ From a block that shows a summary of the page edits if administrator, nothing if <% cached 'loginblock', LastEdited, CurrentMember.isAdmin %> +An additional global key is incorporated in the cache lookup. The default value for this is +`$CurrentReadingMode, $CurrentUser.ID`, which ensures that the current `[api:Versioned]` state and user ID are +used. This may be configured by changing the config value of `SSViewer.global_key`. It is also necessary +to flush the template caching when modifying this config, as this key is cached within the template itself. + +For example, to ensure that the cache is configured to respect another variable, and if the current logged in +user does not influence your template content, you can update this key as below; + + :::yaml + SSViewer: + global_key: '$CurrentReadingMode, $Locale' + + ## Aggregates Often you want to invalidate a cache when any in a set of objects change, or when the objects in a relationship change. diff --git a/docs/en/reference/restfulservice.md b/docs/en/reference/restfulservice.md index 1898129d8..c2e123dd3 100644 --- a/docs/en/reference/restfulservice.md +++ b/docs/en/reference/restfulservice.md @@ -2,8 +2,7 @@ ## Introduction -`[api:RestfulService]` enables connecting to remote web services which supports REST interface and consume those web services -(for example [Flickr](http://www.flickr.com/services/api/), [Youtube](http://code.google.com/apis/youtube/overview.html), Amazon and etc). `[api:RestfulService]` can parse the XML response (sorry no JSON support) +`[api:RestfulService]` uses the php curl library, enabling connections to remote web services which support a REST interface and consuming those web services. (Examples: [Flickr](http://www.flickr.com/services/api/), [Youtube](http://code.google.com/apis/youtube/overview.html), Amazon and etc). `[api:RestfulService]` can parse the XML response (sorry no JSON support) returned from the web service. Further it supports caching of the response, and you can customize the cache interval. To gain the functionality you can either create a new `[api:RestfulService]` object or create a class extending the diff --git a/filesystem/File.php b/filesystem/File.php index a455f89b6..a47a2317b 100644 --- a/filesystem/File.php +++ b/filesystem/File.php @@ -107,6 +107,10 @@ class File extends DataObject { private static $extensions = array( "Hierarchy", ); + + private static $casting = array ( + 'TreeTitle' => 'HTMLText' + ); /** * @config diff --git a/filesystem/Folder.php b/filesystem/Folder.php index cbef4301d..bad6d0d0f 100644 --- a/filesystem/Folder.php +++ b/filesystem/Folder.php @@ -24,10 +24,6 @@ class Folder extends File { private static $plural_name = "Folders"; private static $default_sort = "\"Name\""; - - private static $casting = array ( - 'TreeTitle' => 'HTMLText' - ); /** * diff --git a/forms/HtmlEditorConfig.php b/forms/HtmlEditorConfig.php index e9a46704c..91f4eb08d 100644 --- a/forms/HtmlEditorConfig.php +++ b/forms/HtmlEditorConfig.php @@ -69,7 +69,7 @@ class HtmlEditorConfig { protected $settings = array( 'friendly_name' => '(Please set a friendly name for this config)', 'priority' => 0, - 'mode' => "specific_textareas", + 'mode' => "none", // initialized through HtmlEditorField.js redraw() logic 'editor_selector' => "htmleditor", 'width' => "100%", 'auto_resize' => false, diff --git a/javascript/UploadField.js b/javascript/UploadField.js index 20f29edce..37bf858ed 100644 --- a/javascript/UploadField.js +++ b/javascript/UploadField.js @@ -295,18 +295,30 @@ dialog.ssdialog('open'); }, attachFiles: function(ids, uploadedFileId) { - var self = this, config = this.getConfig(); - $.post( - config['urlAttach'], - {'ids': ids}, - function(data, status, xhr) { + var self = this, + config = this.getConfig(), + indicator = $('
'), + target = (uploadedFileId) ? this.find(".ss-uploadfield-item[data-fileid='"+uploadedFileId+"']") : this.find('.ss-uploadfield-addfile'); + + target.children().hide(); + target.append(indicator); + + $.ajax({ + type: "POST", + url: config['urlAttach'], + data: {'ids': ids}, + complete: function(xhr, status) { + target.children().show(); + indicator.remove(); + }, + success: function(data, status, xhr) { self.fileupload('attach', { files: data, options: self.fileupload('option'), replaceFileID: uploadedFileId }); } - ); + }); } }); $('div.ss-upload *').entwine({ diff --git a/model/DB.php b/model/DB.php index 064e71fbc..558fb72f5 100644 --- a/model/DB.php +++ b/model/DB.php @@ -147,12 +147,18 @@ class DB { /** * Connect to a database. - * Given the database configuration, this method will create the correct subclass of SS_Database, - * and set it as the global connection. + * + * Given the database configuration, this method will create the correct + * subclass of {@link SS_Database}. + * * @param array $database A map of options. The 'type' is the name of the subclass of SS_Database to use. For the * rest of the options, see the specific class. + * @param string $name identifier for the connection + * + * @return SS_Database */ - public static function connect($databaseConfig) { + public static function connect($databaseConfig, $label = 'default') { + // This is used by the "testsession" module to test up a test session using an alternative name if($name = self::get_alternative_database_name()) { $databaseConfig['database'] = $name; @@ -167,7 +173,9 @@ class DB { $dbClass = $databaseConfig['type']; $conn = new $dbClass($databaseConfig); - self::setConn($conn); + self::setConn($conn, $label); + + return $conn; } /** diff --git a/model/MySQLDatabase.php b/model/MySQLDatabase.php index e35a92f2b..8e4ce3382 100644 --- a/model/MySQLDatabase.php +++ b/model/MySQLDatabase.php @@ -67,7 +67,7 @@ class MySQLDatabase extends SS_Database { } else { $this->dbConn = new MySQLi($parameters['server'], $parameters['username'], $parameters['password']); } - + if($this->dbConn->connect_error) { $this->databaseError("Couldn't connect to MySQL database | " . $this->dbConn->connect_error); } @@ -86,6 +86,12 @@ class MySQLDatabase extends SS_Database { } } + public function __destruct() { + if($this->dbConn) { + mysqli_close($this->dbConn); + } + } + /** * Not implemented, needed for PDO */ diff --git a/model/Versioned.php b/model/Versioned.php index 5c1911da5..4dd192d1d 100644 --- a/model/Versioned.php +++ b/model/Versioned.php @@ -8,8 +8,7 @@ * @package framework * @subpackage model */ -class Versioned extends DataExtension { - +class Versioned extends DataExtension implements TemplateGlobalProvider { /** * An array of possible stages. * @var array @@ -1358,6 +1357,12 @@ class Versioned extends DataExtension { public function getDefaultStage() { return $this->defaultStage; } + + public static function get_template_global_variables() { + return array( + 'CurrentReadingMode' => 'get_reading_mode' + ); + } } /** diff --git a/model/fieldtypes/Date.php b/model/fieldtypes/Date.php index f9cb4c3f1..0dfe6dec7 100644 --- a/model/fieldtypes/Date.php +++ b/model/fieldtypes/Date.php @@ -152,6 +152,28 @@ class Date extends DBField { } } + /** + * Return a date formatted as per a CMS user's settings. + * + * @param Member $member + * @return boolean | string A date formatted as per user-defined settings. + */ + public function FormatFromSettings($member = null) { + require_once 'Zend/Date.php'; + + if(!$member) { + if(!Member::currentUserID()) { + return false; + } + $member = Member::currentUser(); + } + + $formatD = $member->getDateFormat(); + $zendDate = new Zend_Date($this->getValue()); + + return $zendDate->toString($formatD); + } + /* * Return a string in the form "12 - 16 Sept" or "12 Aug - 16 Sept" * @param Date $otherDateObj Another date object specifying the end of the range diff --git a/model/fieldtypes/Datetime.php b/model/fieldtypes/Datetime.php index 286abdf94..7ff556fdb 100644 --- a/model/fieldtypes/Datetime.php +++ b/model/fieldtypes/Datetime.php @@ -92,6 +92,29 @@ class SS_Datetime extends Date implements TemplateGlobalProvider { public function Time24() { if($this->value) return $this->Format('H:i'); } + + /** + * Return a date and time formatted as per a CMS user's settings. + * + * @param Member $member + * @return boolean | string A time and date pair formatted as per user-defined settings. + */ + public function FormatFromSettings($member = null) { + require_once 'Zend/Date.php'; + + if(!$member) { + if(!Member::currentUserID()) { + return false; + } + $member = Member::currentUser(); + } + + $formatD = $member->getDateFormat(); + $formatT = $member->getTimeFormat(); + + $zendDate = new Zend_Date($this->getValue()); + return $zendDate->toString($formatD).' '.$zendDate->toString($formatT); + } public function requireField() { $parts=Array('datatype'=>'datetime', 'arrayValue'=>$this->arrayValue); diff --git a/scss/UploadField.scss b/scss/UploadField.scss index 61a4bccbb..57fb1098e 100644 --- a/scss/UploadField.scss +++ b/scss/UploadField.scss @@ -284,4 +284,8 @@ line-height: 30px; } } + .loader { + height: 94px; // Approxmiately matches the height of the field once a file is attached, avoids a 'jump' in size + background: transparent url(../admin/images/spinner.gif) no-repeat 50% 50%; + } } \ No newline at end of file diff --git a/tests/core/manifest/TemplateLoaderTest.php b/tests/core/manifest/TemplateLoaderTest.php index b4e154ced..878e96c87 100644 --- a/tests/core/manifest/TemplateLoaderTest.php +++ b/tests/core/manifest/TemplateLoaderTest.php @@ -6,64 +6,184 @@ * @subpackage tests */ class TemplateLoaderTest extends SapphireTest { - - public function testFindTemplates() { - $base = dirname(__FILE__) . '/fixtures/templatemanifest'; - $manifest = new SS_TemplateManifest($base, 'myproject', false, true); - $loader = new SS_TemplateLoader(); - - $manifest->regenerate(false); - $loader->pushManifest($manifest); - - $expectPage = array( - 'main' => "$base/module/templates/Page.ss", - 'Layout' => "$base/module/templates/Layout/Page.ss" - ); - $expectPageThemed = array( - 'main' => "$base/themes/theme/templates/Page.ss", - 'Layout' => "$base/themes/theme/templates/Layout/Page.ss" - ); - - $this->assertEquals($expectPage, $loader->findTemplates('Page')); - $this->assertEquals($expectPage, $loader->findTemplates(array('Foo', 'Page'))); - $this->assertEquals($expectPage, $loader->findTemplates('PAGE')); - $this->assertEquals($expectPageThemed, $loader->findTemplates('Page', 'theme')); - - $expectPageLayout = array('main' => "$base/module/templates/Layout/Page.ss"); - $expectPageLayoutThemed = array('main' => "$base/themes/theme/templates/Layout/Page.ss"); - - $this->assertEquals($expectPageLayout, $loader->findTemplates('Layout/Page')); - $this->assertEquals($expectPageLayout, $loader->findTemplates('Layout/PAGE')); - $this->assertEquals($expectPageLayoutThemed, $loader->findTemplates('Layout/Page', 'theme')); - - $expectCustomPage = array( - 'main' => "$base/module/templates/Page.ss", - 'Layout' => "$base/module/templates/Layout/CustomPage.ss" - ); - - $this->assertEquals($expectCustomPage, $loader->findTemplates(array('CustomPage', 'Page'))); - - // 'main' template only exists in theme, and 'Layout' template only exists in module - $expectCustomThemePage = array( - 'main' => "$base/themes/theme/templates/CustomThemePage.ss", - 'Layout' => "$base/module/templates/Layout/CustomThemePage.ss" - ); - $this->assertEquals($expectCustomThemePage, $loader->findTemplates(array('CustomThemePage', 'Page'), 'theme')); + + private $base; + private $manifest; + private $loader; + + /** + * Set up manifest before each test + */ + public function setUp() { + parent::setUp(); + $this->base = dirname(__FILE__) . '/fixtures/templatemanifest'; + $this->manifest = new SS_TemplateManifest($this->base, 'myproject', false, true); + $this->loader = new SS_TemplateLoader(); + $this->refreshLoader(); } - - public function testFindTemplatesApplicationOverridesModule() { - $base = dirname(__FILE__) . '/fixtures/templatemanifest'; - $manifest = new SS_TemplateManifest($base, 'myproject', false, true); - $loader = new SS_TemplateLoader(); - - $manifest->regenerate(false); - $loader->pushManifest($manifest); - - $expectPage = array( - 'main' => "$base/myproject/templates/CustomTemplate.ss" + + /** + * Test that 'main' and 'Layout' templates are loaded from module + */ + public function testFindTemplatesInModule() { + $expect = array( + 'main' => "$this->base/module/templates/Page.ss", + 'Layout' => "$this->base/module/templates/Layout/Page.ss" ); - - $this->assertEquals($expectPage, $loader->findTemplates('CustomTemplate')); + $this->assertEquals($expect, $this->loader->findTemplates('Page')); + $this->assertEquals($expect, $this->loader->findTemplates('PAGE')); + $this->assertEquals($expect, $this->loader->findTemplates(array('Foo', 'Page'))); + } + + /** + * Test that 'main' and 'Layout' templates are loaded from set theme + */ + public function testFindTemplatesInTheme() { + $expect = array( + 'main' => "$this->base/themes/theme/templates/Page.ss", + 'Layout' => "$this->base/themes/theme/templates/Layout/Page.ss" + ); + $this->assertEquals($expect, $this->loader->findTemplates('Page', 'theme')); + $this->assertEquals($expect, $this->loader->findTemplates('PAGE', 'theme')); + $this->assertEquals($expect, $this->loader->findTemplates(array('Foo', 'Page'), 'theme')); + } + + /** + * Test that 'main' and 'Layout' templates are loaded from project without a set theme + */ + public function testFindTemplatesInApplication() { + $templates = array( + $this->base . '/myproject/templates/Page.ss', + $this->base . '/myproject/templates/Layout/Page.ss' + ); + $this->createTestTemplates($templates); + $this->refreshLoader(); + + $expect = array( + 'main' => "$this->base/myproject/templates/Page.ss", + 'Layout' => "$this->base/myproject/templates/Layout/Page.ss" + ); + $this->assertEquals($expect, $this->loader->findTemplates('Page')); + $this->assertEquals($expect, $this->loader->findTemplates('PAGE')); + $this->assertEquals($expect, $this->loader->findTemplates(array('Foo', 'Page'))); + + $this->removeTestTemplates($templates); + } + + /** + * Test that 'Layout' template is loaded from module + */ + public function testFindTemplatesInModuleLayout() { + $expect = array( + 'main' => "$this->base/module/templates/Layout/Page.ss" + ); + $this->assertEquals($expect, $this->loader->findTemplates('Layout/Page')); + } + + /** + * Test that 'Layout' template is loaded from theme + */ + public function testFindTemplatesInThemeLayout() { + $expect = array( + 'main' => "$this->base/themes/theme/templates/Layout/Page.ss" + ); + $this->assertEquals($expect, $this->loader->findTemplates('Layout/Page', 'theme')); + } + + /** + * Test that 'main' template is found in theme and 'Layout' is found in module + */ + public function testFindTemplatesMainThemeLayoutModule() { + $expect = array( + 'main' => "$this->base/themes/theme/templates/CustomThemePage.ss", + 'Layout' => "$this->base/module/templates/Layout/CustomThemePage.ss" + ); + $this->assertEquals($expect, $this->loader->findTemplates(array('CustomThemePage', 'Page'), 'theme')); + } + + /** + * Test that project template overrides module template of same name + */ + public function testFindTemplatesApplicationOverridesModule() { + $expect = array( + 'main' => "$this->base/myproject/templates/CustomTemplate.ss" + ); + $this->assertEquals($expect, $this->loader->findTemplates('CustomTemplate')); + } + + /** + * Test that project templates overrides theme templates + */ + public function testFindTemplatesApplicationOverridesTheme() { + $templates = array( + $this->base . '/myproject/templates/Page.ss', + $this->base . '/myproject/templates/Layout/Page.ss' + ); + $this->createTestTemplates($templates); + $this->refreshLoader(); + + $expect = array( + 'main' => "$this->base/myproject/templates/Page.ss", + 'Layout' => "$this->base/myproject/templates/Layout/Page.ss" + ); + $this->assertEquals($expect, $this->loader->findTemplates('Page'), 'theme'); + + $this->removeTestTemplates($templates); + } + + /** + * Test that project 'Layout' template overrides theme 'Layout' template + */ + public function testFindTemplatesApplicationLayoutOverridesThemeLayout() { + $templates = array( + $this->base . '/myproject/templates/Layout/Page.ss' + ); + $this->createTestTemplates($templates); + $this->refreshLoader(); + + $expect = array( + 'main' => "$this->base/themes/theme/templates/Page.ss", + 'Layout' => "$this->base/myproject/templates/Layout/Page.ss" + ); + $this->assertEquals($expect, $this->loader->findTemplates('Page', 'theme')); + + $this->removeTestTemplates($templates); + } + + /** + * Test that project 'main' template overrides theme 'main' template + */ + public function testFindTemplatesApplicationMainOverridesThemeMain() { + $templates = array( + $this->base . '/myproject/templates/Page.ss' + ); + $this->createTestTemplates($templates); + $this->refreshLoader(); + + $expect = array( + 'main' => "$this->base/myproject/templates/Page.ss", + 'Layout' => "$this->base/themes/theme/templates/Layout/Page.ss" + ); + $this->assertEquals($expect, $this->loader->findTemplates('Page', 'theme')); + + $this->removeTestTemplates($templates); + } + + protected function refreshLoader() { + $this->manifest->regenerate(false); + $this->loader->pushManifest($this->manifest); + } + + protected function createTestTemplates($templates) { + foreach ($templates as $template) { + file_put_contents($template, ''); + } + } + + protected function removeTestTemplates($templates) { + foreach ($templates as $template) { + unlink($template); + } } } diff --git a/tests/core/manifest/fixtures/templatemanifest/myproject/templates/Layout/.gitignore b/tests/core/manifest/fixtures/templatemanifest/myproject/templates/Layout/.gitignore new file mode 100644 index 000000000..e69de29bb diff --git a/tests/model/DateTest.php b/tests/model/DateTest.php index e4f4498d6..3f471c607 100644 --- a/tests/model/DateTest.php +++ b/tests/model/DateTest.php @@ -201,5 +201,27 @@ class DateTest extends SapphireTest { SS_Datetime::clear_mock_now(); } + + public function testFormatFromSettings() { + + $memberID = $this->logInWithPermission(); + $member = DataObject::get_by_id('Member', $memberID); + $member->DateFormat = 'dd/MM/YYYY'; + $member->write(); + + $fixtures = array( + '2000-12-31' => '31/12/2000', + '31-12-2000' => '31/12/2000', + '31/12/2000' => '31/12/2000' + ); + + foreach($fixtures as $from => $to) { + $date = DBField::create_field('Date', $from); + // With member + $this->assertEquals($to, $date->FormatFromSettings($member)); + // Without member + $this->assertEquals($to, $date->FormatFromSettings()); + } + } } diff --git a/tests/model/DatetimeTest.php b/tests/model/DatetimeTest.php index 0c6ab8a83..66c97fedd 100644 --- a/tests/model/DatetimeTest.php +++ b/tests/model/DatetimeTest.php @@ -148,5 +148,30 @@ class SS_DatetimeTest extends SapphireTest { SS_Datetime::clear_mock_now(); } + + public function testFormatFromSettings() { + + $memberID = $this->logInWithPermission(); + $member = DataObject::get_by_id('Member', $memberID); + $member->DateFormat = 'dd/MM/YYYY'; + $member->TimeFormat = 'hh:mm:ss'; + $member->write(); + + $fixtures = array( + '2000-12-31 10:11:01' => '31/12/2000 10:11:01', + '2000-12-31 1:11:01' => '31/12/2000 01:11:01', + '12/12/2000 1:11:01' => '12/12/2000 01:11:01', + '2000-12-31' => '31/12/2000 12:00:00', + '10:11:01' => date('d/m/Y').' 10:11:01' + ); + + foreach($fixtures as $from => $to) { + $date = DBField::create_field('Datetime', $from); + // With member + $this->assertEquals($to, $date->FormatFromSettings($member)); + // Without member + $this->assertEquals($to, $date->FormatFromSettings()); + } + } } diff --git a/tests/view/SSViewerCacheBlockTest.php b/tests/view/SSViewerCacheBlockTest.php index 0f81a099e..ceb1e4be4 100644 --- a/tests/view/SSViewerCacheBlockTest.php +++ b/tests/view/SSViewerCacheBlockTest.php @@ -20,9 +20,29 @@ class SSViewerCacheBlockTest_Model extends DataObject implements TestOnly { } } +class SSViewerCacheBlockTest_VersionedModel extends DataObject implements TestOnly { + + protected $entropy = 'default'; + + public static $extensions = array( + "Versioned('Stage', 'Live')" + ); + + public function setEntropy($entropy) { + $this->entropy = $entropy; + } + + public function Inspect() { + return $this->entropy . ' ' . Versioned::get_reading_mode(); + } +} + class SSViewerCacheBlockTest extends SapphireTest { - protected $extraDataObjects = array('SSViewerCacheBlockTest_Model'); + protected $extraDataObjects = array( + 'SSViewerCacheBlockTest_Model', + 'SSViewerCacheBlockTest_VersionedModel' + ); protected $data = null; @@ -37,8 +57,7 @@ class SSViewerCacheBlockTest extends SapphireTest { if ($data === null) $data = $this->data; if (is_array($data)) $data = $this->data->customise($data); - $viewer = SSViewer::fromString($template); - return $viewer->process($data); + return SSViewer::execute_string($template, $data); } public function testParsing() { @@ -104,6 +123,77 @@ class SSViewerCacheBlockTest extends SapphireTest { $this->assertEquals($this->_runtemplate('<% cached %>$Foo<% end_cached %>', array('Foo' => 1)), '1'); $this->assertEquals($this->_runtemplate('<% cached %>$Foo<% end_cached %>', array('Foo' => 2)), '1'); } + + public function testVersionedCache() { + + $origStage = Versioned::current_stage(); + + // Run without caching in stage to prove data is uncached + $this->_reset(false); + Versioned::reading_stage("Stage"); + $data = new SSViewerCacheBlockTest_VersionedModel(); + $data->setEntropy('default'); + $this->assertEquals( + 'default Stage.Stage', + SSViewer::execute_string('<% cached %>$Inspect<% end_cached %>', $data) + ); + $data = new SSViewerCacheBlockTest_VersionedModel(); + $data->setEntropy('first'); + $this->assertEquals( + 'first Stage.Stage', + SSViewer::execute_string('<% cached %>$Inspect<% end_cached %>', $data) + ); + + // Run without caching in live to prove data is uncached + $this->_reset(false); + Versioned::reading_stage("Live"); + $data = new SSViewerCacheBlockTest_VersionedModel(); + $data->setEntropy('default'); + $this->assertEquals( + 'default Stage.Live', + $this->_runtemplate('<% cached %>$Inspect<% end_cached %>', $data) + ); + $data = new SSViewerCacheBlockTest_VersionedModel(); + $data->setEntropy('first'); + $this->assertEquals( + 'first Stage.Live', + $this->_runtemplate('<% cached %>$Inspect<% end_cached %>', $data) + ); + + // Then with caching, initially in draft, and then in live, to prove that + // changing the versioned reading mode doesn't cache between modes, but it does + // within them + $this->_reset(true); + Versioned::reading_stage("Stage"); + $data = new SSViewerCacheBlockTest_VersionedModel(); + $data->setEntropy('default'); + $this->assertEquals( + 'default Stage.Stage', + $this->_runtemplate('<% cached %>$Inspect<% end_cached %>', $data) + ); + $data = new SSViewerCacheBlockTest_VersionedModel(); + $data->setEntropy('first'); + $this->assertEquals( + 'default Stage.Stage', // entropy should be ignored due to caching + $this->_runtemplate('<% cached %>$Inspect<% end_cached %>', $data) + ); + + Versioned::reading_stage('Live'); + $data = new SSViewerCacheBlockTest_VersionedModel(); + $data->setEntropy('first'); + $this->assertEquals( + 'first Stage.Live', // First hit in live, so display current entropy + $this->_runtemplate('<% cached %>$Inspect<% end_cached %>', $data) + ); + $data = new SSViewerCacheBlockTest_VersionedModel(); + $data->setEntropy('second'); + $this->assertEquals( + 'first Stage.Live', // entropy should be ignored due to caching + $this->_runtemplate('<% cached %>$Inspect<% end_cached %>', $data) + ); + + Versioned::reading_stage($origStage); + } /** * Test that cacheblocks conditionally cache with if diff --git a/view/SSTemplateParser.php b/view/SSTemplateParser.php index f0f6f4f33..6a625140a 100644 --- a/view/SSTemplateParser.php +++ b/view/SSTemplateParser.php @@ -67,7 +67,7 @@ class SSTemplateParseException extends Exception { * * Angle Bracket: angle brackets "<" and ">" are used to eat whitespace between template elements * N: eats white space including newlines (using in legacy _t support) - * + * * @package framework * @subpackage view */ @@ -2938,10 +2938,25 @@ class SSTemplateParser extends Parser implements TemplateParser { function CacheBlock_CacheBlockTemplate(&$res, $sub){ // Get the block counter $block = ++$res['subblocks']; - // Build the key for this block from the passed cache key, the block index, and the sha hash of the template - // itself - $key = "'" . sha1($sub['php']) . (isset($res['key']) && $res['key'] ? "_'.sha1(".$res['key'].")" : "'") . - ".'_$block'"; + // Build the key for this block from the global key (evaluated in a closure within the template), + // the passed cache key, the block index, and the sha hash of the template. + $res['php'] .= '$keyExpression = function() use ($scope, $cache) {' . PHP_EOL; + $res['php'] .= '$val = \'\';' . PHP_EOL; + if($globalKey = Config::inst()->get('SSViewer', 'global_key')) { + // Embed the code necessary to evaluate the globalKey directly into the template, + // so that SSTemplateParser only needs to be called during template regeneration. + // Warning: If the global key is changed, it's necessary to flush the template cache. + $parser = new SSTemplateParser($globalKey); + $result = $parser->match_Template(); + if(!$result) throw new SSTemplateParseException('Unexpected problem parsing template', $parser); + $res['php'] .= $result['php'] . PHP_EOL; + } + $res['php'] .= 'return $val;' . PHP_EOL; + $res['php'] .= '};' . PHP_EOL; + $key = 'sha1($keyExpression())' // Global key + . '.\'_' . sha1($sub['php']) // sha of template + . (isset($res['key']) && $res['key'] ? "_'.sha1(".$res['key'].")" : "'") // Passed key + . ".'_$block'"; // block index // Get any condition $condition = isset($res['condition']) ? $res['condition'] : ''; diff --git a/view/SSTemplateParser.php.inc b/view/SSTemplateParser.php.inc index 5a750c7dd..eb467fb14 100644 --- a/view/SSTemplateParser.php.inc +++ b/view/SSTemplateParser.php.inc @@ -673,10 +673,25 @@ class SSTemplateParser extends Parser implements TemplateParser { function CacheBlock_CacheBlockTemplate(&$res, $sub){ // Get the block counter $block = ++$res['subblocks']; - // Build the key for this block from the passed cache key, the block index, and the sha hash of the template - // itself - $key = "'" . sha1($sub['php']) . (isset($res['key']) && $res['key'] ? "_'.sha1(".$res['key'].")" : "'") . - ".'_$block'"; + // Build the key for this block from the global key (evaluated in a closure within the template), + // the passed cache key, the block index, and the sha hash of the template. + $res['php'] .= '$keyExpression = function() use ($scope, $cache) {' . PHP_EOL; + $res['php'] .= '$val = \'\';' . PHP_EOL; + if($globalKey = Config::inst()->get('SSViewer', 'global_key')) { + // Embed the code necessary to evaluate the globalKey directly into the template, + // so that SSTemplateParser only needs to be called during template regeneration. + // Warning: If the global key is changed, it's necessary to flush the template cache. + $parser = new SSTemplateParser($globalKey); + $result = $parser->match_Template(); + if(!$result) throw new SSTemplateParseException('Unexpected problem parsing template', $parser); + $res['php'] .= $result['php'] . PHP_EOL; + } + $res['php'] .= 'return $val;' . PHP_EOL; + $res['php'] .= '};' . PHP_EOL; + $key = 'sha1($keyExpression())' // Global key + . '.\'_' . sha1($sub['php']) // sha of template + . (isset($res['key']) && $res['key'] ? "_'.sha1(".$res['key'].")" : "'") // Passed key + . ".'_$block'"; // block index // Get any condition $condition = isset($res['condition']) ? $res['condition'] : ''; diff --git a/view/SSViewer.php b/view/SSViewer.php index 4dd6c7b52..95008e181 100644 --- a/view/SSViewer.php +++ b/view/SSViewer.php @@ -48,7 +48,6 @@ class SSViewer_Scope { private $localIndex; - public function __construct($item, $inheritedScope = null) { $this->item = $item; $this->localIndex = 0; @@ -631,6 +630,14 @@ class SSViewer { */ protected $parser; + /* + * Default prepended cache key for partial caching + * + * @var string + * @config + */ + private static $global_key = '$CurrentReadingMode, $CurrentUser.ID'; + /** * Create a template from a string instead of a .ss file * @@ -1096,6 +1103,11 @@ class SSViewer { /** * Execute the given template, passing it the given data. * Used by the <% include %> template tag to process templates. + * + * @param string $template Template name + * @param mixed $data Data context + * @param array $arguments Additional arguments + * @return string Evaluated result */ public static function execute_template($template, $data, $arguments = null, $scope = null) { $v = new SSViewer($template); @@ -1103,6 +1115,23 @@ class SSViewer { return $v->process($data, $arguments, $scope); } + + /** + * Execute the evaluated string, passing it the given data. + * Used by partial caching to evaluate custom cache keys expressed using + * template expressions + * + * @param string $content Input string + * @param mixed $data Data context + * @param array $arguments Additional arguments + * @return string Evaluated result + */ + public static function execute_string($content, $data, $arguments = null) { + $v = SSViewer::fromString($content); + $v->includeRequirements(false); + + return $v->process($data, $arguments); + } public function parseTemplateContent($content, $template="") { return $this->parser->compileString(