From 26d5b11b3a0473e3fe7d19e64cd4c42457a670a0 Mon Sep 17 00:00:00 2001 From: Guy Sartorelli <36352093+GuySartorelli@users.noreply.github.com> Date: Tue, 26 Nov 2024 16:40:29 +1300 Subject: [PATCH] API Move logic from silverstripe/cms into central place (#11460) This logic is used by CMSMain but needs to be callable on any hierarchical model. In some cases this logic is generic enough it could be on any model or any DataObject --- src/Forms/GridField/GridFieldDataColumns.php | 59 ++- .../GridFieldDetailForm_ItemRequest.php | 12 +- src/Model/ModelData.php | 61 +++ src/ORM/DataObject.php | 50 +-- src/ORM/FieldType/DBEnum.php | 11 - src/ORM/Hierarchy/Hierarchy.php | 289 ++++++++++++- .../GridField/GridFieldDataColumnsTest.php | 79 ++++ .../TestStatusFlagsObject.php | 30 ++ .../TestBreadcrumbsController.php | 16 + .../TestStatusFlagsObject.php | 30 ++ .../GridFieldDetailForm_ItemRequestTest.php | 29 ++ .../Forms/GridField/GridFieldReadonlyTest.php | 2 - tests/php/Forms/GridField/GridFieldTest.php | 12 - tests/php/Model/ModelDataTest.php | 31 ++ .../ViewableDataTextExtension.php | 9 + tests/php/ORM/HierarchyTest.php | 392 +++++++++++++++++- tests/php/ORM/HierarchyTest.yml | 3 + .../php/ORM/HierarchyTest/HierarchyModel.php | 23 + .../ORM/HierarchyTest/NoEditTestObject.php | 13 + .../HierarchyTest/SortableHierarchyModel.php | 28 ++ .../HierarchyTest/TestAllowedChildrenA.php | 14 + .../HierarchyTest/TestAllowedChildrenB.php | 15 + .../HierarchyTest/TestAllowedChildrenC.php | 15 + .../HierarchyTest/TestAllowedChildrenCext.php | 15 + .../HierarchyTest/TestAllowedChildrenD.php | 15 + .../HierarchyTest/TestAllowedChildrenE.php | 13 + .../TestAllowedChildrenHidden.php | 11 + 27 files changed, 1201 insertions(+), 76 deletions(-) create mode 100644 tests/php/Forms/GridField/GridFieldDataColumnsTest/TestStatusFlagsObject.php create mode 100644 tests/php/Forms/GridField/GridFieldDetailFormItemRequestTest/TestBreadcrumbsController.php create mode 100644 tests/php/Forms/GridField/GridFieldDetailFormItemRequestTest/TestStatusFlagsObject.php create mode 100644 tests/php/ORM/HierarchyTest/HierarchyModel.php create mode 100644 tests/php/ORM/HierarchyTest/NoEditTestObject.php create mode 100644 tests/php/ORM/HierarchyTest/SortableHierarchyModel.php create mode 100644 tests/php/ORM/HierarchyTest/TestAllowedChildrenA.php create mode 100644 tests/php/ORM/HierarchyTest/TestAllowedChildrenB.php create mode 100644 tests/php/ORM/HierarchyTest/TestAllowedChildrenC.php create mode 100644 tests/php/ORM/HierarchyTest/TestAllowedChildrenCext.php create mode 100644 tests/php/ORM/HierarchyTest/TestAllowedChildrenD.php create mode 100644 tests/php/ORM/HierarchyTest/TestAllowedChildrenE.php create mode 100644 tests/php/ORM/HierarchyTest/TestAllowedChildrenHidden.php diff --git a/src/Forms/GridField/GridFieldDataColumns.php b/src/Forms/GridField/GridFieldDataColumns.php index 09e5e8680e8..b7f08a7d817 100644 --- a/src/Forms/GridField/GridFieldDataColumns.php +++ b/src/Forms/GridField/GridFieldDataColumns.php @@ -6,14 +6,12 @@ use InvalidArgumentException; use LogicException; use SilverStripe\Model\ModelData; -use SilverStripe\Dev\Deprecation; /** * @see GridField */ class GridFieldDataColumns extends AbstractGridFieldComponent implements GridField_ColumnProvider { - /** * @var array */ @@ -31,6 +29,15 @@ class GridFieldDataColumns extends AbstractGridFieldComponent implements GridFie */ protected $displayFields = []; + private bool $displayStatusFlags = true; + + private array $columnsForStatusFlag = [ + 'Title', + 'Name', + ]; + + private ?string $statusFlagColumn = null; + /** * Modify the list of columns displayed in the table. * See {@link GridFieldDataColumns->getDisplayFields()} and {@link GridFieldDataColumns}. @@ -44,6 +51,10 @@ public function augmentColumns($gridField, &$columns) foreach ($baseColumns as $col) { $columns[] = $col; + // Find the column to add status flags to + if ($this->statusFlagColumn === null && in_array($col, $this->getColumnsForStatusFlag())) { + $this->statusFlagColumn = $col; + } } $columns = array_unique($columns ?? []); @@ -60,6 +71,45 @@ public function getColumnsHandled($gridField) return array_keys($this->getDisplayFields($gridField) ?? []); } + /** + * Set whether status flags are displayed in this gridfield + */ + public function setDisplayStatusFlags(bool $display): static + { + $this->displayStatusFlags = $display; + return $this; + } + + /** + * Get whether status flags are displayed in this gridfield + */ + public function getDisplayStatusFlags(): bool + { + return $this->displayStatusFlags; + } + + /** + * Set which columns can be used to display the status flags. + * The first column from this list found in the gridfield will be used. + */ + public function setColumnsForStatusFlag(array $columns): static + { + if (empty($columns)) { + throw new InvalidArgumentException('Columns array must not be empty'); + } + $this->columnsForStatusFlag = $columns; + return $this; + } + + /** + * Get which columns can be used to display the status flags. + * The first column from this list found in the gridfield will be used. + */ + public function getColumnsForStatusFlag(): array + { + return $this->columnsForStatusFlag; + } + /** * Override the default behaviour of showing the models summaryFields with * these fields instead @@ -183,6 +233,11 @@ public function getColumnContent($gridField, $record, $columnName) // Do any final escaping $value = $this->escapeValue($gridField, $value); + // Add on status flags + if ($this->getDisplayStatusFlags() && $columnName === $this->statusFlagColumn) { + $value .= $record->getStatusFlagMarkup('ss-gridfield-badge'); + } + return $value; } diff --git a/src/Forms/GridField/GridFieldDetailForm_ItemRequest.php b/src/Forms/GridField/GridFieldDetailForm_ItemRequest.php index d1dfa43d35d..61b1d323605 100644 --- a/src/Forms/GridField/GridFieldDetailForm_ItemRequest.php +++ b/src/Forms/GridField/GridFieldDetailForm_ItemRequest.php @@ -32,6 +32,7 @@ use SilverStripe\View\HTML; use SilverStripe\View\SSViewer; use SilverStripe\Model\ModelData; +use SilverStripe\ORM\FieldType\DBField; class GridFieldDetailForm_ItemRequest extends RequestHandler { @@ -930,11 +931,13 @@ public function Breadcrumbs($unlinked = false) $items = $this->popupController->Breadcrumbs($unlinked); if (!$items) { + /** @var ArrayList $items */ $items = ArrayList::create(); } - if ($this->record && $this->record->ID) { - $title = ($this->record->Title) ? $this->record->Title : "#{$this->record->ID}"; + $record = $this->getRecord(); + if ($record && $record->ID) { + $title = ($record->Title) ? $record->Title : "#{$record->ID}"; $items->push(ArrayData::create([ 'Title' => $title, 'Link' => $this->Link() @@ -952,6 +955,11 @@ public function Breadcrumbs($unlinked = false) } } + $statusFlags = $record->getStatusFlagMarkup('badge--breadcrumbs'); + if ($statusFlags) { + $items->last()->setField('Extra', DBField::create_field('HTMLFragment', $statusFlags)); + } + $this->extend('updateBreadcrumbs', $items); return $items; } diff --git a/src/Model/ModelData.php b/src/Model/ModelData.php index 454d00f8792..f99e4b9d78b 100644 --- a/src/Model/ModelData.php +++ b/src/Model/ModelData.php @@ -17,6 +17,7 @@ use SilverStripe\ORM\FieldType\DBHTMLText; use SilverStripe\Model\ArrayData; use SilverStripe\View\CastingService; +use SilverStripe\View\HTML; use SilverStripe\View\SSViewer; use UnexpectedValueException; @@ -47,6 +48,7 @@ class ModelData private static array $casting = [ 'CSSClasses' => 'Varchar', 'forTemplate' => 'HTMLText', + 'StatusFlagMarkup' => 'HTMLFragment', ]; /** @@ -74,6 +76,8 @@ class ModelData private array $objCache = []; + private $_cache_statusFlags = null; + public function __construct() { // no-op @@ -487,6 +491,52 @@ public function hasValue(string $field, array $arguments = [], bool $cache = tru // UTILITY METHODS ------------------------------------------------------------------------------------------------- + /** + * Flags provides the user with additional data about the current page status. + * + * Mostly this is used for versioning, but can be used for other purposes (e.g. localisation). + * Each page can have more than one status flag. + * + * Returns an associative array of a unique key to a (localized) title for the flag. + * The unique key can be reused as a CSS class. + * + * Example (simple): + * "deletedonlive" => "Deleted" + * + * Example (with optional title attribute): + * "deletedonlive" => ['text' => "Deleted", 'title' => 'This page has been deleted'] + */ + public function getStatusFlags(bool $cached = true): array + { + if (!$this->_cache_statusFlags || !$cached) { + $flags = []; + $this->extend('updateStatusFlags', $flags); + $this->_cache_statusFlags = $flags; + } + return $this->_cache_statusFlags; + } + + /** + * Get the HTML markup for rendering status flags for this model. + */ + public function getStatusFlagMarkup(string $additionalCssClass = ''): string + { + $flagContent = ''; + foreach ($this->getStatusFlags() as $class => $data) { + $flagAttributes = [ + 'class' => rtrim("badge status-{$class} $additionalCssClass"), + ]; + if (is_string($data)) { + $data = ['text' => $data]; + } + if (isset($data['title'])) { + $flagAttributes['title'] = $data['title']; + } + $flagContent .= HTML::createTag('span', $flagAttributes, Convert::raw2xml($data['text'])); + } + return $flagContent; + } + /** * Find appropriate templates for SSViewer to use to render this object */ @@ -545,6 +595,17 @@ public function Debug(): ModelData|string return ModelDataDebugger::create($this); } + /** + * Clears record-specific cached data. + */ + public function flushCache(): static + { + $this->objCacheClear(); + $this->_cache_statusFlags = null; + $this->extend('onFlushCache'); + return $this; + } + /** * Generate the cache name for a field */ diff --git a/src/ORM/DataObject.php b/src/ORM/DataObject.php index 8a004ffec4e..ddc46561c9f 100644 --- a/src/ORM/DataObject.php +++ b/src/ORM/DataObject.php @@ -116,17 +116,13 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro { /** * Human-readable singular name. - * @var string - * @config */ - private static $singular_name = null; + private static ?string $singular_name = null; /** * Human-readable plural name - * @var string - * @config */ - private static $plural_name = null; + private static ?string $plural_name = null; /** * Description of the class. @@ -150,7 +146,6 @@ class DataObject extends ModelData implements DataObjectInterface, i18nEntityPro * @var string */ private static $default_classname = null; - /** * Whether this DataObject class must only use the primary database and not a read-only replica * Note that this will be only be enforced when using DataQuery::execute() or @@ -957,40 +952,23 @@ public function i18n_plural_name() /** * Get description for this class - * @return null|string */ - public function classDescription() + public function classDescription(): ?string { return static::config()->get('class_description', Config::UNINHERITED); } /** * Get localised description for this class - * @return null|string */ - public function i18n_classDescription() + public function i18n_classDescription(): ?string { $notDefined = 'NOT_DEFINED'; - $baseDescription = $this->classDescription() ?? $notDefined; - - // Check the new i18n key first - $description = _t(static::class . '.CLASS_DESCRIPTION', $baseDescription); - if ($description !== $baseDescription) { - return $description; - } - - // Fall back on the deprecated localisation key - $legacyI18n = _t(static::class . '.DESCRIPTION', $baseDescription); - if ($legacyI18n !== $baseDescription) { - return $legacyI18n; - } - - // If there was no description available in config nor in i18n, return null - if ($baseDescription === $notDefined) { + $description = _t(static::class.'.CLASS_DESCRIPTION', $this->classDescription() ?? $notDefined); + if ($description === $notDefined) { return null; } - // Return raw description - return $baseDescription; + return $description; } /** @@ -3562,14 +3540,14 @@ public static function get_one($callerClass = null, $filter = "", $cache = true, } /** - * Flush the cached results for all relations (has_one, has_many, many_many) - * Also clears any cached aggregate data. + * @inheritDoc + * + * Also flush the cached results for all relations (has_one, has_many, many_many) * - * @param boolean $persistent When true will also clear persistent data stored in the Cache system. + * @param bool $persistent When true will also clear persistent data stored in the Cache system. * When false will just clear session-local cached data - * @return static $this */ - public function flushCache($persistent = true) + public function flushCache(bool $persistent = true): static { if (static::class == DataObject::class) { DataObject::$_cache_get_one = []; @@ -3583,11 +3561,9 @@ public function flushCache($persistent = true) } } - $this->extend('onFlushCache'); - $this->components = []; $this->eagerLoadedData = []; - return $this; + return parent::flushCache(); } /** diff --git a/src/ORM/FieldType/DBEnum.php b/src/ORM/FieldType/DBEnum.php index d9874a40d68..3e4b4d57f36 100644 --- a/src/ORM/FieldType/DBEnum.php +++ b/src/ORM/FieldType/DBEnum.php @@ -5,7 +5,6 @@ use SilverStripe\Core\Config\Config; use SilverStripe\Core\Validation\FieldValidation\OptionFieldValidator; use SilverStripe\Core\Resettable; -use SilverStripe\Dev\Deprecation; use SilverStripe\Forms\DropdownField; use SilverStripe\Forms\FormField; use SilverStripe\Forms\SelectField; @@ -43,16 +42,6 @@ class DBEnum extends DBString implements Resettable */ protected static array $enum_cache = []; - /** - * Clear all cached enum values. - * @deprecated 5.4.0 Use reset() instead. - */ - public static function flushCache(): void - { - Deprecation::notice('5.4.0', 'Use reset() instead.'); - static::reset(); - } - public static function reset(): void { DBEnum::$enum_cache = []; diff --git a/src/ORM/Hierarchy/Hierarchy.php b/src/ORM/Hierarchy/Hierarchy.php index 929476fc778..4ae4014668d 100644 --- a/src/ORM/Hierarchy/Hierarchy.php +++ b/src/ORM/Hierarchy/Hierarchy.php @@ -17,6 +17,9 @@ use SilverStripe\Core\Convert; use Exception; use SilverStripe\Model\ModelData; +use SilverStripe\ORM\HiddenClass; +use SilverStripe\Security\Member; +use SilverStripe\Security\Security; /** * DataObjects that use the Hierarchy extension can be be organised as a hierarchy, with children and parents. The most @@ -28,6 +31,47 @@ */ class Hierarchy extends Extension { + /** + * The name of the dedicated sort field, if there is one. + * Will be null if there's no field for sorting this model. + * Does not affect default_sort which needs to be configured separately. + */ + private static ?string $sort_field = null; + + /** + * The default child class for this model. + * Note that this is intended for use with CMSMain and may not be respected with other model management methods. + */ + private static ?string $default_child = null; + + /** + * The default parent class for this model. + * Note that this is intended for use with CMSMain and may not be respected with other model management methods. + */ + private static ?string $default_parent = null; + + /** + * Indicates what kind of children this model can have. + * This can be an array of allowed child classes, or the string "none" - + * indicating that this model can't have children. + * If a classname is prefixed by "*", such as "*App\Model\MyModel", then only that + * class is allowed - no subclasses. Otherwise, the class and all its + * subclasses are allowed. + * To control allowed children on root level (no parent), use {@link $can_be_root}. + * + * Leaving this array empty means this model can have children of any class that is a subclass + * of the first class in its class hierarchy to have the Hierarchy extension, including records of the same class. + * + * Note that this is intended for use with CMSMain and may not be respected with other model management methods. + */ + private static array $allowed_children = []; + + /** + * Controls whether a record can be in the root of the hierarchy. + * Note that this is intended for use with CMSMain and may not be respected with other model management methods. + */ + private static bool $can_be_root = true; + /** * The lower bounds for the amount of nodes to mark. If set, the logic will expand nodes until it reaches at least * this number, and then stops. Root nodes will always show regardless of this setting. Further nodes can be @@ -99,10 +143,14 @@ class Hierarchy extends Extension * A cache used by numChildren(). * Clear through {@link flushCache()}. * version (int)0 means not on this stage. - * - * @var array */ - protected static $cache_numChildren = []; + protected static array $cache_numChildren = []; + + /** + * Used as a cache for allowedChildren() + * Drastically reduces admin page load when there are a lot of subclass types + */ + protected static array $cache_allowedChildren = []; public static function get_extra_config($class, $extension, $args) { @@ -113,13 +161,52 @@ public static function get_extra_config($class, $extension, $args) /** * Validate the owner object - check for existence of infinite loops. - * - * @param ValidationResult $validationResult */ protected function updateValidate(ValidationResult $validationResult) { - // The object is new, won't be looping. $owner = $this->owner; + $this->validateNonCyclicalHierarchy($validationResult); + + // "Can be root" validation + if (!$owner::config()->get('can_be_root') && !$owner->ParentID) { + $validationResult->addError( + _t( + __CLASS__ . '.TypeOnRootNotAllowed', + 'Model type "{type}" is not allowed on the root level', + ['type' => $owner->i18n_singular_name()] + ), + ValidationResult::TYPE_ERROR, + 'CAN_BE_ROOT' + ); + } + + // Allowed children validation + $parent = $owner->getParent(); + if ($parent && $parent->exists()) { + // No need to check for subclasses or instanceof, as allowedChildren() already + // deconstructs any inheritance trees already. + $allowed = $parent->allowedChildren(); + $subject = $owner->hasMethod('getRecordForAllowedChildrenValidation') + ? $owner->getRecordForAllowedChildrenValidation() + : $owner; + if (!in_array($subject->ClassName, $allowed ?? [])) { + $validationResult->addError( + _t( + __CLASS__ . '.ChildTypeNotAllowed', + 'Model type "{type}" not allowed as child of this parent record', + ['type' => $subject->i18n_singular_name()] + ), + ValidationResult::TYPE_ERROR, + 'ALLOWED_CHILDREN' + ); + } + } + } + + private function validateNonCyclicalHierarchy(ValidationResult $validationResult): void + { + $owner = $this->owner; + // The object is new, won't be looping. if (!$owner->ID) { return; } @@ -127,7 +214,7 @@ protected function updateValidate(ValidationResult $validationResult) if (!$owner->ParentID) { return; } - // The parent has not changed, skip the check for performance reasons. + // The parent has not changed, skip the checks for performance reasons. if (!$owner->isChanged('ParentID')) { return; } @@ -153,7 +240,6 @@ protected function updateValidate(ValidationResult $validationResult) } } - /** * Get a list of this DataObject's and all it's descendants IDs. * @@ -186,6 +272,32 @@ protected function loadDescendantIDListInto(&$idList, $node = null) } } + /** + * Duplicates each child of this record recursively and returns the top-level duplicate record. + * If there is a sort field, new sort values are set for the duplicates to retain their sort order. + */ + public function duplicateWithChildren(): DataObject + { + $owner = $this->getOwner(); + $clone = $owner->duplicate(); + $children = $owner->AllChildren(); + $sortField = $owner->getSortField(); + + $sort = 1; + foreach ($children as $child) { + $childClone = $child->duplicateWithChildren(); + $childClone->ParentID = $clone->ID; + if ($sortField) { + //retain sort order by manually setting sort values + $childClone->$sortField = $sort; + $sort++; + } + $childClone->write(); + } + + return $clone; + } + /** * Get the children for this DataObject filtered by canView() * @@ -392,6 +504,104 @@ public static function prepopulate_numchildren_cache($baseClass, $idList = null) } } + /** + * Returns the class name of the default class for children of this page. + * Note that this is intended for use with CMSMain and may not be respected with other model management methods. + */ + public function defaultChild(): ?string + { + $owner = $this->getOwner(); + $default = $owner::config()->get('default_child'); + $allowed = $this->allowedChildren(); + if (empty($allowed)) { + return null; + } + if (!$default || !in_array($default, $allowed)) { + $default = reset($allowed); + } + return $default; + } + + /** + * Returns the class name of the default class for the parent of this page. + * Note that this is intended for use with CMSMain and may not be respected with other model management methods. + * Doesn't check the allowedChildren config for the parent class. + */ + public function defaultParent(): ?string + { + return $this->getOwner()::config()->get('default_parent'); + } + + /** + * Returns an array of the class names of classes that are allowed to be children of this class. + * Note that this is intended for use with CMSMain and may not be respected with other model management methods. + * + * @return string[] + */ + public function allowedChildren(): array + { + $owner = $this->getOwner(); + if (isset(static::$cache_allowedChildren[$owner->ClassName])) { + $allowedChildren = static::$cache_allowedChildren[$owner->ClassName]; + } else { + // Get config from the highest class in the hierarchy to define it. + // This avoids merged config, meaning each class that defines the allowed children defines it from scratch. + $baseClass = $this->getHierarchyBaseClass(); + $class = get_class($owner); + $candidates = null; + while ($class) { + if (Config::inst()->exists($class, 'allowed_children', Config::UNINHERITED)) { + $candidates = Config::inst()->get($class, 'allowed_children', Config::UNINHERITED); + break; + } + // Stop checking if we've hit the first class in the class hierarchy which has this extension + if ($class === $baseClass) { + break; + } + $class = get_parent_class($class); + } + if ($candidates === 'none') { + return []; + } + + // If we're using a superclass, check if we've already processed its allowed children list + if ($class !== $owner->ClassName && isset(static::$cache_allowedChildren[$class])) { + $allowedChildren = static::$cache_allowedChildren[$class]; + static::$cache_allowedChildren[$owner->ClassName] = $allowedChildren; + return $allowedChildren; + } + + // Set the highest available class (and implicitly its subclasses) as being allowed. + if (!$candidates) { + $candidates = [$baseClass]; + } + + // Parse candidate list + $allowedChildren = []; + foreach ((array)$candidates as $candidate) { + // If a classname is prefixed by "*", such as "*App\Model\MyModel", then only that class is allowed - no subclasses. + // Otherwise, the class and all its subclasses are allowed. + if (substr($candidate, 0, 1) == '*') { + $allowedChildren[] = substr($candidate, 1); + } elseif ($subclasses = ClassInfo::subclassesFor($candidate)) { + foreach ($subclasses as $subclass) { + if (!is_a($subclass, HiddenClass::class, true)) { + $allowedChildren[] = $subclass; + } + } + } + } + static::$cache_allowedChildren[$owner->ClassName] = $allowedChildren; + // Make sure we don't have to re-process if this is the allowed children set of a superclass + if ($class !== $owner->ClassName) { + static::$cache_allowedChildren[$class] = $allowedChildren; + } + } + $owner->extend('updateAllowedChildren', $allowedChildren); + + return $allowedChildren; + } + /** * Checks if we're on a controller where we should filter. ie. Are we loading the SiteTree? * @@ -567,6 +777,50 @@ public function getBreadcrumbs($separator = ' » ') return implode($separator ?? '', $crumbs); } + /** + * Get the name of the dedicated sort field, if there is one. + */ + public function getSortField(): ?string + { + return $this->getOwner()::config()->get('sort_field'); + } + + /** + * Returns true if the current user can add children to this page. + * + * Denies permission if any of the following conditions is true: + * - the record is versioned and archived + * - canAddChildren() on a extension returns false + * - canEdit() is not granted + * - allowed_children is not set to "none" + */ + public function canAddChildren(?Member $member = null): bool + { + $owner = $this->getOwner(); + // Disable adding children to archived records + if ($owner->hasExtension(Versioned::class) && $owner->isArchived()) { + return false; + } + + if (!$member) { + $member = Security::getCurrentUser(); + } + + // Standard mechanism for accepting permission changes from extensions + $extended = $owner->extendedCan('canAddChildren', $member); + if ($extended !== null) { + return $extended; + } + + return $owner->canEdit($member) && $owner::config()->get('allowed_children') !== 'none'; + } + + protected function extendCanAddChildren() + { + // Prevent canAddChildren from extending itself + return null; + } + /** * Flush all Hierarchy caches: * - Children (instance) @@ -577,4 +831,23 @@ protected function onFlushCache() $this->owner->_cache_children = null; Hierarchy::$cache_numChildren = []; } + + /** + * Block creating children not allowed for the parent type + */ + protected function canCreate(?Member $member, array $context): ?bool + { + // Parent is added to context through CMSMain + // Note that not having a parent doesn't necessarily mean this record is being + // created at the root, so we can't check against can_be_root here. + $parent = isset($context['Parent']) ? $context['Parent'] : null; + $parentInHierarchy = ($parent && is_a($parent, $this->getHierarchyBaseClass())); + if ($parentInHierarchy && !in_array(get_class($this->getOwner()), $parent->allowedChildren())) { + return false; + } + if ($parent?->exists() && $parentInHierarchy && !$parent->canAddChildren($member)) { + return false; + } + return null; + } } diff --git a/tests/php/Forms/GridField/GridFieldDataColumnsTest.php b/tests/php/Forms/GridField/GridFieldDataColumnsTest.php index 44c51f78073..77b21b3e8d5 100644 --- a/tests/php/Forms/GridField/GridFieldDataColumnsTest.php +++ b/tests/php/Forms/GridField/GridFieldDataColumnsTest.php @@ -4,11 +4,13 @@ use InvalidArgumentException; use LogicException; +use PHPUnit\Framework\Attributes\DataProvider; use SilverStripe\Forms\GridField\GridFieldDataColumns; use SilverStripe\Security\Member; use SilverStripe\Dev\SapphireTest; use SilverStripe\Forms\GridField\GridField; use SilverStripe\Forms\GridField\GridFieldConfig_Base; +use SilverStripe\Forms\Tests\GridField\GridFieldDataColumnsTest\TestStatusFlagsObject; use SilverStripe\Model\List\ArrayList; use SilverStripe\Model\ArrayData; use stdClass; @@ -88,4 +90,81 @@ public function testGetDisplayFieldsThrowsException() $component->getDisplayFields($gridField); } + + public static function provideGetColumnContentHasStatusFlags(): array + { + return [ + 'turned off' => [ + 'displayStatusFlags' => false, + 'columnsForStatusFlag' => [], + 'columnsHaveFlags' => [ + 'Title' => false, + 'AnotherField' => false, + ], + ], + 'turned on, default col' => [ + 'displayStatusFlags' => true, + 'columnsForStatusFlag' => [], + 'columnsHaveFlags' => [ + 'Title' => true, + 'AnotherField' => false, + ], + ], + 'turned on, explicit col' => [ + 'displayStatusFlags' => true, + 'columnsForStatusFlag' => ['AnotherField'], + 'columnsHaveFlags' => [ + 'Title' => false, + 'AnotherField' => true, + ], + ], + 'turned on, missing col' => [ + 'displayStatusFlags' => true, + 'columnsForStatusFlag' => ['MissingField'], + 'columnsHaveFlags' => [ + 'Title' => false, + 'AnotherField' => false, + ], + ], + ]; + } + + #[DataProvider('provideGetColumnContentHasStatusFlags')] + public function testGetColumnContentHasStatusFlags( + bool $displayStatusFlags, + array $columnsForStatusFlag, + array $columnsHaveFlags + ): void { + $model = new TestStatusFlagsObject(); + $gridfield = new GridField('testfield', 'testfield', new ArrayList([$model])); + $columns = $gridfield->getConfig()->getComponentByType(GridFieldDataColumns::class); + $columns->setDisplayFields(['Title' => 'Title', 'AnotherField' => 'AnotherField']); + $columns->setDisplayStatusFlags($displayStatusFlags); + // For this test an empty array means "use the default" - we'll test passing in an empty aray separately. + if (!empty($columnsForStatusFlag)) { + $columns->setColumnsForStatusFlag($columnsForStatusFlag); + } + // Let columns check for an appropriate column to render the flags in + $cols = []; + $columns->augmentColumns($gridfield, $cols); + + $flags = $model->getStatusFlagMarkup('ss-gridfield-badge'); + foreach ($columnsHaveFlags as $column => $hasFlags) { + if ($hasFlags) { + $this->assertStringContainsString($flags, $columns->getColumnContent($gridfield, $model, $column)); + } else { + $this->assertStringNotContainsString($flags, $columns->getColumnContent($gridfield, $model, $column)); + } + } + } + + public function testSetColumnsForStatusFlagEmptyArray(): void + { + $model = new TestStatusFlagsObject(); + $gridfield = new GridField('testfield', 'testfield', new ArrayList([$model])); + $columns = $gridfield->getConfig()->getComponentByType(GridFieldDataColumns::class); + + $this->expectException(InvalidArgumentException::class); + $columns->setColumnsForStatusFlag([]); + } } diff --git a/tests/php/Forms/GridField/GridFieldDataColumnsTest/TestStatusFlagsObject.php b/tests/php/Forms/GridField/GridFieldDataColumnsTest/TestStatusFlagsObject.php new file mode 100644 index 00000000000..23a6db0f7e8 --- /dev/null +++ b/tests/php/Forms/GridField/GridFieldDataColumnsTest/TestStatusFlagsObject.php @@ -0,0 +1,30 @@ + 'a flag', + 'text' => 'flag1', + ]; + $flags['f2'] = 'flag2'; + return $flags; + } +} diff --git a/tests/php/Forms/GridField/GridFieldDetailFormItemRequestTest/TestBreadcrumbsController.php b/tests/php/Forms/GridField/GridFieldDetailFormItemRequestTest/TestBreadcrumbsController.php new file mode 100644 index 00000000000..c8ca203d380 --- /dev/null +++ b/tests/php/Forms/GridField/GridFieldDetailFormItemRequestTest/TestBreadcrumbsController.php @@ -0,0 +1,16 @@ + 'My Controller', 'Link' => 'my-link'])]); + } +} diff --git a/tests/php/Forms/GridField/GridFieldDetailFormItemRequestTest/TestStatusFlagsObject.php b/tests/php/Forms/GridField/GridFieldDetailFormItemRequestTest/TestStatusFlagsObject.php new file mode 100644 index 00000000000..75904d31fb9 --- /dev/null +++ b/tests/php/Forms/GridField/GridFieldDetailFormItemRequestTest/TestStatusFlagsObject.php @@ -0,0 +1,30 @@ + 'a flag', + 'text' => 'flag1', + ]; + $flags['f2'] = 'flag2'; + return $flags; + } +} diff --git a/tests/php/Forms/GridField/GridFieldDetailForm_ItemRequestTest.php b/tests/php/Forms/GridField/GridFieldDetailForm_ItemRequestTest.php index 0ccd4ee44e5..16ab9be84cb 100644 --- a/tests/php/Forms/GridField/GridFieldDetailForm_ItemRequestTest.php +++ b/tests/php/Forms/GridField/GridFieldDetailForm_ItemRequestTest.php @@ -4,13 +4,17 @@ use LogicException; use SilverStripe\Control\Controller; +use SilverStripe\Dev\CSSContentParser; use SilverStripe\Dev\SapphireTest; use SilverStripe\Forms\GridField\GridField; use SilverStripe\Forms\GridField\GridFieldConfig_Base; use SilverStripe\Forms\GridField\GridFieldDetailForm; use SilverStripe\Forms\GridField\GridFieldDetailForm_ItemRequest; +use SilverStripe\Forms\Tests\GridField\GridFieldDetailFormItemRequestTest\TestBreadcrumbsController; +use SilverStripe\Forms\Tests\GridField\GridFieldDetailFormItemRequestTest\TestStatusFlagsObject; use SilverStripe\Model\List\ArrayList; use SilverStripe\Model\ArrayData; +use SilverStripe\ORM\FieldType\DBHTMLText; class GridFieldDetailForm_ItemRequestTest extends SapphireTest { @@ -31,4 +35,29 @@ public function testItemEditFormThrowsException() $itemRequest->ItemEditForm(); } + + public function testBreadcrumbs(): void + { + $record = new TestStatusFlagsObject(); + $gridField = new GridField('dummy', 'dummy', new ArrayList([$record]), new GridFieldConfig_Base()); + $modelClass = ArrayData::class; + $gridField->setModelClass($modelClass); + $itemRequest = new GridFieldDetailForm_ItemRequest($gridField, new GridFieldDetailForm(), $record, new TestBreadcrumbsController(), ''); + + $crumbs = $itemRequest->Breadcrumbs(); + $this->assertCount(2, $crumbs); + $this->assertTrue($crumbs->last()->hasField('Extra')); + $crumbFlags = $crumbs->last()->getField('Extra'); + $statusFlagMarkup = $record->getStatusFlagMarkup('badge--breadcrumbs'); + + // Check status flags are in the right spot + $this->assertInstanceOf(DBHTMLText::class, $crumbFlags); + $this->assertSame($statusFlagMarkup, $crumbFlags->__toString()); + + // Check crumbs are as expected + $this->assertSame('My Controller', $crumbs->first()->Title); + $this->assertSame('my-link', $crumbs->first()->Link); + $this->assertSame('New TestStatusFlagsObject', $crumbs->last()->Title); + $this->assertSame(false, $crumbs->last()->Link); + } } diff --git a/tests/php/Forms/GridField/GridFieldReadonlyTest.php b/tests/php/Forms/GridField/GridFieldReadonlyTest.php index b377d337c5c..50285fabf53 100644 --- a/tests/php/Forms/GridField/GridFieldReadonlyTest.php +++ b/tests/php/Forms/GridField/GridFieldReadonlyTest.php @@ -22,7 +22,6 @@ use SilverStripe\Forms\GridField\GridField; use SilverStripe\Forms\GridField\GridFieldViewButton; use SilverStripe\Forms\Tests\GridField\GridFieldReadonlyTest\GridFieldViewButtonReplacement; -use SilverStripe\Versioned\VersionedGridFieldState\VersionedGridFieldState; use PHPUnit\Framework\Attributes\DataProvider; class GridFieldReadonlyTest extends SapphireTest @@ -77,7 +76,6 @@ public function testReadOnlyTransformation(?string $viewButtonClass) $gridConfig->addComponent($pagination = new GridFieldPaginator(2)); $gridConfig->addComponent(new GridFieldDetailForm()); $gridConfig->addComponent(new GridFieldDeleteAction()); - $gridConfig->addComponent(new VersionedGridFieldState()); $gridField = GridField::create( 'Cheerleaders', diff --git a/tests/php/Forms/GridField/GridFieldTest.php b/tests/php/Forms/GridField/GridFieldTest.php index 1eba6d4edf5..d80a9a70c62 100644 --- a/tests/php/Forms/GridField/GridFieldTest.php +++ b/tests/php/Forms/GridField/GridFieldTest.php @@ -13,8 +13,6 @@ use SilverStripe\Forms\GridField\GridField; use SilverStripe\Forms\GridField\GridFieldButtonRow; use SilverStripe\Forms\GridField\GridFieldConfig; -use SilverStripe\Forms\GridField\GridFieldConfig_Base; -use SilverStripe\Forms\GridField\GridFieldConfig_RecordEditor; use SilverStripe\Forms\GridField\GridFieldDataColumns; use SilverStripe\Forms\GridField\GridFieldFilterHeader; use SilverStripe\Forms\GridField\GridFieldPageCount; @@ -37,7 +35,6 @@ use SilverStripe\Core\Validation\ValidationResult; use SilverStripe\Security\Group; use SilverStripe\Security\Member; -use SilverStripe\Versioned\VersionedGridFieldStateExtension; class GridFieldTest extends SapphireTest { @@ -48,15 +45,6 @@ class GridFieldTest extends SapphireTest Team::class, ]; - protected static $illegal_extensions = [ - GridFieldConfig_RecordEditor::class => [ - VersionedGridFieldStateExtension::class, - ], - GridFieldConfig_Base::class => [ - VersionedGridFieldStateExtension::class, - ], - ]; - public function testGridField() { $obj = new GridField('testfield', 'testfield'); diff --git a/tests/php/Model/ModelDataTest.php b/tests/php/Model/ModelDataTest.php index 9f233980f5a..93ee701ea69 100644 --- a/tests/php/Model/ModelDataTest.php +++ b/tests/php/Model/ModelDataTest.php @@ -398,4 +398,35 @@ public function testWrapArrayInObj(array $arr, string $expectedClass): void $modelData->arr = $arr; $this->assertInstanceOf($expectedClass, $modelData->obj('arr')); } + + public function testGetStatusFlags(): void + { + // no flags by default + $modelData = new ModelData(); + $this->assertSame([], $modelData->getStatusFlags()); + + // test updateStatusFlags extension hook + $modelData = new ModelDataTestObject(); + $this->assertSame([ + 'myKey1' => 'some flag', + 'myKey2' => [ + 'text' => 'another flag', + 'title' => 'title attr', + ], + ], $modelData->getStatusFlags()); + } + + public function testGetStatusFlagMarkup(): void + { + // no flags means no markup by default + $modelData = new ModelData(); + $this->assertSame('', $modelData->getStatusFlagMarkup('my-css-class')); + + // test updateStatusFlags extension hook + $modelData = new ModelDataTestObject(); + $this->assertSame( + 'some flaganother flag', + $modelData->getStatusFlagMarkup('my-css-class') + ); + } } diff --git a/tests/php/Model/ModelDataTest/ViewableDataTextExtension.php b/tests/php/Model/ModelDataTest/ViewableDataTextExtension.php index 6c1aaae07bf..b41b55e4aaf 100644 --- a/tests/php/Model/ModelDataTest/ViewableDataTextExtension.php +++ b/tests/php/Model/ModelDataTest/ViewableDataTextExtension.php @@ -21,4 +21,13 @@ public function publicMethodFromExtension(): string { return 'Public function'; } + + public function updateStatusFlags(array &$flags): void + { + $flags['myKey1'] = 'some flag'; + $flags['myKey2'] = [ + 'text' => 'another flag', + 'title' => 'title attr', + ]; + } } diff --git a/tests/php/ORM/HierarchyTest.php b/tests/php/ORM/HierarchyTest.php index 989cedf4bbe..4a367f5db3e 100644 --- a/tests/php/ORM/HierarchyTest.php +++ b/tests/php/ORM/HierarchyTest.php @@ -2,9 +2,13 @@ namespace SilverStripe\ORM\Tests; +use PHPUnit\Framework\Attributes\DataProvider; +use ReflectionClass; +use SilverStripe\Core\Config\Config; use SilverStripe\Core\Validation\ValidationException; use SilverStripe\Versioned\Versioned; use SilverStripe\Dev\SapphireTest; +use SilverStripe\ORM\Hierarchy\Hierarchy; class HierarchyTest extends SapphireTest { @@ -16,6 +20,16 @@ class HierarchyTest extends SapphireTest HierarchyTest\HideTestSubObject::class, HierarchyTest\HierarchyOnSubclassTestObject::class, HierarchyTest\HierarchyOnSubclassTestSubObject::class, + HierarchyTest\NoEditTestObject::class, + HierarchyTest\HierarchyModel::class, + HierarchyTest\SortableHierarchyModel::class, + HierarchyTest\TestAllowedChildrenA::class, + HierarchyTest\TestAllowedChildrenB::class, + HierarchyTest\TestAllowedChildrenC::class, + HierarchyTest\TestAllowedChildrenCext::class, + HierarchyTest\TestAllowedChildrenD::class, + HierarchyTest\TestAllowedChildrenE::class, + HierarchyTest\TestAllowedChildrenHidden::class, ]; public static function getExtraDataObjects() @@ -68,12 +82,12 @@ public function testAllHistoricalChildren() // Check that obj1-3 appear at the top level of the AllHistoricalChildren tree $this->assertEquals( - ["Obj 1", "Obj 2", "Obj 3"], + ['Obj 1', 'Obj 2', 'Obj 3', 'Obj no-edit 1'], HierarchyTest\TestObject::singleton()->AllHistoricalChildren()->column('Title') ); // Check numHistoricalChildren - $this->assertEquals(3, HierarchyTest\TestObject::singleton()->numHistoricalChildren()); + $this->assertEquals(4, HierarchyTest\TestObject::singleton()->numHistoricalChildren()); // Check that both obj 2 children are returned /** @var HierarchyTest\TestObject $obj2 */ @@ -294,4 +308,378 @@ public function testHideFromHierarchy() $this->assertEquals($obj4->stageChildren()->Count(), 1); $this->assertEquals($obj4->liveChildren()->Count(), 1); } + + /** + * Check canCreate permissions respect allowed_children config. + * + * Note we are intentionally note testing all possible allowed_children config here since allowedChildren() + * will be called and there are dedicated tests for that method. + */ + public function testCanCreate(): void + { + $singleton = singleton(HierarchyTest\TestObject::class); + $reflectionHierarchy = new ReflectionClass(Hierarchy::class); + $reflectionHierarchy->setStaticPropertyValue('cache_allowedChildren', []); + + // Test logged out users cannot create (i.e. we're not breaking default permissions) + $this->logOut(); + $this->assertFalse($singleton->canCreate()); + + // Login with admin permissions (default return true on DataObject) + $this->logInWithPermission('ADMIN'); + $this->assertTrue($singleton->canCreate()); + + // Test creation underneath a parent which this user can edit + $parent = $this->objFromFixture(HierarchyTest\HideTestObject::class, 'obj4'); + $this->assertTrue($singleton->canCreate(null, ['Parent' => $parent])); + + // Test creation underneath a parent which this user CANNOT edit + $parent = $this->objFromFixture(HierarchyTest\NoEditTestObject::class, 'no-edit1'); + $this->assertFalse($singleton->canCreate(null, ['Parent' => $parent])); + + // Test creation underneath a parent which explicitly allows it + HierarchyTest\HideTestSubObject::config()->set('allowed_children', [HierarchyTest\HideTestObject::class]); + $singleton2 = HierarchyTest\HideTestObject::singleton(); + $reflectionHierarchy->setStaticPropertyValue('cache_allowedChildren', []); + $parent = $this->objFromFixture(HierarchyTest\HideTestSubObject::class, 'obj4b'); + $this->assertTrue($singleton2->canCreate(null, ['Parent' => $parent])); + + // Test creation underneath a parent which implicitly does NOT allow it + HierarchyTest\HideTestSubObject::config()->set('allowed_children', [HierarchyTest\HideTestSubObject::class]); + $reflectionHierarchy->setStaticPropertyValue('cache_allowedChildren', []); + $parent = $this->objFromFixture(HierarchyTest\HideTestSubObject::class, 'obj4b'); + $this->assertFalse($singleton2->canCreate(null, ['Parent' => $parent])); + + // Test we don't check for allowedChildren on parent context if it's not in the same hierarchy + $parent = $this->objFromFixture(HierarchyTest\HideTestObject::class, 'obj4'); + HierarchyTest\HideTestObject::config()->set('allowed_children', [HierarchyTest\HideTestObject::class]); + $this->assertTrue($singleton->canCreate(null, ['Parent' => $parent])); + } + + public function testCanAddChildren() + { + $record = new HierarchyTest\TestObject(); + + // Can't add children if unauthenticated (default canEdit permissions) + $this->logOut(); + $this->assertFalse($record->canAddChildren()); + + // Admin can add children by default + $this->logInWithPermission('ADMIN'); + $this->assertTrue($record->canAddChildren()); + + // Can't add children to archived records + $record->publishSingle(); + $record->doArchive(); + $this->assertFalse($record->canAddChildren()); + + // Can't add children to models that don't allow children + $record = new HierarchyTest\TestAllowedChildrenE(); + $this->assertFalse($record->canAddChildren()); + + // Can't edit, so can't add children + $record = new HierarchyTest\NoEditTestObject(); + $this->assertFalse($record->canAddChildren()); + } + + public static function provideAllowedChildren(): array + { + return [ + 'implicitly allows entire unhidden hierarchy' => [ + 'className' => HierarchyTest\HierarchyModel::class, + 'expected' => [ + HierarchyTest\HierarchyModel::class, + HierarchyTest\TestAllowedChildrenA::class, + HierarchyTest\TestAllowedChildrenB::class, + HierarchyTest\TestAllowedChildrenC::class, + HierarchyTest\TestAllowedChildrenD::class, + HierarchyTest\TestAllowedChildrenE::class, + HierarchyTest\TestAllowedChildrenCext::class, + ], + ], + 'directly sets allowed child' => [ + 'className' => HierarchyTest\TestAllowedChildrenA::class, + 'expected' => [ + HierarchyTest\TestAllowedChildrenB::class, + ], + ], + 'subclasses are allowed implicitly' => [ + 'className' => HierarchyTest\TestAllowedChildrenB::class, + 'expected' => [ + HierarchyTest\TestAllowedChildrenC::class, + HierarchyTest\TestAllowedChildrenCext::class, + ], + ], + 'multiple classes can be defined' => [ + 'className' => HierarchyTest\TestAllowedChildrenC::class, + 'expected' => [ + HierarchyTest\TestAllowedChildrenA::class, + HierarchyTest\TestAllowedChildrenD::class, + ], + ], + 'overrides (rather than merging with) parent class config' => [ + 'className' => HierarchyTest\TestAllowedChildrenCext::class, + 'expected' => [ + HierarchyTest\TestAllowedChildrenB::class, + ], + ], + 'explicitly excludes subclasses of the allowed child' => [ + 'className' => HierarchyTest\TestAllowedChildrenD::class, + 'expected' => [ + HierarchyTest\TestAllowedChildrenC::class, + ], + ], + 'explicitly allows no children' => [ + 'className' => HierarchyTest\TestAllowedChildrenE::class, + 'expected' => [], + ], + ]; + } + + /** + * Tests that various types of SiteTree classes will or will not be returned from the allowedChildren method + */ + #[DataProvider('provideAllowedChildren')] + public function testAllowedChildren(string $className, array $expected): void + { + $class = new $className(); + $this->assertSame($expected, $class->allowedChildren()); + } + + public static function provideValidationAllowedChildren(): array + { + return [ + 'Does allow children on unrestricted parent' => [ + 'parentClass' => HierarchyTest\HierarchyModel::class, + 'validateClass' => HierarchyTest\TestAllowedChildrenB::class, + 'expected' => true, + ], + 'Does allow child specifically allowed by parent' => [ + 'parentClass' => HierarchyTest\TestAllowedChildrenA::class, + 'validateClass' => HierarchyTest\TestAllowedChildrenB::class, + 'expected' => true, + ], + 'Doesnt allow child on parents specifically restricting children' => [ + 'parentClass' => HierarchyTest\TestAllowedChildrenC::class, + 'validateClass' => HierarchyTest\TestAllowedChildrenB::class, + 'expected' => false, + ], + 'Doesnt allow child on parents disallowing all children' => [ + 'parentClass' => HierarchyTest\TestAllowedChildrenE::class, + 'validateClass' => HierarchyTest\TestAllowedChildrenB::class, + 'expected' => false, + ], + 'Does allow subclasses of allowed children by default' => [ + 'parentClass' => HierarchyTest\TestAllowedChildrenB::class, + 'validateClass' => HierarchyTest\TestAllowedChildrenCext::class, + 'expected' => true, + ], + 'Doesnt allow child where only parent class is allowed on parent node, and asterisk prefixing is used' => [ + 'parentClass' => HierarchyTest\TestAllowedChildrenD::class, + 'validateClass' => HierarchyTest\TestAllowedChildrenCext::class, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidationAllowedChildren')] + public function testValidationAllowedChildren(string $parentClass, string $validateClass, bool $expected): void + { + $parent = new $parentClass(); + $parent->write(); + $toValidate = new $validateClass(); + $toValidate->ParentID = $parent->ID; + + $this->assertSame($expected, $toValidate->validate()->isValid()); + } + + public static function provideValidationCanBeRoot(): array + { + return [ + [ + 'canBeRoot' => true, + 'hasParent' => true, + 'expected' => true, + ], + [ + 'canBeRoot' => true, + 'hasParent' => false, + 'expected' => true, + ], + [ + 'canBeRoot' => false, + 'hasParent' => true, + 'expected' => true, + ], + [ + 'canBeRoot' => false, + 'hasParent' => false, + 'expected' => false, + ], + ]; + } + + #[DataProvider('provideValidationCanBeRoot')] + public function testValidationCanBeRoot(bool $canBeRoot, bool $hasParent, bool $expected): void + { + $record = new HierarchyTest\HierarchyModel(); + if ($hasParent) { + $parent = new HierarchyTest\HierarchyModel(); + $parent->write(); + $record->ParentID = $parent->ID; + } + + HierarchyTest\HierarchyModel::config()->set('can_be_root', $canBeRoot); + $this->assertSame($expected, $record->validate()->isValid()); + } + + /** + * Test that duplicateWithChildren() works on models with no sort field + */ + public function testDuplicateWithChildren(): void + { + $parent = new HierarchyTest\HierarchyModel(); + $parent->Title = 'Parent'; + $parent->write(); + + $child1 = new HierarchyTest\HierarchyModel(); + $child1->ParentID = $parent->ID; + $child1->Title = 'Child 1'; + $child1->write(); + + $child2 = new HierarchyTest\HierarchyModel(); + $child2->ParentID = $parent->ID; + $child2->Title = 'Child 2'; + $child2->write(); + + $duplicateParent = $parent->duplicateWithChildren(); + $duplicateChildren = $duplicateParent->AllChildren()->toArray(); + $this->assertCount(2, $duplicateChildren); + + $duplicateChild1 = array_shift($duplicateChildren); + $duplicateChild2 = array_shift($duplicateChildren); + + // Kept titles, but have new IDs + $this->assertEquals($child1->Title, $duplicateChild1->Title); + $this->assertEquals($child2->Title, $duplicateChild2->Title); + $this->assertNotEquals($duplicateChild1->ID, $child1->ID); + $this->assertNotEquals($duplicateChild2->ID, $child2->ID); + } + + /** + * Test that duplicateWithChildren() works on models which do have a sort field + */ + public function testDuplicateWithChildrenRetainSort(): void + { + $parent = new HierarchyTest\SortableHierarchyModel(); + $parent->Title = 'Parent'; + $parent->write(); + + $child1 = new HierarchyTest\SortableHierarchyModel(); + $child1->ParentID = $parent->ID; + $child1->Title = 'Child 1'; + $child1->Sort = 2; + $child1->write(); + + $child2 = new HierarchyTest\SortableHierarchyModel(); + $child2->ParentID = $parent->ID; + $child2->Title = 'Child 2'; + $child2->Sort = 1; + $child2->write(); + + $duplicateParent = $parent->duplicateWithChildren(); + $duplicateChildren = $duplicateParent->AllChildren()->toArray(); + $this->assertCount(2, $duplicateChildren); + + $duplicateChild2 = array_shift($duplicateChildren); + $duplicateChild1 = array_shift($duplicateChildren); + + // Kept titles, but have new IDs + $this->assertEquals($child1->Title, $duplicateChild1->Title); + $this->assertEquals($child2->Title, $duplicateChild2->Title); + $this->assertNotEquals($duplicateChild1->ID, $child1->ID); + $this->assertNotEquals($duplicateChild2->ID, $child2->ID); + + // assertGreaterThan works by having the LOWER value first + $this->assertGreaterThan($duplicateChild2->Sort, $duplicateChild1->Sort); + } + + public static function provideDefaultChild(): array + { + return [ + 'defaults to first allowed child' => [ + 'class' => HierarchyTest\HierarchyModel::class, + 'defaultChildConfig' => null, + 'expected' => HierarchyTest\HierarchyModel::class, + ], + 'respects default_child config' => [ + 'class' => HierarchyTest\HierarchyModel::class, + 'defaultChildConfig' => HierarchyTest\TestAllowedChildrenA::class, + 'expected' => HierarchyTest\TestAllowedChildrenA::class, + ], + 'doesnt allow children outside of class hierarchy' => [ + 'class' => HierarchyTest\HierarchyModel::class, + 'defaultChildConfig' => HierarchyTest\SortableHierarchyModel::class, + 'expected' => HierarchyTest\HierarchyModel::class, + ], + 'doesnt allow hidden children' => [ + 'class' => HierarchyTest\HierarchyModel::class, + 'defaultChildConfig' => HierarchyTest\TestAllowedChildrenHidden::class, + 'expected' => HierarchyTest\HierarchyModel::class, + ], + 'doesnt allow children that arent in allow list' => [ + 'class' => HierarchyTest\TestAllowedChildrenA::class, + 'defaultChildConfig' => HierarchyTest\TestAllowedChildrenA::class, + 'expected' => HierarchyTest\TestAllowedChildrenB::class, + ], + ]; + } + + #[DataProvider('provideDefaultChild')] + public function testDefaultChild(string $class, ?string $defaultChildConfig, ?string $expected): void + { + Config::forClass($class)->set('default_child', $defaultChildConfig); + /** @var DataObject&Hierarchy $obj */ + $obj = new $class(); + + $this->assertSame($expected, $obj->defaultChild()); + } + + public static function provideDefaultParent(): array + { + // These are subject to change but the current behaviour is very naive + // so that's what we're validating against here + return [ + 'no default value' => [ + 'class' => HierarchyTest\HierarchyModel::class, + 'defaultParentConfig' => null, + 'expected' => null, + ], + 'respects default_parent config' => [ + 'class' => HierarchyTest\HierarchyModel::class, + 'defaultParentConfig' => HierarchyTest\TestAllowedChildrenA::class, + 'expected' => HierarchyTest\TestAllowedChildrenA::class, + ], + 'doesnt validate if the class is in our hierarchy' => [ + 'class' => HierarchyTest\SortableHierarchyModel::class, + 'defaultParentConfig' => HierarchyTest\HierarchyModel::class, + 'expected' => HierarchyTest\HierarchyModel::class, + ], + 'doesnt validate against allowedChildren of the parent class' => [ + 'class' => HierarchyTest\TestAllowedChildrenA::class, + 'defaultParentConfig' => HierarchyTest\TestAllowedChildrenA::class, + 'expected' => HierarchyTest\TestAllowedChildrenA::class, + ], + ]; + } + + #[DataProvider('provideDefaultParent')] + public function testDefaultParent(string $class, ?string $defaultParentConfig, ?string $expected): void + { + Config::forClass($class)->set('default_parent', $defaultParentConfig); + /** @var DataObject&Hierarchy $obj */ + $obj = new $class(); + + $this->assertSame($expected, $obj->defaultParent()); + } } diff --git a/tests/php/ORM/HierarchyTest.yml b/tests/php/ORM/HierarchyTest.yml index a8a9d8c3b40..9639d5a62f0 100644 --- a/tests/php/ORM/HierarchyTest.yml +++ b/tests/php/ORM/HierarchyTest.yml @@ -70,3 +70,6 @@ SilverStripe\ORM\Tests\HierarchyTest\HierarchyOnSubclassTestSubObject: obj5ba: Parent: =>SilverStripe\ORM\Tests\HierarchyTest\HierarchyOnSubclassTestSubObject.obj5b Title: Obj 5ba +SilverStripe\ORM\Tests\HierarchyTest\NoEditTestObject: + no-edit1: + Title: Obj no-edit 1 diff --git a/tests/php/ORM/HierarchyTest/HierarchyModel.php b/tests/php/ORM/HierarchyTest/HierarchyModel.php new file mode 100644 index 00000000000..90e153beb87 --- /dev/null +++ b/tests/php/ORM/HierarchyTest/HierarchyModel.php @@ -0,0 +1,23 @@ + 'Varchar' + ]; + + private static $extensions = [ + Hierarchy::class, + ]; +} diff --git a/tests/php/ORM/HierarchyTest/NoEditTestObject.php b/tests/php/ORM/HierarchyTest/NoEditTestObject.php new file mode 100644 index 00000000000..7a2c9efa524 --- /dev/null +++ b/tests/php/ORM/HierarchyTest/NoEditTestObject.php @@ -0,0 +1,13 @@ + 'Varchar', + 'Sort' => 'Int' + ]; + + private static $extensions = [ + Hierarchy::class, + ]; + + private static $default_sort = 'Sort'; + + private static $sort_field = 'Sort'; +} diff --git a/tests/php/ORM/HierarchyTest/TestAllowedChildrenA.php b/tests/php/ORM/HierarchyTest/TestAllowedChildrenA.php new file mode 100644 index 00000000000..7102835c96d --- /dev/null +++ b/tests/php/ORM/HierarchyTest/TestAllowedChildrenA.php @@ -0,0 +1,14 @@ +