diff --git a/src/Forms/GridField/GridFieldDataColumns.php b/src/Forms/GridField/GridFieldDataColumns.php index 09e5e8680e8..eb06cdbcef1 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 LogicException('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 ($columnName === $this->statusFlagColumn) { + $value .= $record->getStatusFlagMarkup('ss-gridfield-badge badge'); + } + return $value; } diff --git a/src/Forms/GridField/GridFieldDetailForm_ItemRequest.php b/src/Forms/GridField/GridFieldDetailForm_ItemRequest.php index d1dfa43d35d..7a3addcc59e 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 badge'); + 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..d53e1ca03d3 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 $cssClasses = 'badge'): string + { + $flagContent = ''; + foreach ($this->getStatusFlags() as $class => $data) { + $flagAttributes = [ + 'class' => "$cssClasses status-{$class}", + ]; + 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/Model/ModelDataTest.php b/tests/php/Model/ModelDataTest.php index 9f233980f5a..b5afd389749 100644 --- a/tests/php/Model/ModelDataTest.php +++ b/tests/php/Model/ModelDataTest.php @@ -398,4 +398,21 @@ 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()); + } } 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/DataObjectTest.php b/tests/php/ORM/DataObjectTest.php index f71787a16e7..b30aa70401b 100644 --- a/tests/php/ORM/DataObjectTest.php +++ b/tests/php/ORM/DataObjectTest.php @@ -1913,7 +1913,7 @@ public function testManyManyUnlimitedRowCount() $this->assertEquals(2, $player->Teams()->dataQuery()->query()->unlimitedRowCount()); } - public function provideSingularName(): array + public static function provideSingularName(): array { return [ [ @@ -1933,8 +1933,8 @@ public function provideSingularName(): array /** * Tests that singular_name() generates sensible defaults. - * @dataProvider provideSingularName */ + #[DataProvider('provideSingularName')] public function testSingularName(string $class, string $expected): void { i18n::set_locale('en_NZ'); @@ -1952,7 +1952,7 @@ public function testSingularName(string $class, string $expected): void ); } - public function providePluralName(): array + public static function providePluralName(): array { return [ [ @@ -1984,8 +1984,8 @@ public function providePluralName(): array /** * Tests that plural_name() generates sensible defaults. - * @dataProvider providePluralName */ + #[DataProvider('providePluralName')] public function testPluralName(string $class, string $expected): void { i18n::set_locale('en_NZ'); @@ -2003,7 +2003,7 @@ public function testPluralName(string $class, string $expected): void ); } - public function provideClassDescription(): array + public static function provideClassDescription(): array { return [ 'no description by default' => [ @@ -2021,9 +2021,7 @@ public function provideClassDescription(): array ]; } - /** - * @dataProvider provideClassDescription - */ + #[DataProvider('provideClassDescription')] public function testClassDescription(string $class, ?string $expected): void { i18n::set_locale('en_NZ'); 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 @@ +