diff --git a/.gitattributes b/.gitattributes index dfe0770..c846455 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,12 @@ # Auto detect text files and perform LF normalization * text=auto +/tests export-ignore +/docs export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/code-of-conduct.md export-ignore +/contributing.md export-ignore +/README.md export-ignore +/.scrutinizer.yml export-ignore +/.github export-ignore diff --git a/.gitignore b/.gitignore index 771f21c..42263ff 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ vendor /composer.lock /vendor/ /public/ +/.php-cs-fixer.cache diff --git a/README.md b/README.md index d2b7a4b..bffe539 100644 --- a/README.md +++ b/README.md @@ -27,3 +27,5 @@ Check out [documentation](https://gorriecoe.github.io/silverstripe-link/en) - [Gorrie Coe](https://github.com/gorriecoe) - [Elliot Sawyer](https://github.com/elliot-sawyer) + + diff --git a/composer.json b/composer.json index 54a7251..18ddad0 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,8 @@ "nswdpc/ci-files": "dev-v-4", "phpstan/phpstan": "^2", "phpunit/phpunit": "^11.5", - "rector/rector": "^2" + "rector/rector": "^2", + "phpstan/phpstan-phpunit": "^2" }, "extra": { "installer-name": "link" @@ -34,7 +35,9 @@ "config": { "allow-plugins": { "composer/installers": true, - "silverstripe/vendor-plugin": true + "silverstripe/vendor-plugin": true, + "silverstripe/recipe-plugin": true, + "phpstan/extension-installer": true } }, "scripts": { diff --git a/src/extensions/AutomaticMarkupID.php b/src/extensions/AutomaticMarkupID.php index 571f6a1..b59cb26 100644 --- a/src/extensions/AutomaticMarkupID.php +++ b/src/extensions/AutomaticMarkupID.php @@ -2,6 +2,7 @@ namespace gorriecoe\Link\Extensions; +use gorriecoe\Link\Models\Link; use SilverStripe\Core\Convert; use SilverStripe\Core\Extension; @@ -9,6 +10,7 @@ * Add sitetree type to link field * * @package silverstripe-link + * @extends \SilverStripe\Core\Extension */ class AutomaticMarkupID extends Extension { @@ -17,9 +19,9 @@ class AutomaticMarkupID extends Extension */ public function updateIDValue(&$id) { - $owner = $this->owner; - if ($owner->Title) { - $id = Convert::raw2url($owner->Title); + $owner = $this->getOwner(); + if (($owner instanceof Link) && $owner->Title) { + $id = Convert::raw2url($owner->Title ?? ''); } } } diff --git a/src/extensions/DBStringLink.php b/src/extensions/DBStringLink.php index 28d9c6b..4b01ca5 100644 --- a/src/extensions/DBStringLink.php +++ b/src/extensions/DBStringLink.php @@ -10,6 +10,7 @@ * Adds methods to DBString to help manipulate the output suitable for links * * @package silverstripe-link + * @extends \SilverStripe\Core\Extension<(\SilverStripe\ORM\FieldType\DBString & static)> */ class DBStringLink extends Extension { @@ -18,7 +19,7 @@ class DBStringLink extends Extension */ public function LinkFriendly(): string { - return Convert::raw2url($this->owner->value); + return Convert::raw2url($this->getOwner()->value ?? ''); } /** @@ -32,13 +33,13 @@ public function URLFriendly(): string /** * Provides string replace to allow phone number friendly urls */ - public function PhoneFriendly(): string + public function PhoneFriendly(): ?Phone { - $value = $this->owner->value; + $value = $this->getOwner()->value; if ($value) { return Phone::create($value); } else { - return ''; + return null; } } } diff --git a/src/extensions/DefineableMarkupID.php b/src/extensions/DefineableMarkupID.php index e02b750..b1c535a 100644 --- a/src/extensions/DefineableMarkupID.php +++ b/src/extensions/DefineableMarkupID.php @@ -2,6 +2,7 @@ namespace gorriecoe\Link\Extensions; +use gorriecoe\Link\Models\Link; use SilverStripe\Forms\FieldList; use SilverStripe\Forms\TextField; use SilverStripe\Core\Extension; @@ -11,33 +12,31 @@ * Add sitetree type to link field * * @package silverstripe-link + * @property ?string $IDCustomValue + * @extends \SilverStripe\Core\Extension */ class DefineableMarkupID extends Extension { /** * Database fields - * @var array */ - private static $db = [ + private static array $db = [ 'IDCustomValue' => 'Text' ]; /** * Update Fields - * @return FieldList */ public function updateCMSFields(FieldList $fields) { - $owner = $this->owner; $fields->addFieldToTab( 'Root.Main', TextField::create( 'IDCustomValue', - _t(__CLASS__ . '.ID', 'ID') + _t(self::class . '.ID', 'ID') ) - ->setDescription(_t(__CLASS__ . '.IDCUSTOMVALUE', 'Define an ID for the link. This is particularly useful for google tracking.')) + ->setDescription(_t(self::class . '.IDCUSTOMVALUE', 'Define an ID for the link. This is particularly useful for google tracking.')) ); - return $fields; } /** @@ -45,17 +44,19 @@ public function updateCMSFields(FieldList $fields) */ public function onBeforeWrite() { - $owner = $this->owner; - $owner->IDCustomValue = Convert::raw2url($owner->IDCustomValue); + $owner = $this->getOwner(); + if ($owner instanceof Link) { + $owner->IDCustomValue = Convert::raw2url($owner->IDCustomValue ?? ''); + } } /** * Renders an HTML ID attribute for this link */ - public function updateIDValue(&$id) + public function updateIDValue(&$id): void { - $owner = $this->owner; - if ($owner->IDCustomValue) { + $owner = $this->getOwner(); + if (($owner instanceof Link) && $owner->IDCustomValue) { $id = $owner->IDCustomValue; } } diff --git a/src/extensions/LinkSiteTree.php b/src/extensions/LinkSiteTree.php index 03add41..c5d421d 100644 --- a/src/extensions/LinkSiteTree.php +++ b/src/extensions/LinkSiteTree.php @@ -4,13 +4,14 @@ use gorriecoe\Link\Models\Link; use SilverStripe\CMS\Model\SiteTree; +use SilverStripe\Core\Config\Config; use SilverStripe\Forms\FieldList; use SilverStripe\Forms\TreeDropdownField; use SilverStripe\Forms\TextField; use SilverStripe\Core\Extension; use UncleCheese\DisplayLogic\Forms\Wrapper; -if(!class_exists(SiteTree::class)) { +if (!class_exists(SiteTree::class)) { return; } @@ -20,22 +21,23 @@ * @package silverstripe-link * * @property int $SiteTreeID + * @property ?string $Anchor + * @method mixed SiteTree() + * @extends \SilverStripe\Core\Extension */ class LinkSiteTree extends Extension { /** * Database fields - * @var array */ - private static $db = [ + private static array $db = [ 'Anchor' => 'Varchar(255)', ]; /** * Has_one relationship - * @var array */ - private static $has_one = [ + private static array $has_one = [ // @phpstan-ignore class.notFound 'SiteTree' => SiteTree::class, ]; @@ -43,29 +45,26 @@ class LinkSiteTree extends Extension /** * A map of object types that can be linked to * Custom dataobjects can be added to this - * - * @var array **/ - private static $types = [ + private static array $types = [ 'SiteTree' => 'Page on this website', ]; /** * Defines the label used in the sitetree dropdown. - * @param String $sitetree_field_label + * @param string $sitetree_field_label */ - private static $sitetree_field_label = 'MenuTitle'; + private static string $sitetree_field_label = 'MenuTitle'; /** * Update Fields - * @param FieldList $fields */ public function updateCMSFields(FieldList $fields) { - if(class_exists(SiteTree::class)) { - $owner = $this->owner; - $config = $owner->config(); - $sitetree_field_label = $config->get('sitetree_field_label') ? : 'MenuTitle'; + $owner = $this->getOwner(); + if (class_exists(SiteTree::class) && ($owner instanceof Link)) { + + $sitetree_field_label = Config::inst()->get($owner::class, 'sitetree_field_label') ?: 'MenuTitle'; // Insert site tree field after the file selection field $fields->insertAfter( @@ -73,71 +72,75 @@ public function updateCMSFields(FieldList $fields) Wrapper::create( $sitetreeField = TreeDropdownField::create( 'SiteTreeID', - _t(__CLASS__ . '.PAGE', 'Page'), + _t(self::class . '.PAGE', 'Page'), SiteTree::class ) ->setTitleField($sitetree_field_label), TextField::create( 'Anchor', - _t(__CLASS__ . '.ANCHOR', 'Anchor/Querystring') + _t(self::class . '.ANCHOR', 'Anchor/Querystring') ) - ->setDescription(_t(__CLASS__ . '.ANCHORINFO', 'Include # at the start of your anchor name or, ? at the start of your querystring')) + ->setDescription(_t(self::class . '.ANCHORINFO', 'Include # at the start of your anchor name or, ? at the start of your querystring')) ) ->displayIf('Type')->isEqualTo('SiteTree')->end() ); // Display warning if the selected page is deleted or unpublished - if ($owner->SiteTreeID && !$owner->SiteTree()->isPublished()) { - $sitetreeField->setDescription(_t(__CLASS__ . '.DELETEDWARNING', 'Warning: The selected page appears to have been deleted or unpublished. This link may not appear or may be broken in the frontend')); + $siteTree = $owner->SiteTree(); + if ($siteTree->isInDB() && !$siteTree->isPublished()) { + $sitetreeField->setDescription(_t(self::class . '.DELETEDWARNING', 'Warning: The selected page appears to have been deleted or unpublished. This link may not appear or may be broken in the frontend')); } } } public function updateIsCurrent(&$status): void { - $owner = $this->owner; + $owner = $this->getOwner(); if ( class_exists(SiteTree::class) && - $owner->Type == 'SiteTree' && - isset($owner->SiteTreeID) && - ($owner->CurrentPage instanceof SiteTree) + ($owner instanceof Link) && + $owner->Type == 'SiteTree' ) { - $currentPage = $owner->CurrentPage; - $status = $currentPage === $owner->SiteTree() || $currentPage->ID === $owner->SiteTreeID; + $currentPage = $owner->getCurrentPage(); + if ($currentPage instanceof SiteTree) { + $status = $currentPage === $owner->SiteTree() || $currentPage->ID === $owner->SiteTreeID; + } } } public function updateIsSection(&$status): void { - $owner = $this->owner; + $owner = $this->getOwner(); if ( class_exists(SiteTree::class) && - $owner->Type == 'SiteTree' && - isset($owner->SiteTreeID) && - ($owner->CurrentPage instanceof SiteTree) + ($owner instanceof Link) && + $owner->Type == 'SiteTree' ) { - $currentPage = $owner->CurrentPage; - $status = $owner->isCurrent() || in_array($owner->SiteTreeID, $currentPage->getAncestors()->column()); + $currentPage = $owner->getCurrentPage(); + if ($currentPage instanceof SiteTree) { + $status = $owner->isCurrent() || in_array($owner->SiteTreeID, $currentPage->getAncestors()->column()); + } } } public function updateIsOrphaned(&$status): void { - $owner = $this->owner; + $owner = $this->getOwner(); if ( class_exists(SiteTree::class) && - $owner->Type == 'SiteTree' && - isset($owner->SiteTreeID) && - ($owner->CurrentPage instanceof SiteTree) + ($owner instanceof Link) && + $owner->Type == 'SiteTree' ) { - $currentPage = $owner->CurrentPage; - // Always false for root pages - if (empty($owner->SiteTree()->ParentID)) { - $status = false; - } else { - // Parent must exist and not be an orphan itself - $parent = $owner->Parent(); - $status = !$parent || !$parent->exists() || $parent->isOrphaned(); + $currentPage = $owner->getCurrentPage(); + if ($currentPage instanceof SiteTree) { + // Always false for root pages + if (empty($owner->SiteTree()->ParentID)) { + $status = false; + } else { + // Parent must exist and not be an orphan itself + $parent = $owner->Parent(); + $status = !$parent || !$parent->exists() || $parent->isOrphaned(); + } } } } diff --git a/src/extensions/SiteTreeLink.php b/src/extensions/SiteTreeLink.php index 0ca9443..a9aa97a 100644 --- a/src/extensions/SiteTreeLink.php +++ b/src/extensions/SiteTreeLink.php @@ -3,12 +3,15 @@ namespace gorriecoe\Link\Extensions; use gorriecoe\Link\Models\Link; +use SilverStripe\CMS\Model\SiteTree; +use SilverStripe\Core\Config\Config; use SilverStripe\Core\Extension; /** * Fixes duplicate link in SiteTree * * @package silverstripe-link + * @extends \SilverStripe\Core\Extension */ class SiteTreeLink extends Extension { @@ -17,9 +20,9 @@ class SiteTreeLink extends Extension */ public function onBeforeDuplicate() { - $owner = $this->owner; + $owner = $this->getOwner(); //loop through has_one relationships and reset any Link fields - if($hasOne = $owner->Config()->get('has_one')){ + if (class_exists(SiteTree::class) && ($owner instanceof SiteTree) && ($hasOne = Config::inst()->get($owner::class, 'has_one'))) { foreach ($hasOne as $field => $fieldType) { if ($fieldType === Link::class) { $owner->{$field.'ID'} = 0; diff --git a/src/models/Link.php b/src/models/Link.php index a8541cc..e34fae4 100644 --- a/src/models/Link.php +++ b/src/models/Link.php @@ -2,8 +2,8 @@ namespace gorriecoe\Link\Models; +use gorriecoe\Link\View\Phone; use gorriecoe\Link\Extensions\LinkSiteTree; -use gorriecoe\Link\Extensions\SiteTreeLink; use InvalidArgumentException; use SilverStripe\Assets\File; use SilverStripe\CMS\Model\SiteTree; @@ -16,6 +16,8 @@ use SilverStripe\Forms\TextField; use SilverStripe\Forms\TreeDropdownField; use SilverStripe\ORM\DataObject; +use SilverStripe\ORM\FieldType\DBField; +use SilverStripe\ORM\FieldType\DBHTMLText; use SilverStripe\Core\Convert; use SilverStripe\Core\Validation\ValidationResult; use SilverStripe\Control\Director; @@ -29,12 +31,12 @@ * @package silverstripe-link * * @property string $Title - * @property string $Type - * @property string $URL - * @property string $Email - * @property string $Phone + * @property ?string $Type + * @property ?string $URL + * @property ?string $Email + * @property ?string $Phone * @property bool $OpenInNewWindow - * @property string $SelectedStyle + * @property ?string $SelectedStyle * @property int $FileID * @method File File() * @mixin LinkSiteTree @@ -43,15 +45,13 @@ class Link extends DataObject { /** * Defines the database table name - * @var string */ - private static $table_name = 'Link'; + private static string $table_name = 'Link'; /** * Database fields - * @var array */ - private static $db = [ + private static array $db = [ 'Title' => 'Varchar', 'Type' => 'Varchar(50)', 'URL' => 'Text', @@ -63,22 +63,20 @@ class Link extends DataObject /** * Has_one relationship - * @var array */ - private static $has_one = [ + private static array $has_one = [ 'File' => File::class ]; - - private static $owns = [ - 'File', + + private static array $owns = [ + 'File', ]; /** * Defines summary fields commonly used in table columns * as a quick overview of the data for this dataobject - * @var array */ - private static $summary_fields = [ + private static array $summary_fields = [ 'Title' => 'Title', 'TypeLabel' => 'Type', 'LinkURL' => 'Link' @@ -86,9 +84,8 @@ class Link extends DataObject /** * Defines a default list of filters for the search context - * @var array */ - private static $searchable_fields = [ + private static array $searchable_fields = [ 'Title', 'URL', 'Email', @@ -98,18 +95,14 @@ class Link extends DataObject /** * A map of styles that are available in the cms for * users to select from. - * - * @var array */ - private static $styles = []; + private static array $styles = []; /** * A map of object types that can be linked to * Custom dataobjects can be added to this - * - * @var array */ - private static $types = [ + private static array $types = [ 'URL' => 'URL', 'Email' => 'Email address', 'Phone' => 'Phone number', @@ -121,55 +114,28 @@ class Link extends DataObject * * @var array */ - private static $allowed_types = null; - - /** - * Ensures that the methods are wrapped in the correct type and - * values are safely escaped while rendering in the template. - * @var array - */ - private static $casting = [ - 'ClassAttr' => 'HTMLFragment', - 'TargetAttr' => 'HTMLFragment', - 'IDAttr' => 'HTMLFragment' - ]; + private static $allowed_types; /** * @config - * @var string */ - private static $linking_mode_default = 'link'; + private static string $linking_mode_default = 'link'; /** * @config - * @var string */ - private static $linking_mode_current = 'current'; + private static string $linking_mode_current = 'current'; /** * @config - * @var string */ - private static $linking_mode_section = 'section'; + private static string $linking_mode_section = 'section'; /** * If false, when Type is "File", folders in the TreeDropdownField will not be selectable. * @config - * @var boolean - */ - private static $link_to_folders = false; - - /** - * Provides a quick way to define additional methods for provideGraphQLScaffolding as Fields - * @return Array - */ - private static $gql_fields = []; - - /** - * Provides a quick way to define additional methods for provideGraphQLScaffolding as Nested Queries - * @var Array */ - private static $gql_nested_queries = []; + private static bool $link_to_folders = false; /** * Custom CSS classes for template @@ -186,6 +152,7 @@ class Link extends DataObject * CMS Fields * @return FieldList */ + #[\Override] public function getCMSFields() { $fields = FieldList::create( @@ -206,10 +173,10 @@ public function getCMSFields() 'Root.Settings', DropdownField::create( 'SelectedStyle', - _t(__CLASS__ . '.STYLE', 'Style'), + _t(self::class . '.STYLE', 'Style'), $styles ) - ->setEmptyString(_t(__CLASS__ . '.DEFAULT', 'Default')), + ->setEmptyString(_t(self::class . '.DEFAULT', 'Default')), 'Type' ); } @@ -227,27 +194,25 @@ public function getCMSFields() /** * CMS Main fields * This is so other modules can access these fields without other tabs etc. - * - * @return Array */ - public function getCMSMainFields() + public function getCMSMainFields(): array { $fields = [ TextField::create( 'Title', - _t(__CLASS__ . '.TITLE', 'Title') + _t(self::class . '.TITLE', 'Title') ) - ->setDescription(_t(__CLASS__ . '.OPTIONALTITLE', 'Optional. Will be auto-generated from link if left blank.')), + ->setDescription(_t(self::class . '.OPTIONALTITLE', 'Optional. Will be auto-generated from link if left blank.')), OptionsetField::create( 'Type', - _t(__CLASS__ . '.LINKTYPE', 'Type'), + _t(self::class . '.LINKTYPE', 'Type'), $this->i18nTypes ) ->setValue('URL'), Wrapper::create( $fileDropdown = TreeDropdownField::create( 'FileID', - _t(__CLASS__ . '.FILE', 'File'), + _t(self::class . '.FILE', 'File'), File::class, 'ID', 'Title' @@ -257,27 +222,27 @@ public function getCMSMainFields() Wrapper::create( TextField::create( 'URL', - _t(__CLASS__ . '.URL', 'URL') + _t(self::class . '.URL', 'URL') ) ) ->displayIf('Type')->isEqualTo('URL')->end(), Wrapper::create( TextField::create( 'Email', - _t(__CLASS__ . '.EMAILADDRESS', 'Email Address') + _t(self::class . '.EMAILADDRESS', 'Email Address') ) ) ->displayIf('Type')->isEqualTo('Email')->end(), Wrapper::create( TextField::create( 'Phone', - _t(__CLASS__ . '.PHONENUMBER', 'Phone Number') + _t(self::class . '.PHONENUMBER', 'Phone Number') ) ) ->displayIf('Type')->isEqualTo('Phone')->end(), CheckboxField::create( 'OpenInNewWindow', - _t(__CLASS__ . '.OPENINNEWWINDOW','Open link in a new window') + _t(self::class . '.OPENINNEWWINDOW', 'Open link in a new window') ) ->displayIf('Type')->isEqualTo('URL') ->orIf()->isEqualTo('File') @@ -286,9 +251,7 @@ public function getCMSMainFields() // Disable folders in dropdown if linking to folders is not allowed. if (!$this->config()->get('link_to_folders')) { - $fileDropdown->setDisableFunction(function ($item) { - return is_a($item, Folder::class); - }); + $fileDropdown->setDisableFunction(fn ($item): bool => is_a($item, Folder::class)); } $this->extend('updateCMSMainFields', $fields); @@ -299,6 +262,7 @@ public function getCMSMainFields() /** * Validate */ + #[\Override] public function validate(): ValidationResult { $valid = true; @@ -313,58 +277,64 @@ public function validate(): ValidationResult if ($this->{$type} == '') { $valid = false; $message = _t( - __CLASS__ . '.VALIDATIONERROR_EMPTY'.strtoupper($type), + self::class . '.VALIDATIONERROR_EMPTY'.strtoupper((string) $type), 'You must enter a {TypeLabel}', [ 'TypeLabel' => $this->TypeLabel ] ); } + break; case 'File': case 'SiteTree': if (empty($this->{$type.'ID'})) { $valid = false; $message = _t( - __CLASS__ . '.VALIDATIONERROR_OBJECT', + self::class . '.VALIDATIONERROR_OBJECT', 'Please select a {TypeLabel}', [ 'TypeLabel' => $this->TypeLabel ] ); } + break; } + // if its already failed don't bother checking the rest if ($valid) { switch ($type) { case 'URL': $allowedFirst = ['#', '/']; - if (!in_array(substr($this->URL, 0, 1), $allowedFirst) && !filter_var($this->URL, FILTER_VALIDATE_URL)) { + if (!in_array(substr((string) $this->URL, 0, 1), $allowedFirst) && !filter_var($this->URL, FILTER_VALIDATE_URL)) { $valid = false; $message = _t( - __CLASS__ . '.VALIDATIONERROR_VALIDURL', + self::class . '.VALIDATIONERROR_VALIDURL', 'Please enter a valid URL. Be sure to include http:// for an external URL. or begin your internal url/anchor with a "/" character' ); } + break; case 'Email': if (!filter_var($this->Email, FILTER_VALIDATE_EMAIL)) { $valid = false; $message = _t( - __CLASS__ . '.VALIDATIONERROR_VALIDEMAIL', + self::class . '.VALIDATIONERROR_VALIDEMAIL', 'Please enter a valid Email address' ); } + break; case 'Phone': - if (!preg_match("/^\+?[0-9a-zA-Z\-\s]*[\,\#]?[0-9\-\s]*$/", $this->Phone)) { + if (!preg_match("/^\+?[0-9a-zA-Z\-\s]*[\,\#]?[0-9\-\s]*$/", (string) $this->Phone)) { $valid = false; $message = _t( - __CLASS__ . '.VALIDATIONERROR_VALIDPHONE', + self::class . '.VALIDATIONERROR_VALIDPHONE', 'Please enter a valid Phone number' ); } + break; } } @@ -383,6 +353,7 @@ public function validate(): ValidationResult * Event handler called before writing to the database. * If the title is empty, set a default based on the link. */ + #[\Override] public function onBeforeWrite() { parent::onBeforeWrite(); @@ -395,12 +366,13 @@ public function onBeforeWrite() $this->Title = $this->getField($type); break; case 'SiteTree': - if(class_exists(SiteTree::class) && $this->hasMethod('SiteTree')) { + if (class_exists(SiteTree::class) && $this->hasMethod('SiteTree')) { $siteTree = $this->SiteTree(); - if($siteTree instanceof SiteTree) { + if ($siteTree instanceof SiteTree) { $this->Title = $siteTree->MenuTitle; } } + break; default: if ($this->getRelationType($type) == 'has_one' && $component = $this->getComponent($type)) { @@ -408,64 +380,41 @@ public function onBeforeWrite() } else { $this->Title = 'Link-' . $this->ID; } + break; } } } - /** - * Provides a quick way to define additional methods to provideGraphQLScaffolding as Fields - * @return Array - */ - public function gqlFields() - { - $fields = $this->config()->get('gql_fields'); - $this->extend('updateGqlFields', $fields); - $fields = array_merge(['LinkURL'], $fields); - return $fields; - } - - /** - * Provides a quick way to define additional methods to provideGraphQLScaffolding as Nested Queries - * @return Array - */ - public function gqlNestedQueries() - { - $nested = $this->config()->get('gql_nested_queries'); - $this->extend('updateGqlNestedQueries', $nested); - return $nested; - } - /** * Set CSS classes for templates * @param string $class CSS classes. - * @return Link */ - public function addExtraClass($class) + public function addExtraClass($class): static { $classes = ($class) ? explode(' ', $class) : []; - foreach ($classes as $key => $value) { + foreach ($classes as $value) { $this->classes[$value] = $value; } + return $this; } /** * This is an alias to {@link addExtraClass()} * @param string $class CSS classes. - * @return Link */ - public function setClass($class) + public function setClass($class): static { return $this->addExtraClass($class); } /** - * Set style used for + * Set style class used in the class attribute. + * This is not used as an inline style attribute. * @param string $style - * @return Link */ - public function setStyle($style) + public function setStyle($style): static { $this->template_style = $style; return $this; @@ -476,16 +425,15 @@ public function setStyle($style) */ public function getStyle(): ?string { - return $this->SelectedStyle ? $this->SelectedStyle : $this->template_style; + return $this->SelectedStyle ?: $this->template_style; } /** * Sets allowed link types * * @param array $types Allowed type names - * @return Link */ - public function setAllowedTypes($types = []) + public function setAllowedTypes($types = []): static { $this->allowed_types = $types; return $this; @@ -504,32 +452,34 @@ public function getTypes() // Prioritise local field over global settings $allowed_types = $this->allowed_types; } + if ($allowed_types) { - foreach ($allowed_types as $type) { + foreach ($allowed_types as $type) { if (!array_key_exists($type, $types)) { user_error("{$type} is not a valid link type"); } } - foreach (array_diff_key($types, array_flip($allowed_types)) as $key => $value) { + foreach (array_keys(array_diff_key($types, array_flip($allowed_types))) as $key) { unset($types[$key]); } } + $this->extend('updateTypes', $types); return $types; } /** * Returns allowed link types with translations - * @return array */ - public function geti18nTypes() + public function geti18nTypes(): array { $i18nTypes = []; // Get translatable labels foreach ($this->Types as $key => $label) { - $i18nTypes[$key] = _t(__CLASS__ . '.TYPE'.strtoupper($key), $label); + $i18nTypes[$key] = _t(self::class . '.TYPE'.strtoupper($key), $label); } + $this->extend('updatei18nTypes', $i18nTypes); return $i18nTypes; } @@ -547,18 +497,28 @@ public function getStyles() /** * Returns available styles with translations - * @return array */ - public function geti18nStyles() + public function geti18nStyles(): array { $i18nStyles = []; foreach ($this->styles as $key => $label) { - $i18nStyles[$key] = _t(__CLASS__ . '.STYLE' . strtoupper($key), $label); + $i18nStyles[$key] = _t(self::class . '.STYLE' . strtoupper($key), $label); } + $this->extend('updatei18nStyles', $i18nStyles); return $i18nStyles; } + public function getFormattedPhoneLink(): string + { + $phone = $this->obj('Phone')->PhoneFriendly(); + if ($phone instanceof Phone) { + return $phone->RFC3966()->forTemplate(); + } else { + return ''; + } + } + /** * Works out what the URL for this link should be based on it's Type */ @@ -567,6 +527,7 @@ public function getLinkURL(): ?string if (!$this->ID) { return null; } + $type = $this->Type; switch ($type) { case 'URL': @@ -576,7 +537,7 @@ public function getLinkURL(): ?string $LinkURL = $this->Email ? 'mailto:' . $this->Email : null; break; case 'Phone': - $LinkURL = $this->obj('Phone')->PhoneFriendly()->RFC3966(); + $LinkURL = $this->getFormattedPhoneLink(); break; case 'File': case 'SiteTree': @@ -584,11 +545,12 @@ public function getLinkURL(): ?string if (!$component->exists()) { $LinkURL = null; } + if ($component->hasMethod('Link')) { $LinkURL = $component->Link() . $this->Anchor; } else { $LinkURL = _t( - __CLASS__ . '.LINKMETHODMISSING', + self::class . '.LINKMETHODMISSING', 'Please implement a Link() method on your dataobject "{type}"', [ 'type' => $type @@ -596,6 +558,7 @@ public function getLinkURL(): ?string ); } } + break; default: $LinkURL = null; @@ -606,6 +569,14 @@ public function getLinkURL(): ?string return $LinkURL; } + /** + * Returns value for the template variable $LinkURL + */ + public function LinkURL(): string + { + return $this->getLinkURL(); + } + /** * Returns the css classes */ @@ -613,39 +584,65 @@ public function getClass(): string { if ($this->SelectedStyle) { $this->setClass($this->SelectedStyle); - } else if ($this->template_style) { + } elseif ($this->template_style) { $this->setClass($this->template_style); } $classes = $this->classes; $this->extend('updateClasses', $classes); - if (count($classes)) { + if ($classes !== []) { return implode(' ', $classes); } + return ''; } + /** + * Returns the html class attribute value for the template variable $Class + */ + public function Class(): string + { + return $this->getClass(); + } + /** * Returns the html class attribute */ public function getClassAttr(): string { $class = trim($this->getClass()); - if($class !== '') { + if ($class !== '') { return ' class="' . Convert::raw2htmlatt($class) . '"'; } else { return ''; } } + /** + * Returns the html class attribute for the template variable $ClassAttr + */ + public function ClassAttr(): DBHTMLText + { + // @phpstan-ignore return.type + return DBField::create_field('HTMLFragment', $this->getClassAttr()); + } + /** * Returns the html target attribute */ - public function getTarget() + public function getTarget(): string { return $this->OpenInNewWindow ? "_blank" : ''; } + /** + * Returns the html target attribute value for the template variable $Target + */ + public function Target(): string + { + return $this->getTarget(); + } + /** * Returns the html target attribute */ @@ -654,6 +651,15 @@ public function getTargetAttr(): string return $this->OpenInNewWindow ? ' target="_blank" rel="noopener"' : ''; } + /** + * Returns the html target attribute for the template variable $TargetAttr + */ + public function TargetAttr(): DBHTMLText + { + // @phpstan-ignore return.type + return DBField::create_field('HTMLFragment', $this->getTargetAttr()); + } + /** * Returns the html id attribute */ @@ -664,19 +670,36 @@ public function getIDValue(): ?string return $id; } + /** + * Returns the html id attribute value for the template variable $IDValue + */ + public function IDValue(): ?string + { + return $this->getIDValue(); + } + /** * Renders an HTML ID attribute */ public function getIDAttr(): string { $idValue = trim($this->getIDValue() ?? ''); - if($idValue !== '') { - return ' id="' . $idValue . '"'; + if ($idValue !== '') { + return ' id="' . Convert::raw2htmlatt($idValue) . '"'; } else { return ''; } } + /** + * Returns the html id attribute for the template variable $IDAttr + */ + public function IDAttr(): DBHTMLText + { + // @phpstan-ignore return.type + return DBField::create_field('HTMLFragment', $this->getIDAttr()); + } + /** * Returns the current page scope */ @@ -686,6 +709,7 @@ public function getCurrentPage() if (class_exists(SiteTree::class) && class_exists(ContentController::class) && ($currentPage instanceof ContentController)) { $currentPage = $currentPage->data(); } + return $currentPage; } @@ -786,15 +810,14 @@ public function LinkingMode() public function getTypeLabel() { $types = $this->config()->get('types'); - return isset($types[$this->Type]) ? _t(__CLASS__ . '.TYPE' . strtoupper($this->Type), $types[$this->Type]) : null; + return isset($types[$this->Type]) ? _t(self::class . '.TYPE' . strtoupper((string) $this->Type), $types[$this->Type]) : null; } /** * Returns the base class without namespacing * @param string $class - * @return string */ - public function baseClassName($class) + public function baseClassName($class): string { $class = explode('\\', $class); return array_pop($class); @@ -803,12 +826,14 @@ public function baseClassName($class) /** * Renders an HTML anchor attribute for this link */ + #[\Override] public function forTemplate(): string { $link = ''; - if ($this->LinkURL) { - $link = $this->renderWith($this->RenderTemplates); + if ($this->getLinkURL()) { + $link = $this->renderWith($this->getRenderTemplates()); } + $this->extend('updateTemplate', $link); return $link; } @@ -816,23 +841,22 @@ public function forTemplate(): string /** * Renders an HTML anchor tag for this link * This is an alias to {@link forTemplate()} - * - * @return string */ - public function getLayout() + public function getLayout(): string { return $this->forTemplate(); } /** * Returns a list of rendering templates - * @return array */ - public function getRenderTemplates() + public function getRenderTemplates(): array { $ClassName = $this->ClassName; - if (is_object($ClassName)) $ClassName = get_class($ClassName); + if (is_object($ClassName)) { + $ClassName = $ClassName::class; + } if (!is_subclass_of($ClassName, DataObject::class)) { throw new InvalidArgumentException($ClassName . ' is not a subclass of DataObject'); @@ -846,12 +870,15 @@ public function getRenderTemplates() if ($this->Style) { $templates[] = $baseClassName . '_' . $this->style; } + $templates[] = $baseClassName; if ($next == DataObject::class) { return $templates; } + $ClassName = $next; } + return []; } @@ -859,6 +886,7 @@ public function getRenderTemplates() * @param \SilverStripe\Security\Member|null $member * @return bool */ + #[\Override] public function canView($member = null) { return true; @@ -868,6 +896,7 @@ public function canView($member = null) * @param \SilverStripe\Security\Member|null $member * @return bool */ + #[\Override] public function canEdit($member = null) { return true; @@ -877,6 +906,7 @@ public function canEdit($member = null) * @param \SilverStripe\Security\Member|null $member * @return bool */ + #[\Override] public function canDelete($member = null) { return true; @@ -887,6 +917,7 @@ public function canDelete($member = null) * @param array $context * @return bool */ + #[\Override] public function canCreate($member = null, $context = []) { return true; diff --git a/src/view/Phone.php b/src/view/Phone.php index 3bbc236..bb4c43f 100644 --- a/src/view/Phone.php +++ b/src/view/Phone.php @@ -4,7 +4,6 @@ use libphonenumber\PhoneNumberFormat; use libphonenumber\PhoneNumberUtil; -use libphonenumber\PhoneNumber; use SilverStripe\Model\ModelData; /** @@ -12,7 +11,6 @@ */ class Phone extends ModelData { - protected \libphonenumber\PhoneNumberUtil $library; protected \libphonenumber\PhoneNumber $instance; @@ -22,7 +20,7 @@ class Phone extends ModelData /** * The country the user is dialing from. */ - protected string $fromCountry; + protected string $fromCountry = ''; private static string $default_country = 'NZ'; @@ -115,6 +113,7 @@ public function Render(): string } } + #[\Override] public function forTemplate(): string { return $this->Render(); diff --git a/templates/Includes/Link.ss b/templates/Includes/Link.ss index 1ae5282..4918001 100644 --- a/templates/Includes/Link.ss +++ b/templates/Includes/Link.ss @@ -1,5 +1 @@ -<% if LinkURL %> - - {$Title} - -<% end_if %> +<% if $LinkURL %>{$Title}<% end_if %> diff --git a/tests/.gitkeep b/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/EmailLinkTest.php b/tests/EmailLinkTest.php new file mode 100644 index 0000000..0ed5551 --- /dev/null +++ b/tests/EmailLinkTest.php @@ -0,0 +1,100 @@ + 'Email "> me', + 'Type' => 'Email', + 'Email' => 'test@example.com', + 'OpenInNewWindow' => false + ]); + $link->write(); + + $this->assertEquals( + 'Email "> me', + trim($link->forTemplate()) + ); + } + + public function testLinkWithTargetAttribute(): void + { + $link = Link::create([ + 'Type' => 'Email', + 'Email' => 'test@example.com', + 'OpenInNewWindow' => true + ]); + $link->write(); + + $this->assertEquals( + 'test@example.com', + trim($link->forTemplate()) + ); + } + + public function testLinkWithClassAttribute(): void + { + $link = Link::create([ + 'Type' => 'Email', + 'Email' => 'test@example.com', + 'OpenInNewWindow' => false + ]); + $link->write(); + // a style class + $link->setStyle("link-set-style"); + // a class + $link->setClass("link-set-class"); + // multi classes via extra class + $link->addExtraClass("link-extra-class-one link-extra-class-two"); + + $this->assertEquals( + 'test@example.com', + trim($link->forTemplate()) + ); + } + + public function testLinkWithEscapedClassAttribute(): void + { + $link = Link::create([ + 'Type' => 'Email', + 'Email' => 'test@example.com', + 'OpenInNewWindow' => false + ]); + $link->write(); + $link->setClass('">strongassertEquals( + 'test@example.com', + trim($link->forTemplate()) + ); + } + + public function testLinkWithAttributes(): void + { + $link = Link::create([ + 'Type' => 'Email', + 'Email' => 'test@example.com', + 'OpenInNewWindow' => true + ]); + $link->write(); + // a style class + $link->setStyle("link-set-style"); + // a class + $link->setClass("link-set-class"); + // multi classes via extra class + $link->addExtraClass("link-extra-class-one link-extra-class-two"); + + $this->assertEquals( + 'test@example.com', + trim($link->forTemplate()) + ); + } +} diff --git a/tests/FileLinkTest.php b/tests/FileLinkTest.php new file mode 100644 index 0000000..66b8706 --- /dev/null +++ b/tests/FileLinkTest.php @@ -0,0 +1,50 @@ + 'FileTest.txt', + 'FileHash' => '55b443b60176235ef09801153cca4e6da7494a0c', + 'Name' => 'FileTest.txt' + ]); + $file->setFromString(str_repeat('x', 1000000), $file->getFilename()); + $file->write(); + $file->publishFile(); + + $fileLink = $file->Link(); + $this->assertNotEmpty($fileLink); + + $link = Link::create([ + 'Title' => 'Download "> file', + 'Type' => 'File', + 'FileID' => $file->ID, + 'OpenInNewWindow' => false + ]); + $link->write(); + + $this->assertEquals( + 'Download "> file', + trim($link->forTemplate()) + ); + } + +} diff --git a/tests/PhoneLinkTest.php b/tests/PhoneLinkTest.php new file mode 100644 index 0000000..a970093 --- /dev/null +++ b/tests/PhoneLinkTest.php @@ -0,0 +1,128 @@ + 'Phone "> me', + 'Type' => 'Phone', + 'Phone' => '+6480074992488', + 'OpenInNewWindow' => false + ]); + $link->write(); + + $this->assertEquals( + 'Phone "> me', + trim($link->forTemplate()) + ); + } + + public function testLinkWithTargetAttribute(): void + { + $link = Link::create([ + 'Type' => 'Phone', + 'Phone' => '+6480074992488', + 'OpenInNewWindow' => true + ]); + $link->write(); + + $this->assertEquals( + '+6480074992488', + trim($link->forTemplate()) + ); + } + + public function testLinkWithClassAttribute(): void + { + $link = Link::create([ + 'Type' => 'Phone', + 'Phone' => '+6480074992488', + 'OpenInNewWindow' => false + ]); + $link->write(); + // a style class + $link->setStyle("link-set-style"); + // a class + $link->setClass("link-set-class"); + // multi classes via extra class + $link->addExtraClass("link-extra-class-one link-extra-class-two"); + + $this->assertEquals( + '+6480074992488', + trim($link->forTemplate()) + ); + } + + public function testLinkWithEscapedClassAttribute(): void + { + $link = Link::create([ + 'Type' => 'Phone', + 'Phone' => '+6480074992488', + 'OpenInNewWindow' => false + ]); + $link->write(); + $link->setClass('">strongassertEquals( + '+6480074992488', + trim($link->forTemplate()) + ); + } + + public function testLinkWithAttributes(): void + { + $link = Link::create([ + 'Type' => 'Phone', + 'Phone' => '+6480074992488', + 'OpenInNewWindow' => true + ]); + $link->write(); + // a style class + $link->setStyle("link-set-style"); + // a class + $link->setClass("link-set-class"); + // multi classes via extra class + $link->addExtraClass("link-extra-class-one link-extra-class-two"); + + $this->assertEquals( + '+6480074992488', + trim($link->forTemplate()) + ); + } + + public function testPhoneFormats(): void + { + $link = Link::create([ + 'Type' => 'Phone', + 'Phone' => '+6480074992488', + 'OpenInNewWindow' => false + ]); + $link->write(); + + $obj = $link->obj('Phone'); + $phoneFriendly = $obj->PhoneFriendly(); + $this->assertInstanceof(Phone::class, $phoneFriendly); + + $e164 = $phoneFriendly->E164(); + $this->assertEquals('+6480074992488', $e164->forTemplate()); + + $national = $phoneFriendly->National(); + $this->assertEquals('80074992488', $national->forTemplate()); + + $international = $phoneFriendly->International(); + $this->assertEquals('+64 80074992488', $international->forTemplate()); + + $rfc3966 = $phoneFriendly->RFC3966(); + $this->assertEquals('tel:+64-80074992488', $rfc3966->forTemplate()); + + } +} diff --git a/tests/SiteTreeLinkTest.php b/tests/SiteTreeLinkTest.php new file mode 100644 index 0000000..bf483ac --- /dev/null +++ b/tests/SiteTreeLinkTest.php @@ -0,0 +1,58 @@ +markTestSkipped( + 'The silverstripe/cms module is required to run this test.' + ); + } + } + + public function testLink(): void + { + + if (!class_exists(SiteTree::class)) { + $this->markTestSkipped( + 'The silverstripe/cms module is required to run this test.' + ); + } + + $siteTree = SiteTree::create([ + 'Title' => 'Test page', + 'URLSegment' => 'test-page', + 'ParentID' => 0 + ]); + $siteTree->write(); + $siteTree->publishSingle(); + + $siteTreeLink = $siteTree->Link(); + $this->assertNotEmpty($siteTreeLink); + + $link = Link::create([ + 'Title' => 'Visit "> page', + 'Type' => 'SiteTree', + 'SiteTreeID' => $siteTree->ID, + 'OpenInNewWindow' => false + ]); + $link->write(); + + $this->assertEquals( + 'Visit "> page', + trim($link->forTemplate()) + ); + } + +} diff --git a/tests/UrlLinkTest.php b/tests/UrlLinkTest.php new file mode 100644 index 0000000..5be9d59 --- /dev/null +++ b/tests/UrlLinkTest.php @@ -0,0 +1,100 @@ + 'Example "> link', + 'Type' => 'URL', + 'URL' => 'https://example.com', + 'OpenInNewWindow' => false + ]); + $link->write(); + + $this->assertEquals( + 'Example "> link', + trim($link->forTemplate()) + ); + } + + public function testLinkWithTargetAttribute(): void + { + $link = Link::create([ + 'Type' => 'URL', + 'URL' => 'https://example.com', + 'OpenInNewWindow' => true + ]); + $link->write(); + + $this->assertEquals( + 'https://example.com', + trim($link->forTemplate()) + ); + } + + public function testLinkWithClassAttribute(): void + { + $link = Link::create([ + 'Type' => 'URL', + 'URL' => 'https://example.com', + 'OpenInNewWindow' => false + ]); + $link->write(); + // a style class + $link->setStyle("link-set-style"); + // a class + $link->setClass("link-set-class"); + // multi classes via extra class + $link->addExtraClass("link-extra-class-one link-extra-class-two"); + + $this->assertEquals( + 'https://example.com', + trim($link->forTemplate()) + ); + } + + public function testLinkWithEscapedClassAttribute(): void + { + $link = Link::create([ + 'Type' => 'URL', + 'URL' => 'https://example.com', + 'OpenInNewWindow' => false + ]); + $link->write(); + $link->setClass('">strongassertEquals( + 'https://example.com', + trim($link->forTemplate()) + ); + } + + public function testLinkWithAttributes(): void + { + $link = Link::create([ + 'Type' => 'URL', + 'URL' => 'https://example.com', + 'OpenInNewWindow' => true + ]); + $link->write(); + // a style class + $link->setStyle("link-set-style"); + // a class + $link->setClass("link-set-class"); + // multi classes via extra class + $link->addExtraClass("link-extra-class-one link-extra-class-two"); + + $this->assertEquals( + 'https://example.com', + trim($link->forTemplate()) + ); + } +}