diff --git a/_config/passwords.yml b/_config/passwords.yml
new file mode 100644
index 000000000..fc865200a
--- /dev/null
+++ b/_config/passwords.yml
@@ -0,0 +1,13 @@
+---
+Name: corepasswords
+---
+SilverStripe\Core\Injector\Injector:
+ SilverStripe\Security\PasswordValidator:
+ properties:
+ MinLength: 8
+ HistoricCount: 6
+
+# In the case someone uses `new PasswordValidator` instead of Injector, provide some safe defaults through config.
+SilverStripe\Security\PasswordValidator:
+ min_length: 8
+ historic_count: 6
diff --git a/docs/en/02_Developer_Guides/00_Model/10_Versioning.md b/docs/en/02_Developer_Guides/00_Model/10_Versioning.md
index 40538951d..6ffecad81 100644
--- a/docs/en/02_Developer_Guides/00_Model/10_Versioning.md
+++ b/docs/en/02_Developer_Guides/00_Model/10_Versioning.md
@@ -838,7 +838,420 @@ $obj = MyRecord::getComplexObjectRetrieval(); // returns 'Stage' records
Versioned::set_reading_mode($origMode); // reset current mode
```
+## Using the history viewer
+
+Since SilverStripe 4.3 you can use the React and GraphQL driven history viewer UI to display historic changes and
+comparisons for a versioned DataObject. This is automatically enabled for SiteTree objects and content blocks in
+[dnadesign/silverstripe-elemental](https://github.com/dnadesign/silverstripe-elemental).
+
+If you want to enable the history viewer for a custom versioned DataObject, you will need to:
+
+* Expose GraphQL scaffolding
+* Add the necessary GraphQL queries and mutations to your module
+* Register your GraphQL queries and mutations with Injector
+* Add a HistoryViewerField to the DataObject's `getCMSFields`
+
+**Please note:** these examples are given in the context of project-level customisation. You may need to adjust
+the webpack configuration slightly for use in a module. They are also designed to be used on SilverStripe 4.3 or
+later.
+
+For these examples, you can use this simple DataObject and create a ModelAdmin for it:
+
+```php
+use SilverStripe\ORM\DataObject;
+use SilverStripe\Versioned\Versioned;
+
+class MyVersionedObject extends DataObject
+{
+ private static $db = [
+ 'Title' => 'Varchar',
+ ];
+
+ private static $extensions = [
+ Versioned::class,
+ ];
+}
+```
+
+### Configure frontend asset building
+
+If you haven't already configured frontend asset building for your project, you will need to configure some basic
+packages to be built via webpack in order to enable history viewer functionality. If you have this configured for
+your project already, ensure you have the `react-apollo` and `graphql-tag` libraries in your `package.json`
+requirements, and skip this section.
+
+You can configure your directory structure like so:
+
+**package.json**
+
+```json
+{
+ "name": "my-project",
+ "scripts": {
+ "build": "yarn && NODE_ENV=production webpack -p --bail --progress",
+ "watch": "yarn && NODE_ENV=development webpack --watch --progress"
+ },
+ "dependencies": {
+ "react-apollo": "^0.7.1",
+ "graphql-tag": "^0.1.17"
+ },
+ "devDependencies": {
+ "@silverstripe/webpack-config": "^0.4.1",
+ "webpack": "^2.6.1"
+ },
+ "jest": {
+ "roots": [
+ "client/src"
+ ],
+ "moduleDirectories": [
+ "app/client/src",
+ "node_modules",
+ "node_modules/@silverstripe/webpack-config/node_modules",
+ "vendor/silverstripe/admin/client/src",
+ "vendor/silverstripe/admin/node_modules"
+ ]
+ },
+ "babel": {
+ "presets": [
+ "env",
+ "react"
+ ],
+ "plugins": [
+ "transform-object-rest-spread"
+ ]
+ },
+ "engines": {
+ "node": "^6.x"
+ }
+}
+```
+
+**webpack.config.js**
+
+```json
+const Path = require('path');
+// Import the core config
+const webpackConfig = require('@silverstripe/webpack-config');
+const {
+ resolveJS,
+ externalJS,
+ moduleJS,
+ pluginJS,
+} = webpackConfig;
+
+const ENV = process.env.NODE_ENV;
+const PATHS = {
+ MODULES: 'node_modules',
+ FILES_PATH: '../',
+ ROOT: Path.resolve(),
+ SRC: Path.resolve('app/client/src'),
+ DIST: Path.resolve('app/client/dist'),
+};
+
+const config = [
+ {
+ name: 'js',
+ entry: {
+ bundle: `${PATHS.SRC}/boot/index.js`,
+ },
+ output: {
+ path: PATHS.DIST,
+ filename: 'js/[name].js',
+ },
+ devtool: (ENV !== 'production') ? 'source-map' : '',
+ resolve: resolveJS(ENV, PATHS),
+ externals: externalJS(ENV, PATHS),
+ module: moduleJS(ENV, PATHS),
+ plugins: pluginJS(ENV, PATHS),
+ }
+];
+
+// Use WEBPACK_CHILD=js or WEBPACK_CHILD=css env var to run a single config
+module.exports = (process.env.WEBPACK_CHILD)
+ ? config.find((entry) => entry.name === process.env.WEBPACK_CHILD)
+ : module.exports = config;
+```
+
+**composer.json**
+
+```json
+"extra": {
+ "expose": [
+ "app/client/dist"
+ ]
+}
+```
+
+**app/client/src/boot/index.js**
+
+```js
+console.log('Hello world');
+```
+
+**.eslintrc.js**
+
+```js
+module.exports = require('@silverstripe/webpack-config/.eslintrc');
+```
+
+At this stage, running `yarn build` should show you a linting warning for the console statement, and correctly build
+`app/client/dist/js/bundle.js`.
+
+### Expose GraphQL scaffolding
+
+Only a minimal amount of data is required to be exposed via GraphQL scaffolding, and only to the "admin" GraphQL
+schema. For more information, see [ReactJS, Redux and GraphQL](../../customising_the_admin_interface/react_redux_and_graphql).
+
+**app/_config/graphql.yml**
+
+```yaml
+SilverStripe\GraphQL\Manager:
+ schemas:
+ admin:
+ scaffolding:
+ types:
+ MyVersionedObject:
+ fields: [ID, LastEdited]
+ operations:
+ readOne: true
+ SilverStripe\Security\Member:
+ fields: [ID, FirstName, Surname]
+ operations:
+ readOne: true
+```
+
+Once configured, flush your cache and explore the new GraphQL schema to ensure it loads correctly. You can use a GraphQL
+application such as GraphiQL, or [silverstripe-graphql-devtools](https://github.com/silverstripe/silverstripe-graphql-devtools)
+for a browser solution:
+
+```
+composer require --dev silverstripe/graphql-devtools dev-master
+```
+
+### Configure the necessary GraphQL queries and mutations
+
+The history viewer interface uses two main operations:
+
+* Read a list of versions for a DataObject
+* Revert to an older version of a DataObject
+
+For this we need one query and one mutation:
+
+**app/client/src/state/readOneMyVersionedObjectQuery.js**
+
+```js
+import { graphql } from 'react-apollo';
+import gql from 'graphql-tag';
+
+// GraphQL query for retrieving the version history of a specific object. The results of
+// the query must be set to the "versions" prop on the component that this HOC is
+// applied to for binding implementation.
+const query = gql`
+query ReadHistoryViewerMyVersionedObject ($id: ID!, $limit: Int!, $offset: Int!) {
+ readOneMyVersionedObject(
+ Versioning: {
+ Mode: LATEST
+ },
+ ID: $id
+ ) {
+ ID
+ Versions (limit: $limit, offset: $offset) {
+ pageInfo {
+ totalCount
+ }
+ edges {
+ node {
+ Version
+ Author {
+ FirstName
+ Surname
+ }
+ Publisher {
+ FirstName
+ Surname
+ }
+ Published
+ LiveVersion
+ LatestDraftVersion
+ LastEdited
+ }
+ }
+ }
+ }
+}
+`;
+
+const config = {
+ options({recordId, limit, page}) {
+ return {
+ variables: {
+ limit,
+ offset: ((page || 1) - 1) * limit,
+ block_id: recordId,
+ }
+ };
+ },
+ props(
+ {
+ data: {
+ error,
+ refetch,
+ readOneMyVersionedObject,
+ loading: networkLoading,
+ },
+ ownProps: {
+ actions = {
+ versions: {}
+ },
+ limit,
+ recordId,
+ },
+ }
+ ) {
+ const versions = readOneMyVersionedObject || null;
+
+ const errors = error && error.graphQLErrors &&
+ error.graphQLErrors.map((graphQLError) => graphQLError.message);
+
+ return {
+ loading: networkLoading || !versions,
+ versions,
+ graphQLErrors: errors,
+ actions: {
+ ...actions,
+ versions: {
+ ...versions,
+ goToPage(page) {
+ refetch({
+ offset: ((page || 1) - 1) * limit,
+ limit,
+ block_id: recordId,
+ });
+ }
+ },
+ },
+ };
+ },
+};
+
+export { query, config };
+
+export default graphql(query, config);
+```
+
+**app/client/src/state/revertToMyVersionedObjectVersionMutation.js**
+
+```js
+import { graphql } from 'react-apollo';
+import gql from 'graphql-tag';
+
+const mutation = gql`
+mutation revertMyVersionedObjectToVersion($id:ID!, $toVersion:Int!) {
+ rollbackMyVersionedObject(
+ ID: $id
+ ToVersion: $toVersion
+ ) {
+ ID
+ }
+}
+`;
+
+const config = {
+ props: ({ mutate, ownProps: { actions } }) => {
+ const revertToVersion = (id, toVersion) => mutate({
+ variables: {
+ id,
+ toVersion,
+ },
+ });
+
+ return {
+ actions: {
+ ...actions,
+ revertToVersion,
+ },
+ };
+ },
+ options: {
+ // Refetch versions after mutation is completed
+ refetchQueries: ['ReadHistoryViewerMyVersionedObject']
+ }
+};
+
+export { mutation, config };
+
+export default graphql(mutation, config);
+````
+
+### Register your GraphQL query and mutation with Injector
+
+Once your GraphQL query and mutation are created, you will need to tell the JavaScript Injector about them.
+This does two things:
+
+* Allow them to be loaded by core components.
+* Allow Injector to provide them in certain contexts. They should be available for `MyVersionedObject` history viewer
+ instances, but not for CMS pages for example.
+
+**app/client/src/boot/index.js**
+
+```js
+/* global window */
+import Injector from 'lib/Injector';
+import readOneMyVersionedObjectQuery from 'state/readOneMyVersionedObjectQuery';
+import revertToMyVersionedObjectVersionMutation from 'state/revertToMyVersionedObjectVersionMutation';
+
+window.document.addEventListener('DOMContentLoaded', () => {
+ // Register GraphQL operations with Injector as transformations
+ Injector.transform(
+ 'myversionedobject-history', (updater) => {
+ updater.component(
+ 'HistoryViewer.Form_ItemEditForm',
+ readOneMyVersionedObjectQuery, 'ElementHistoryViewer');
+ }
+ );
+
+ Injector.transform(
+ 'myversionedobject-history-revert', (updater) => {
+ updater.component(
+ 'HistoryViewerToolbar.VersionedAdmin.HistoryViewer.MyVersionedObject.HistoryViewerVersionDetail',
+ revertToMyVersionedObjectVersionMutation,
+ 'MyVersionedObjectRevertMutation'
+ );
+ }
+ );
+});
+```
+
+For more information, see [ReactJS, Redux and GraphQL](../../customising_the_admin_interface/react_redux_and_graphql).
+
+### Adding the HistoryViewerField
+
+You can add the [HistoryViewerField](api:SilverStripe\VersionedAdmin\Forms\HistoryViewerField) to your object's CMS
+fields in the same way as any other form field:
+
+```php
+use SilverStripe\VersionedAdmin\Forms\HistoryViewerField;
+use SilverStripe\View\Requirements;
+
+public function getCMSFields()
+{
+ $fields = parent::getCMSFields();
+
+ Requirements::javascript('app/client/dist/js/bundle.js');
+ $fields->addFieldToTab('Root.History', HistoryViewerField::create('MyObjectHistory'));
+
+ return $fields;
+}
+```
+
+### Previewable DataObjects
+
+History viewer will automatically detect and render a side-by-side preview panel for DataObjects that implement
+[CMSPreviewable](api:SilverStripe\ORM\CMSPreviewable). Please note that if you are adding this functionality, you
+will also need to expose the `AbsoluteLink` field in your GraphQL read scaffolding, and add it to the fields in
+`readOneMyVersionedObjectQuery`.
## API Documentation
* [Versioned](api:SilverStripe\Versioned\Versioned)
+* [HistoryViewerField](api:SilverStripe\VersionedAdmin\Forms\HistoryViewerField)
diff --git a/docs/en/02_Developer_Guides/05_Extending/01_Extensions.md b/docs/en/02_Developer_Guides/05_Extending/01_Extensions.md
index cc00ecf0b..70e9a5063 100644
--- a/docs/en/02_Developer_Guides/05_Extending/01_Extensions.md
+++ b/docs/en/02_Developer_Guides/05_Extending/01_Extensions.md
@@ -56,7 +56,7 @@ Alternatively, we can add extensions through PHP code (in the `_config.php` file
```php
-SilverStripe\Security\Member::add_extension('MyMemberExtension');
+SilverStripe\Security\Member::add_extension(MyMemberExtension::class);
```
This class now defines a `MyMemberExtension` that applies to all `Member` instances on the website. It will have
@@ -256,7 +256,7 @@ $member = Security::getCurrentUser();
print_r($member->getExtensionInstances());
-if($member->hasExtension('MyCustomMemberExtension')) {
+if ($member->hasExtension(MyCustomMemberExtension::class)) {
// ..
}
```
@@ -282,7 +282,7 @@ if not specified in `self::$defaults`, but before extensions have been called:
public function __construct()
{
$this->beforeExtending('populateDefaults', function() {
- if(empty($this->MyField)) {
+ if (empty($this->MyField)) {
$this->MyField = 'Value we want as a default if not specified in $defaults, but set before extensions';
}
});
@@ -301,9 +301,9 @@ This method is preferred to disabling, enabling, and calling field extensions ma
```php
public function getCMSFields()
{
- $this->beforeUpdateCMSFields(function($fields) {
+ $this->beforeUpdateCMSFields(function ($fields) {
// Include field which must be present when updateCMSFields is called on extensions
- $fields->addFieldToTab("Root.Main", new TextField('Detail', 'Details', null, 255));
+ $fields->addFieldToTab('Root.Main', new TextField('Detail', 'Details', null, 255));
});
$fields = parent::getCMSFields();
@@ -312,9 +312,45 @@ public function getCMSFields()
}
```
-## Related Lessons
-* [DataExtensions and SiteConfig](https://www.silverstripe.org/learn/lessons/v4/data-extensions-and-siteconfig-1)
+## Extending extensions {#extendingextensions}
+Extension classes can be overloaded using the Injector, if you want to modify the way that an extension in one of
+your modules works:
+
+```yaml
+SilverStripe\Core\Injector\Injector:
+ Company\Vendor\SomeExtension:
+ class: App\Project\CustomisedSomeExtension
+```
+
+**app/src/CustomisedSomeExtension.php**
+
+```php
+namespace App\Project;
+
+use Company\Vendor\SomeExtension;
+
+class CustomisedSomeExtension extends SomeExtension
+{
+ public function someMethod()
+ {
+ $result = parent::someMethod();
+ // modify result;
+ return $result;
+ }
+}
+```
+
+
+Please note that modifications such as this should be done in YAML configuration only. It is not recommended
+to use `Config::modify()->set()` to adjust the implementation class name of an extension after the configuration
+manifest has been loaded, and may not work consistently due to the "extra methods" cache having already been
+populated.
+
+
+## Related Lessons
+
+* [DataExtensions and SiteConfig](https://www.silverstripe.org/learn/lessons/v4/data-extensions-and-siteconfig-1)
## Related Documentaion
diff --git a/docs/en/02_Developer_Guides/09_Security/04_Secure_Coding.md b/docs/en/02_Developer_Guides/09_Security/04_Secure_Coding.md
index 76a03518a..791318d8a 100644
--- a/docs/en/02_Developer_Guides/09_Security/04_Secure_Coding.md
+++ b/docs/en/02_Developer_Guides/09_Security/04_Secure_Coding.md
@@ -549,23 +549,50 @@ salt values generated with the strongest entropy generators available on the pla
(see [RandomGenerator](api:SilverStripe\Security\RandomGenerator)). This prevents brute force attacks with
[Rainbow tables](http://en.wikipedia.org/wiki/Rainbow_table).
-Strong passwords are a crucial part of any system security.
-So in addition to storing the password in a secure fashion,
-you can also enforce specific password policies by configuring
-a [PasswordValidator](api:SilverStripe\Security\PasswordValidator):
+Strong passwords are a crucial part of any system security. So in addition to storing the password in a secure fashion,
+you can also enforce specific password policies by configuring a
+[PasswordValidator](api:SilverStripe\Security\PasswordValidator). This can be done through a `_config.php` file
+at runtime, or via YAML configuration.
+From SilverStripe 4.3 onwards, the default password validation rules are configured in the framework's `passwords.yml`
+file. You will need to ensure that your config file is processed after it. For SilverStripe <4.3 you will need to
+use a `_config.php` file to modify the class's config at runtime (see `_config.php` installed in your mysite/app folder
+if you're using silverstripe/recipe-core).
-```php
-use SilverStripe\Security\Member;
-use SilverStripe\Security\PasswordValidator;
+```yaml
+---
+Name: mypasswords
+After: '#corepasswords'
+---
+SilverStripe\Core\Injector\Injector:
+ SilverStripe\Security\PasswordValidator:
+ properties:
+ MinLength: 7
+ HistoricCount: 6
+ MinTestScore: 3
-$validator = new PasswordValidator();
-$validator->minLength(7);
-$validator->checkHistoricalPasswords(6);
-$validator->characterStrength(3, ["lowercase", "uppercase", "digits", "punctuation"]);
-Member::set_password_validator($validator);
+# In the case someone uses `new PasswordValidator` instead of Injector, provide some safe defaults through config.
+SilverStripe\Security\PasswordValidator:
+ min_length: 7
+ historic_count: 6
+ min_test_score: 3
```
+### Configuring custom password validator tests
+
+The default password validation character strength tests can be seen in the `PasswordValidator.character_strength_tests`
+configuration property. You can add your own with YAML config, by providing a name for it and a regex pattern to match:
+
+```yaml
+SilverStripe\Security\PasswordValidator:
+ character_strength_tests:
+ contains_secret_word: '/1337pw/'
+```
+
+This will ensure that a password contains `1337pw` somewhere in the string before validation will succeed.
+
+### Other options
+
In addition, you can tighten password security with the following configuration settings:
* `Member.password_expiry_days`: Set the number of days that a password should be valid for.
diff --git a/docs/en/04_Changelogs/4.3.0.md b/docs/en/04_Changelogs/4.3.0.md
index 3965ad378..ab31d003e 100644
--- a/docs/en/04_Changelogs/4.3.0.md
+++ b/docs/en/04_Changelogs/4.3.0.md
@@ -26,7 +26,7 @@ To enable the legacy search API on a `GridFieldFilterHeader`, you can either:
* set the `useLegacyFilterHeader` property to `true`,
* or pass `true` to the first argument of its constructor.
-To force the legacy search API on all instances of `GridFieldFilterHeader`, you can set it in your [configuration file](../../configuration):
+To force the legacy search API on all instances of `GridFieldFilterHeader`, you can set it in your [configuration file](../developer_guides/configuration):
```yml
SilverStripe\Forms\GridField\GridFieldFilterHeader:
force_legacy: true
@@ -67,4 +67,18 @@ SilverStripe\Core\Injector\Injector:
App\MySite\MyCustomControllerFactory
```
-[Implementing a _Factory_ with the Injector](/developer_guides/extending/injector/#factories)
+[Implementing a _Factory_ with the Injector](/developer_guides/extending/injector/#factories).
+
+### Using the history viewer for custom DataObjects
+
+For information on how to implement the history viewer UI in your own versioned DataObjects, please refer to
+[the Versioning documentation](../developer_guides/model/versioning).
+
+### Tests with dynamic extension customisations
+
+In SilverStripe 4.2, some unit tests that modify an extension class with PHP configuration manifest customisations
+may have passed and may now fail in SilverStripe 4.3. This behaviour is inconsistent, is not a recommended approach
+to customising extensions and should be avoided in all SilverStripe 4.x releases.
+
+For information on how to customise extensions, see
+["Extending Extensions"](../developer_guides/extending/extensions#extendingextensions).
diff --git a/src/Core/Extensible.php b/src/Core/Extensible.php
index b29dda32e..79fe536b6 100644
--- a/src/Core/Extensible.php
+++ b/src/Core/Extensible.php
@@ -215,15 +215,6 @@ trait Extensible
return true;
}
- /**
- * Clears all cached extra_methods cache data
- */
- public static function flush_extra_methods_cache()
- {
- self::$extra_methods = [];
- }
-
-
/**
* Remove an extension from a class.
* Note: This will not remove extensions from parent classes, and must be called
diff --git a/src/Core/Startup/ConfirmationTokenChain.php b/src/Core/Startup/ConfirmationTokenChain.php
index a47f2c4c0..e042bb6cb 100644
--- a/src/Core/Startup/ConfirmationTokenChain.php
+++ b/src/Core/Startup/ConfirmationTokenChain.php
@@ -139,9 +139,10 @@ class ConfirmationTokenChain
*/
public function getRedirectUrlParams()
{
- $params = [];
+ $params = $_GET;
+ unset($params['url']); // CLIRequestBuilder may add this
foreach ($this->filteredTokens() as $token) {
- $params = array_merge($params, $token->getRedirectUrlParams());
+ $params = array_merge($params, $token->params());
}
return $params;
diff --git a/src/Dev/State/ExtensionTestState.php b/src/Dev/State/ExtensionTestState.php
index 2cfedddbd..29c4dd09c 100644
--- a/src/Dev/State/ExtensionTestState.php
+++ b/src/Dev/State/ExtensionTestState.php
@@ -13,6 +13,16 @@ use SilverStripe\ORM\DataObject;
*/
class ExtensionTestState implements TestState
{
+ /**
+ * @var array
+ */
+ protected $extensionsToReapply = [];
+
+ /**
+ * @var array
+ */
+ protected $extensionsToRemove = [];
+
/**
* Called on setup
*
@@ -20,7 +30,6 @@ class ExtensionTestState implements TestState
*/
public function setUp(SapphireTest $test)
{
- DataObject::flush_extra_methods_cache();
}
public function tearDown(SapphireTest $test)
@@ -31,6 +40,8 @@ class ExtensionTestState implements TestState
{
// May be altered by another class
$isAltered = false;
+ $this->extensionsToReapply = [];
+ $this->extensionsToRemove = [];
/** @var string|SapphireTest $class */
/** @var string|DataObject $dataClass */
@@ -46,6 +57,10 @@ class ExtensionTestState implements TestState
if (!class_exists($extension) || !$dataClass::has_extension($extension)) {
continue;
}
+ if (!isset($this->extensionsToReapply[$dataClass])) {
+ $this->extensionsToReapply[$dataClass] = [];
+ }
+ $this->extensionsToReapply[$dataClass][] = $extension;
$dataClass::remove_extension($extension);
$isAltered = true;
}
@@ -62,6 +77,10 @@ class ExtensionTestState implements TestState
throw new LogicException("Test {$class} requires extension {$extension} which doesn't exist");
}
if (!$dataClass::has_extension($extension)) {
+ if (!isset($this->extensionsToRemove[$dataClass])) {
+ $this->extensionsToRemove[$dataClass] = [];
+ }
+ $this->extensionsToRemove[$dataClass][] = $extension;
$dataClass::add_extension($extension);
$isAltered = true;
}
@@ -85,6 +104,23 @@ class ExtensionTestState implements TestState
public function tearDownOnce($class)
{
- DataObject::flush_extra_methods_cache();
+ // @todo: This isn't strictly necessary to restore extensions, but only to ensure that
+ // Object::$extra_methods is properly flushed. This should be replaced with a simple
+ // flush mechanism for each $class.
+ /** @var string|DataObject $dataClass */
+
+ // Remove extensions added for testing
+ foreach ($this->extensionsToRemove as $dataClass => $extensions) {
+ foreach ($extensions as $extension) {
+ $dataClass::remove_extension($extension);
+ }
+ }
+
+ // Reapply ones removed
+ foreach ($this->extensionsToReapply as $dataClass => $extensions) {
+ foreach ($extensions as $extension) {
+ $dataClass::add_extension($extension);
+ }
+ }
}
}
diff --git a/src/Forms/TreeDropdownField.php b/src/Forms/TreeDropdownField.php
index ca4e11ea9..b5a1cfe84 100644
--- a/src/Forms/TreeDropdownField.php
+++ b/src/Forms/TreeDropdownField.php
@@ -499,6 +499,17 @@ class TreeDropdownField extends FormField
// Begin marking
$markingSet->markPartialTree();
+ // Explicitely mark our search results if necessary
+ foreach ($this->searchIds as $id => $marked) {
+ if ($marked) {
+ $object = $this->objectForKey($id);
+ if (!$object) {
+ continue;
+ }
+ $markingSet->markToExpose($object);
+ }
+ }
+
// Allow to pass values to be selected within the ajax request
$value = $request->requestVar('forceValue') ?: $this->value;
if ($value && ($values = preg_split('/,\s*/', $value))) {
diff --git a/tests/php/Core/Injector/InjectorTest.php b/tests/php/Core/Injector/InjectorTest.php
index b1dee54db..1e54cc1de 100644
--- a/tests/php/Core/Injector/InjectorTest.php
+++ b/tests/php/Core/Injector/InjectorTest.php
@@ -21,13 +21,12 @@ use SilverStripe\Core\Tests\Injector\InjectorTest\NeedsBothCirculars;
use SilverStripe\Core\Tests\Injector\InjectorTest\NewRequirementsBackend;
use SilverStripe\Core\Tests\Injector\InjectorTest\OriginalRequirementsBackend;
use SilverStripe\Core\Tests\Injector\InjectorTest\OtherTestObject;
-use SilverStripe\Core\Tests\Injector\InjectorTest\SomeCustomisedExtension;
-use SilverStripe\Core\Tests\Injector\InjectorTest\SomeExtension;
use SilverStripe\Core\Tests\Injector\InjectorTest\TestObject;
use SilverStripe\Core\Tests\Injector\InjectorTest\TestSetterInjections;
use SilverStripe\Core\Tests\Injector\InjectorTest\TestStaticInjections;
use SilverStripe\Dev\SapphireTest;
-use SilverStripe\Security\Member;
+use SilverStripe\Dev\TestOnly;
+use stdClass;
define('TEST_SERVICES', __DIR__ . '/AopProxyServiceTest');
@@ -1048,24 +1047,4 @@ class InjectorTest extends SapphireTest
Injector::unnest();
$this->nestingLevel--;
}
-
- /**
- * Tests that overloaded extensions work, see {@link Extensible::getExtensionInstance()}
- */
- public function testExtendedExtensions()
- {
- Config::modify()
- ->set(Injector::class, SomeExtension::class, [
- 'class' => SomeCustomisedExtension::class,
- ])
- ->merge(Member::class, 'extensions', [
- SomeExtension::class,
- ]);
-
- /** @var Member|SomeExtension $member */
- $member = new Member();
- $this->assertTrue($member->hasExtension(SomeExtension::class));
- $this->assertTrue($member->hasMethod('someMethod'));
- $this->assertSame('bar', $member->someMethod());
- }
}
diff --git a/tests/php/Core/Injector/InjectorTest/SomeCustomisedExtension.php b/tests/php/Core/Injector/InjectorTest/SomeCustomisedExtension.php
deleted file mode 100644
index 9641d7df1..000000000
--- a/tests/php/Core/Injector/InjectorTest/SomeCustomisedExtension.php
+++ /dev/null
@@ -1,13 +0,0 @@
-getTokenRequiringReload(true, ['getRedirectUrlParams']);
+ $mockToken = $this->getTokenRequiringReload(true, ['params']);
$mockToken->expects($this->once())
- ->method('getRedirectUrlParams')
+ ->method('params')
->will($this->returnValue(['mockTokenParam' => '1']));
- $secondMockToken = $this->getTokenRequiringReload(true, ['getRedirectUrlParams']);
+ $secondMockToken = $this->getTokenRequiringReload(true, ['params']);
$secondMockToken->expects($this->once())
- ->method('getRedirectUrlParams')
+ ->method('params')
->will($this->returnValue(['secondMockTokenParam' => '2']));
$chain = new ConfirmationTokenChain();
$chain->pushToken($mockToken);
$chain->pushToken($secondMockToken);
- $this->assertEquals(['mockTokenParam' => '1', 'secondMockTokenParam' => '2'], $chain->getRedirectUrlParams());
+ $params = $chain->getRedirectUrlParams();
+ $this->assertEquals('1', $params['mockTokenParam']);
+ $this->assertEquals('2', $params['secondMockTokenParam']);
}
}
diff --git a/tests/php/Core/Startup/ErrorControlChainMiddlewareTest.php b/tests/php/Core/Startup/ErrorControlChainMiddlewareTest.php
index a90799026..2917c536a 100644
--- a/tests/php/Core/Startup/ErrorControlChainMiddlewareTest.php
+++ b/tests/php/Core/Startup/ErrorControlChainMiddlewareTest.php
@@ -48,7 +48,7 @@ class ErrorControlChainMiddlewareTest extends SapphireTest
$this->assertInstanceOf(HTTPResponse::class, $result);
$location = $result->getHeader('Location');
- $this->assertContains('?flush=1&flushtoken=', $location);
+ $this->assertContains('flush=1&flushtoken=', $location);
$this->assertNotContains('Security/login', $location);
}
@@ -95,7 +95,7 @@ class ErrorControlChainMiddlewareTest extends SapphireTest
$this->assertInstanceOf(HTTPResponse::class, $result);
$location = $result->getHeader('Location');
$this->assertContains('/dev/build', $location);
- $this->assertContains('?devbuildtoken=', $location);
+ $this->assertContains('devbuildtoken=', $location);
$this->assertNotContains('Security/login', $location);
}
diff --git a/tests/php/Forms/ConfirmedPasswordFieldTest.php b/tests/php/Forms/ConfirmedPasswordFieldTest.php
index d5b7218d5..3ed909c64 100644
--- a/tests/php/Forms/ConfirmedPasswordFieldTest.php
+++ b/tests/php/Forms/ConfirmedPasswordFieldTest.php
@@ -10,9 +10,19 @@ use SilverStripe\Forms\Form;
use SilverStripe\Forms\ReadonlyField;
use SilverStripe\Forms\RequiredFields;
use SilverStripe\Security\Member;
+use SilverStripe\Security\PasswordValidator;
class ConfirmedPasswordFieldTest extends SapphireTest
{
+ protected $usesDatabase = true;
+
+ protected function setUp()
+ {
+ parent::setUp();
+
+ PasswordValidator::singleton()->setMinLength(0);
+ }
+
public function testSetValue()
{
$field = new ConfirmedPasswordField('Test', 'Testing', 'valueA');
diff --git a/tests/php/Forms/TreeDropdownFieldTest.php b/tests/php/Forms/TreeDropdownFieldTest.php
index 5b5e22fd1..9af392f4b 100644
--- a/tests/php/Forms/TreeDropdownFieldTest.php
+++ b/tests/php/Forms/TreeDropdownFieldTest.php
@@ -9,12 +9,17 @@ use SilverStripe\Dev\CSSContentParser;
use SilverStripe\Dev\SapphireTest;
use SilverStripe\Control\HTTPRequest;
use SilverStripe\Forms\TreeDropdownField;
+use SilverStripe\ORM\Tests\HierarchyTest\TestObject;
class TreeDropdownFieldTest extends SapphireTest
{
protected static $fixture_file = 'TreeDropdownFieldTest.yml';
+ protected static $extra_dataobjects = [
+ TestObject::class
+ ];
+
public function testSchemaStateDefaults()
{
$field = new TreeDropdownField('TestTree', 'Test tree', Folder::class);
@@ -97,6 +102,38 @@ class TreeDropdownFieldTest extends SapphireTest
);
}
+ public function testTreeSearchJsonFlatlistWithLowNodeThreshold()
+ {
+ // Initialise our TreeDropDownField
+ $field = new TreeDropdownField('TestTree', 'Test tree', TestObject::class);
+ $field->config()->set('node_threshold_total', 2);
+
+ // Search for all Test object matching our criteria
+ $request = new HTTPRequest(
+ 'GET',
+ 'url',
+ ['search' => 'MatchSearchCriteria', 'format' => 'json', 'flatList' => '1']
+ );
+ $request->setSession(new Session([]));
+ $response = $field->tree($request);
+ $tree = json_decode($response->getBody(), true);
+ $actualNodeIDs = array_column($tree['children'], 'id');
+
+
+ // Get the list of expected node IDs from the YML Fixture
+ $expectedNodeIDs = array_map(
+ function ($key) {
+ return $this->objFromFixture(TestObject::class, $key)->ID;
+ },
+ ['zero', 'oneA', 'twoAi', 'three'] // Those are the identifiers of the object we expect our search to find
+ );
+
+ sort($actualNodeIDs);
+ sort($expectedNodeIDs);
+
+ $this->assertEquals($expectedNodeIDs, $actualNodeIDs);
+ }
+
public function testTreeSearch()
{
$field = new TreeDropdownField('TestTree', 'Test tree', Folder::class);
diff --git a/tests/php/Forms/TreeDropdownFieldTest.yml b/tests/php/Forms/TreeDropdownFieldTest.yml
index 7546885b3..2207cc300 100644
--- a/tests/php/Forms/TreeDropdownFieldTest.yml
+++ b/tests/php/Forms/TreeDropdownFieldTest.yml
@@ -8,6 +8,7 @@ SilverStripe\Assets\Folder:
folder1-subfolder1:
Name: FileTest-folder1-subfolder1
ParentID: =>SilverStripe\Assets\Folder.folder1
+
SilverStripe\Assets\File:
asdf:
Filename: assets/FileTest.txt
@@ -24,3 +25,40 @@ SilverStripe\Assets\File:
Filename: assets/FileTest-folder1/File1.txt
Name: File1.txt
ParentID: =>SilverStripe\Assets\Folder.folder1
+
+SilverStripe\ORM\Tests\HierarchyTest\TestObject:
+ zero:
+ Title: Zero MatchSearchCriteria
+ zeroA:
+ Title: Child A of Zero
+ ParentID: =>SilverStripe\ORM\Tests\HierarchyTest\TestObject.zero
+ zeroB:
+ Title: Child B of Zero
+ ParentID: =>SilverStripe\ORM\Tests\HierarchyTest\TestObject.zero
+ zeroC:
+ Title: Child C of Zero
+ ParentID: =>SilverStripe\ORM\Tests\HierarchyTest\TestObject.zero
+ one:
+ Title: One
+ oneA:
+ Title: Child A of One MatchSearchCriteria
+ ParentID: =>SilverStripe\ORM\Tests\HierarchyTest\TestObject.one
+ oneB:
+ Title: Child B of One
+ ParentID: =>SilverStripe\ORM\Tests\HierarchyTest\TestObject.one
+ oneC:
+ Title: Child C of One
+ ParentID: =>SilverStripe\ORM\Tests\HierarchyTest\TestObject.one
+ oneD:
+ Title: Child C of One
+ ParentID: =>SilverStripe\ORM\Tests\HierarchyTest\TestObject.one
+ two:
+ Title: Two
+ twoA:
+ Title: Child A of Two
+ ParentID: =>SilverStripe\ORM\Tests\HierarchyTest\TestObject.two
+ twoAi:
+ Title: Grandchild i of Child A of Two MatchSearchCriteria
+ ParentID: =>SilverStripe\ORM\Tests\HierarchyTest\TestObject.twoA
+ three:
+ Title: Three MatchSearchCriteria
diff --git a/tests/php/Security/MemberTest.php b/tests/php/Security/MemberTest.php
index 4b5341622..e5b7fd303 100644
--- a/tests/php/Security/MemberTest.php
+++ b/tests/php/Security/MemberTest.php
@@ -22,6 +22,7 @@ use SilverStripe\Security\MemberAuthenticator\MemberAuthenticator;
use SilverStripe\Security\MemberAuthenticator\SessionAuthenticationHandler;
use SilverStripe\Security\MemberPassword;
use SilverStripe\Security\PasswordEncryptor_Blowfish;
+use SilverStripe\Security\PasswordValidator;
use SilverStripe\Security\Permission;
use SilverStripe\Security\RememberLoginHash;
use SilverStripe\Security\Security;
@@ -55,7 +56,8 @@ class MemberTest extends FunctionalTest
parent::setUp();
Member::config()->set('unique_identifier_field', 'Email');
- Member::set_password_validator(null);
+
+ PasswordValidator::singleton()->setMinLength(0);
i18n::set_locale('en_US');
}
diff --git a/tests/php/Security/PasswordValidatorTest.php b/tests/php/Security/PasswordValidatorTest.php
index 4fd99ed08..7fa5637ab 100644
--- a/tests/php/Security/PasswordValidatorTest.php
+++ b/tests/php/Security/PasswordValidatorTest.php
@@ -2,9 +2,9 @@
namespace SilverStripe\Security\Tests;
-use SilverStripe\Security\PasswordValidator;
-use SilverStripe\Security\Member;
use SilverStripe\Dev\SapphireTest;
+use SilverStripe\Security\Member;
+use SilverStripe\Security\PasswordValidator;
class PasswordValidatorTest extends SapphireTest
{
@@ -14,6 +14,16 @@ class PasswordValidatorTest extends SapphireTest
*/
protected $usesDatabase = true;
+ protected function setUp()
+ {
+ parent::setUp();
+
+ // Unset framework default values
+ PasswordValidator::config()
+ ->remove('min_length')
+ ->remove('historic_count');
+ }
+
public function testValidate()
{
$v = new PasswordValidator();