From c82ae1dd4382de49cbbcb83aa1f6d4bfa3aa068e Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Mon, 24 Jun 2024 15:42:58 -0700 Subject: [PATCH 01/18] Link field type --- src/fields/Link.php | 417 ++++++++++++++++++ src/fields/Url.php | 373 +--------------- src/fields/data/LinkData.php | 56 +++ src/fields/linktypes/BaseElementLinkType.php | 117 +++++ src/fields/linktypes/BaseLinkType.php | 84 ++++ src/fields/linktypes/BaseTextLinkType.php | 143 ++++++ src/fields/linktypes/Category.php | 37 ++ src/fields/linktypes/Email.php | 50 +++ src/fields/linktypes/Entry.php | 60 +++ src/fields/linktypes/Telephone.php | 54 +++ src/fields/linktypes/Url.php | 49 ++ src/services/Fields.php | 4 +- src/translations/en/app.php | 3 +- src/web/assets/cp/dist/cp.js | 2 +- src/web/assets/cp/dist/cp.js.map | 2 +- src/web/assets/cp/dist/css/cp.css | 2 +- src/web/assets/cp/dist/css/cp.css.map | 2 +- src/web/assets/cp/src/Craft.js | 1 + src/web/assets/cp/src/css/_main.scss | 69 +++ .../cp/src/js/BaseElementSelectInput.js | 35 +- src/web/assets/cp/src/js/Craft.js | 62 ++- src/web/assets/cp/src/js/LinkInput.js | 200 +++++++++ src/web/assets/cp/src/js/UI.js | 38 ++ 23 files changed, 1460 insertions(+), 400 deletions(-) create mode 100644 src/fields/Link.php create mode 100644 src/fields/data/LinkData.php create mode 100644 src/fields/linktypes/BaseElementLinkType.php create mode 100644 src/fields/linktypes/BaseLinkType.php create mode 100644 src/fields/linktypes/BaseTextLinkType.php create mode 100644 src/fields/linktypes/Category.php create mode 100644 src/fields/linktypes/Email.php create mode 100644 src/fields/linktypes/Entry.php create mode 100644 src/fields/linktypes/Telephone.php create mode 100644 src/fields/linktypes/Url.php create mode 100644 src/web/assets/cp/src/js/LinkInput.js diff --git a/src/fields/Link.php b/src/fields/Link.php new file mode 100644 index 00000000000..f4070f916c3 --- /dev/null +++ b/src/fields/Link.php @@ -0,0 +1,417 @@ + + * @since 5.3.0 + */ +class Link extends Field implements InlineEditableFieldInterface +{ + /** + * @event DefineLinkOptionsEvent The event that is triggered when registering the link types for Link fields. + * @see types() + */ + public const EVENT_REGISTER_LINK_TYPES = 'registerLinkTypes'; + + /** @deprecated in 5.3.0 */ + public const TYPE_URL = 'url'; + /** @deprecated in 5.3.0 */ + public const TYPE_TEL = 'tel'; + /** @deprecated in 5.3.0 */ + public const TYPE_EMAIL = 'email'; + + /** + * @inheritdoc + */ + public static function displayName(): string + { + return Craft::t('app', 'Link'); + } + + /** + * @inheritdoc + */ + public static function icon(): string + { + return 'link'; + } + + /** + * @inheritdoc + */ + public static function phpType(): string + { + return 'string|null'; + } + + /** + * @inheritdoc + */ + public static function dbType(): string + { + return Schema::TYPE_STRING; + } + + /** + * @return array + * @phpstan-return array> + */ + private static function types(): array + { + if (!isset(self::$_types)) { + /** @var array $types */ + /** @phpstan-var class-string[] $types */ + $types = [ + Category::class, + EmailType::class, + Entry::class, + Telephone::class, + ]; + + // Fire a registerLinkTypes event + if (Event::hasHandlers(self::class, self::EVENT_REGISTER_LINK_TYPES)) { + $event = new RegisterComponentTypesEvent([ + 'types' => $types, + ]); + Event::trigger(self::class, self::EVENT_REGISTER_LINK_TYPES, $event); + $types = $event->types; + } + + // URL *has* to be there + $types[] = UrlType::class; + + self::$_types = array_combine( + array_map(fn(string $type) => $type::id(), $types), + $types, + ); + } + + return self::$_types; + } + + private static function resolveType(string $value): string + { + foreach (self::types() as $id => $type) { + if ($id !== 'url' && $type::supports($value)) { + return $id; + } + } + + return 'url'; + } + + private static array $_types; + + /** + * @var string[] Allowed link types + */ + public array $types = [ + 'entry', + 'url', + ]; + + /** + * @var int The maximum length (in bytes) the field can hold + */ + public int $maxLength = 255; + + /** + * @inheritdoc + */ + public function __construct($config = []) + { + if (array_key_exists('placeholder', $config)) { + unset($config['placeholder']); + } + + parent::__construct($config); + } + + /** + * @inheritdoc + */ + public function fields(): array + { + $fields = parent::fields(); + unset($fields['placeholder']); + return $fields; + } + + /** + * @inheritdoc + */ + protected function defineRules(): array + { + $rules = parent::defineRules(); + $rules[] = [['types'], ArrayValidator::class]; + $rules[] = [['types', 'maxLength'], 'required']; + $rules[] = [['maxLength'], 'number', 'integerOnly' => true, 'min' => 10]; + return $rules; + } + + /** + * @inheritdoc + */ + public function getSettingsHtml(): ?string + { + $linkTypeOptions = array_map(fn(string $type) => [ + 'label' => $type::label(), + 'value' => $type::id(), + ], self::types()); + + // Sort them by label, with URL at the top + $urlOption = $linkTypeOptions['url']; + unset($linkTypeOptions['url']); + usort($linkTypeOptions, fn(array $a, array $b) => $a['label'] <=> $b['label']); + $linkTypeOptions = [$urlOption, ...$linkTypeOptions]; + + return + Cp::checkboxSelectFieldHtml([ + 'label' => Craft::t('app', 'Allowed Link Types'), + 'id' => 'types', + 'name' => 'types', + 'options' => $linkTypeOptions, + 'values' => $this->types, + 'required' => true, + ]) . + Cp::textFieldHtml([ + 'label' => Craft::t('app', 'Max Length'), + 'instructions' => Craft::t('app', 'The maximum length (in bytes) the field can hold.'), + 'id' => 'maxLength', + 'name' => 'maxLength', + 'type' => 'number', + 'min' => '10', + 'step' => '10', + 'value' => $this->maxLength, + 'errors' => $this->getErrors('maxLength'), + 'data' => ['error-key' => 'maxLength'], + ]); + } + + /** + * @inheritdoc + */ + public function normalizeValue(mixed $value, ?ElementInterface $element): mixed + { + if ($value instanceof LinkData) { + return $value; + } + + $types = self::types(); + + if (is_array($value)) { + $typeId = $value['type'] ?? 'url'; + $value = trim($value[$typeId]['value'] ?? ''); + + if (!isset($types[$typeId])) { + throw new InvalidArgumentException("Invalid link type: $typeId"); + } + + if (!$value) { + return null; + } + + $type = $types[$typeId]; + $value = $type::normalize(str_replace(' ', '+', $value)); + } else { + if (!$value) { + return null; + } + + $typeId = self::resolveType($value); + $type = $types[$typeId]; + } + + return new LinkData($value, $type); + } + + /** + * @inheritdoc + */ + public function useFieldset(): bool + { + return count($this->types) > 1; + } + + /** + * @inheritdoc + */ + protected function inputHtml(mixed $value, ?ElementInterface $element, bool $inline): string + { + $types = self::types(); + /** @var LinkData|null $value */ + /** @var BaseLinkType|string|null $type */ + /** @phpstan-var class-string|null $type */ + $type = $types[$value?->type]; + $valueTypeId = $type ? $type::id() : 'url'; + $allowedTypeIds = in_array($valueTypeId, $this->types) ? $this->types : array_merge($this->types, [$valueTypeId]); + $allowedTypeIds = array_filter($allowedTypeIds, fn(string $typeId) => isset($types[$typeId])); + $id = $this->getInputId(); + + $view = Craft::$app->getView(); + + if (!$value) { + // Override the initial value being set to null by CustomField::inputHtml() + $view->setInitialDeltaValue($this->handle, [ + 'type' => $valueTypeId, + 'value' => '', + ]); + } + + $typeInputName = "$this->handle[type]"; + + if (count($allowedTypeIds) === 1) { + $innerHtml = Html::hiddenInput($typeInputName, $valueTypeId); + $hasSelect = false; + } else { + $namespacedId = $view->namespaceInputId($id); + $js = << { + const type = $('#$namespacedId-type').val(); + $('#$namespacedId') + .attr('type', type) + .attr('inputmode', type); +}); +JS; + $view->registerJs($js); + + $innerHtml = Cp::selectHtml([ + 'id' => "$id-type", + 'describedBy' => $this->describedBy, + 'name' => $typeInputName, + 'options' => array_map(fn(string $typeId) => [ + 'label' => $types[$typeId]::label(), + 'value' => $types[$typeId]::id(), + ], $allowedTypeIds), + 'value' => $valueTypeId, + 'inputAttributes' => [ + 'aria' => [ + 'label' => Craft::t('app', 'URL type'), + ], + ], + 'toggle' => true, + 'targetPrefix' => "$id-", + ]); + + $hasSelect = true; + } + + foreach ($allowedTypeIds as $typeId) { + $containerId = "$id-$typeId"; + $nsContainerId = $view->namespaceInputId($containerId); + $selected = $typeId === $valueTypeId; + $typeValue = $selected ? $value?->serialize() : null; + $isTextLink = is_subclass_of($types[$typeId], BaseTextLinkType::class); + $innerHtml .= + Html::beginTag('div', [ + 'id' => $containerId, + 'class' => array_keys(array_filter([ + 'flex-grow' => true, + 'hidden' => !$selected, + 'text-link' => $isTextLink, + ])), + ]) . + $view->namespaceInputs( + fn() => $types[$typeId]::inputHtml($this, $typeValue, $nsContainerId), + "$this->handle[$typeId]", + ) . + Html::endTag('div'); + } + + return + Html::beginTag('div', [ + 'id' => $id, + 'class' => array_keys(array_filter([ + 'link-input' => true, + 'has-link-type-select' => $hasSelect, + ])), + ]) . + Html::tag('div', $innerHtml, [ + 'class' => ['flex', 'flex-nowrap'], + ]) . + Html::endTag('div'); + } + + /** + * @inheritdoc + */ + public function getElementValidationRules(): array + { + return [ + [ + function(ElementInterface $element) { + /** @var LinkData $value */ + $value = $element->getFieldValue($this->handle); + $types = self::types(); + /** @var BaseLinkType|string $type */ + /** @phpstan-var class-string $type */ + $type = $types[$value->type]; + $error = null; + if (!$type::validate($value->serialize(), $error)) { + /** @var string|null $error */ + $element->addError("field:$this->handle", $error ?? Craft::t('yii', '{attribute} is invalid.', [ + 'attribute' => $this->getUiLabel(), + ])); + return; + } + + $stringValidator = new StringValidator(['max' => $this->maxLength]); + if (!$stringValidator->validate($value->serialize(), $error)) { + $element->addError("field:$this->handle", $error); + } + }, + ], + ]; + } + + /** + * @inheritdoc + */ + public function getElementConditionRuleType(): array|string|null + { + return TextFieldConditionRule::class; + } + + /** + * @inheritdoc + */ + public function getPreviewHtml(mixed $value, ElementInterface $element): string + { + /** @var LinkData|null $value */ + if (!$value) { + return ''; + } + $value = Html::encode((string)$value); + return "$value"; + } +} diff --git a/src/fields/Url.php b/src/fields/Url.php index 1e39746ed0e..968cec7a0b9 100644 --- a/src/fields/Url.php +++ b/src/fields/Url.php @@ -7,374 +7,15 @@ namespace craft\fields; -use Craft; -use craft\base\ElementInterface; -use craft\base\Field; -use craft\base\InlineEditableFieldInterface; -use craft\fields\conditions\TextFieldConditionRule; -use craft\helpers\Cp; -use craft\helpers\Html; -use craft\helpers\StringHelper; -use craft\helpers\UrlHelper; -use craft\validators\ArrayValidator; -use craft\validators\UrlValidator; -use yii\base\InvalidConfigException; -use yii\base\InvalidValueException; -use yii\db\Schema; -use yii\validators\EmailValidator; - -/** - * Url represents a URL field. - * - * @author Pixel & Tonic, Inc. - * @since 3.0.0 - */ -class Url extends Field implements InlineEditableFieldInterface -{ - /** - * @since 3.6.0 - */ - public const TYPE_URL = 'url'; - /** - * @since 3.6.0 - */ - public const TYPE_TEL = 'tel'; - /** - * @since 3.6.0 - */ - public const TYPE_EMAIL = 'email'; - - /** - * @inheritdoc - */ - public static function displayName(): string - { - return Craft::t('app', 'URL'); - } - - /** - * @inheritdoc - */ - public static function icon(): string - { - return 'link'; - } - - /** - * @inheritdoc - */ - public static function phpType(): string - { - return 'string|null'; - } - - /** - * @inheritdoc - */ - public static function dbType(): string - { - return Schema::TYPE_STRING; - } - - /** - * @var string[] Allowed URL types - * @since 3.6.0 - */ - public array $types = [ - self::TYPE_URL, - ]; - - /** - * @var int The maximum length (in bytes) the field can hold - */ - public int $maxLength = 255; - - /** - * @inheritdoc - */ - public function __construct($config = []) - { - if (array_key_exists('placeholder', $config)) { - unset($config['placeholder']); - } - - parent::__construct($config); - } - - /** - * @inheritdoc - */ - public function fields(): array - { - $fields = parent::fields(); - unset($fields['placeholder']); - return $fields; - } - - /** - * @inheritdoc - */ - protected function defineRules(): array - { - $rules = parent::defineRules(); - $rules[] = [['types'], ArrayValidator::class]; - $rules[] = [['types', 'maxLength'], 'required']; - $rules[] = [['maxLength'], 'number', 'integerOnly' => true, 'min' => 10]; - return $rules; - } - - /** - * @inheritdoc - */ - public function getSettingsHtml(): ?string - { - return - Cp::checkboxSelectFieldHtml([ - 'label' => Craft::t('app', 'Allowed URL Types'), - 'id' => 'types', - 'name' => 'types', - 'options' => [ - ['label' => Craft::t('app', 'Web page'), 'value' => self::TYPE_URL], - ['label' => Craft::t('app', 'Telephone'), 'value' => self::TYPE_TEL], - ['label' => Craft::t('app', 'Email'), 'value' => self::TYPE_EMAIL], - ], - 'values' => $this->types, - 'required' => true, - ]) . - Cp::textFieldHtml([ - 'label' => Craft::t('app', 'Max Length'), - 'instructions' => Craft::t('app', 'The maximum length (in bytes) the field can hold.'), - 'id' => 'maxLength', - 'name' => 'maxLength', - 'type' => 'number', - 'min' => '10', - 'step' => '10', - 'value' => $this->maxLength, - 'errors' => $this->getErrors('maxLength'), - 'data' => ['error-key' => 'maxLength'], - ]); - } - - /** - * @inheritdoc - */ - public function normalizeValue(mixed $value, ?ElementInterface $element): mixed - { - if (is_array($value) && isset($value['value'])) { - $type = $value['type'] ?? self::TYPE_URL; - $value = trim($value['value']); - - if ($value) { - switch ($type) { - case self::TYPE_TEL: - $value = str_replace(' ', '-', $value); - $value = StringHelper::ensureLeft($value, 'tel:'); - break; - case self::TYPE_EMAIL: - $value = StringHelper::ensureLeft($value, 'mailto:'); - break; - case self::TYPE_URL: - if (!UrlHelper::isFullUrl($value)) { - $value = StringHelper::ensureLeft($value, 'http://'); - } - break; - default: - throw new InvalidValueException("Invalid URL type: $type"); - } - } - } - - if (!$value) { - return null; - } - - return str_replace(' ', '+', $value); - } - +/** @phpstan-ignore-next-line */ +if (false) { /** - * @inheritdoc + * @since 3.0.0 + * @deprecated in 5.3.0 */ - public function useFieldset(): bool + class Url { - return count($this->types) > 1; - } - - /** - * @inheritdoc - */ - protected function inputHtml(mixed $value, ?ElementInterface $element, bool $inline): string - { - if (is_string($value)) { - $valueType = $this->_urlType($value); - } else { - $valueType = self::TYPE_URL; - } - - if (!in_array($valueType, $this->types, true)) { - $valueType = reset($this->types); - } - - $id = $this->getInputId(); - $typeOptions = []; - - foreach ($this->types as $type) { - switch ($type) { - case self::TYPE_URL: - $label = Craft::t('app', 'Web page'); - $prefix = null; - break; - case self::TYPE_TEL: - $label = Craft::t('app', 'Telephone'); - $prefix = 'tel:'; - break; - case self::TYPE_EMAIL: - $label = Craft::t('app', 'Email'); - $prefix = 'mailto:'; - break; - default: - throw new InvalidConfigException("Invalid URL type: $type"); - } - - $typeOptions[] = ['label' => $label, 'value' => $type]; - - if (is_string($value) && $type === $valueType && $prefix) { - $value = StringHelper::removeLeft($value, $prefix); - } - } - - $input = Craft::$app->getView()->renderTemplate('_includes/forms/text.twig', [ - 'id' => $id, - 'describedBy' => $this->describedBy, - 'class' => ['flex-grow', 'fullwidth'], - 'type' => $valueType, - 'name' => "$this->handle[value]", - 'inputmode' => $valueType, - 'value' => $value, - 'inputAttributes' => [ - 'aria' => [ - 'label' => Craft::t('site', $this->name), - ], - ], - ]); - - $view = Craft::$app->getView(); - - if ($value === null) { - // Override the initial value being set to null by CustomField::inputHtml() - $view->setInitialDeltaValue($this->handle, [ - 'type' => $valueType, - 'value' => '', - ]); - } - - if (count($this->types) === 1) { - return - Html::hiddenInput("$this->handle[type]", $valueType) . - $input; - } - - $namespacedId = $view->namespaceInputId($id); - $js = << { - const type = $('#$namespacedId-type').val(); - $('#$namespacedId') - .attr('type', type) - .attr('inputmode', type); -}); -JS; - $view->registerJs($js); - - return Html::tag( - 'div', - Cp::selectHtml([ - 'id' => "$id-type", - 'describedBy' => $this->describedBy, - 'name' => "$this->handle[type]", - 'options' => $typeOptions, - 'value' => $valueType, - 'inputAttributes' => [ - 'aria' => [ - 'label' => Craft::t('app', 'URL type'), - ], - ], - ]) . - $input, - [ - 'class' => ['flex', 'flex-nowrap'], - ] - ); - } - - /** - * @inheritdoc - */ - public function getElementValidationRules(): array - { - $patterns = []; - - foreach ($this->types as $type) { - switch ($type) { - case self::TYPE_URL: - $patterns[] = UrlValidator::URL_PATTERN; - break; - case self::TYPE_TEL: - // * and # characters are not allowed by iOS - // see https://developer.apple.com/library/archive/featuredarticles/iPhoneURLScheme_Reference/PhoneLinks/PhoneLinks.html - $patterns[] = '^tel:[\d\+\(\)\-,;]+$'; - break; - case self::TYPE_EMAIL: - $emailPattern = trim((new EmailValidator())->pattern, '/^$'); - $patterns[] = "^mailto:$emailPattern(\?.*)?$"; - break; - } - } - - return [ - ['trim'], - [ - UrlValidator::class, - 'pattern' => '/' . implode('|', $patterns) . '/i', - ], - ['string', 'max' => $this->maxLength], - ]; - } - - /** - * @inheritdoc - */ - public function getElementConditionRuleType(): array|string|null - { - return TextFieldConditionRule::class; - } - - /** - * @inheritdoc - */ - public function getPreviewHtml(mixed $value, ElementInterface $element): string - { - if (!$value) { - return ''; - } - $value = Html::encode($value); - return "$value"; - } - - /** - * Returns what type of URL a given value is. - * - * @param string $value - * @return string - */ - private function _urlType(string $value): string - { - if (str_starts_with($value, 'tel:')) { - return self::TYPE_TEL; - } - - if (str_starts_with($value, 'mailto:')) { - return self::TYPE_EMAIL; - } - - return self::TYPE_URL; } } + +class_alias(Link::class, Url::class); diff --git a/src/fields/data/LinkData.php b/src/fields/data/LinkData.php new file mode 100644 index 00000000000..e33a0a65c02 --- /dev/null +++ b/src/fields/data/LinkData.php @@ -0,0 +1,56 @@ + + * @since 5.3.0 + */ +class LinkData extends BaseObject implements Serializable +{ + private string $renderedValue; + + /** + * @param string $value + * @param string $type + * @phpstan-param class-string $type + */ + public function __construct( + private readonly string $value, + private readonly string $type, + ) { + } + + public function __toString(): string + { + if (!isset($this->renderedValue)) { + /** @var BaseLinkType|string $type */ + /** @phpstan-var class-string $type */ + $type = $this->type; + $this->renderedValue = $type::render($this->value); + } + return $this->renderedValue; + } + + public function getType(): string + { + return $this->type::id(); + } + + public function serialize(): mixed + { + return $this->value; + } +} diff --git a/src/fields/linktypes/BaseElementLinkType.php b/src/fields/linktypes/BaseElementLinkType.php new file mode 100644 index 00000000000..d2ce3cc629a --- /dev/null +++ b/src/fields/linktypes/BaseElementLinkType.php @@ -0,0 +1,117 @@ + + * @since 5.3.0 + */ +abstract class BaseElementLinkType extends BaseLinkType +{ + /** + * Returns the element type this link type is for. + * + * @return ElementInterface|string + * @phpstan-return class-string + */ + abstract protected static function elementType(): string; + + public static function id(): string + { + return sprintf('element-%s', static::elementType()::refHandle()); + } + + public static function label(): string + { + return static::elementType()::displayName(); + } + + public static function supports(string $value): bool + { + return (bool)preg_match(sprintf('/^\{%s:(\d+):url\}$/', static::elementType()::refHandle()), $value); + } + + public static function normalize(string $value): string + { + if (is_numeric($value)) { + return sprintf('{%s:%s:url}', static::elementType()::refHandle(), $value); + } + + return $value; + } + + public static function render(string $value): string + { + return Craft::$app->getElements()->parseRefs($value); + } + + public static function inputHtml(Link $field, ?string $value, string $containerId): string + { + $elements = []; + + if ($value && preg_match(sprintf('/^\{%s:(\d+):url\}$/', static::elementType()::refHandle()), $value, $match)) { + $id = $match[1]; + $element = static::elementType()::find() + ->id($id) + ->status(null) + ->drafts(null) + ->revisions(null) + ->one(); + if ($element) { + $elements[] = $element; + } + } + + return Cp::elementSelectHtml([ + 'name' => 'value', + 'elementType' => static::elementType(), + 'limit' => 1, + 'single' => true, + 'elements' => $elements, + 'sources' => array_merge(static::selectionSources(), self::customSources()), + 'criteria' => static::selectionCriteria(), + ]); + } + + protected static function selectionSources(): array + { + return []; + } + + private static function customSources(): array + { + $customSources = []; + $elementSources = Craft::$app->getElementSources()->getSources(static::elementType(), 'modal'); + foreach ($elementSources as $elementSource) { + if ($elementSource['type'] === ElementSources::TYPE_CUSTOM && isset($elementSource['key'])) { + $customSources[] = $elementSource['key']; + } + } + return $customSources; + } + + protected static function selectionCriteria(): array + { + return [ + 'uri' => 'not :empty:', + ]; + } + + public static function validate(string $value, ?string &$error = null): bool + { + return true; + } +} diff --git a/src/fields/linktypes/BaseLinkType.php b/src/fields/linktypes/BaseLinkType.php new file mode 100644 index 00000000000..0916c36d12d --- /dev/null +++ b/src/fields/linktypes/BaseLinkType.php @@ -0,0 +1,84 @@ + + * @since 5.3.0 + */ +abstract class BaseLinkType extends BaseObject +{ + /** + * Returns the link type’s unique identifier, which will be stored within + * Link fields’ [[\craft\fields\Link::types]] settings. + * + * @return string + */ + abstract public static function id(): string; + + /** + * Returns the link type’s human-facing label. + * + * @return string + */ + abstract public static function label(): string; + + /** + * Returns whether the given value is supported by this link type. + * + * @param string $value + * @return bool + */ + abstract public static function supports(string $value): bool; + + /** + * Normalizes a posted link value. + * + * @param string $value + * @return string + */ + public static function normalize(string $value): string + { + return $value; + } + + /** + * Renders a value for the front end. + * + * @param string $value + * @return string + */ + public static function render(string $value): string + { + return $value; + } + + /** + * Returns the input HTML that should be shown when this link type is selected. + * + * @param Link $field The Link field + * @param string|null $value The current value, if this link type was previously selected. + * @param string $containerId The ID of the input’s container div. + * @return string + */ + abstract public static function inputHtml(Link $field, ?string $value, string $containerId): string; + + /** + * Validates the given value. + * + * @param string $value + * @param string|null $error + * @return bool + */ + abstract public static function validate(string $value, ?string &$error = null): bool; +} diff --git a/src/fields/linktypes/BaseTextLinkType.php b/src/fields/linktypes/BaseTextLinkType.php new file mode 100644 index 00000000000..89703096a84 --- /dev/null +++ b/src/fields/linktypes/BaseTextLinkType.php @@ -0,0 +1,143 @@ + + * @since 5.3.0 + */ +abstract class BaseTextLinkType extends BaseLinkType +{ + /** + * Returns the prefix(es) that supported URLs must start with. + * + * @return string|string[] + */ + abstract protected static function urlPrefix(): string|array; + + public static function supports(string $value): bool + { + $value = mb_strtolower($value); + foreach ((array)static::urlPrefix() as $prefix) { + if (str_starts_with($value, $prefix)) { + return true; + } + } + return false; + } + + public static function normalize(string $value): string + { + if (static::supports($value)) { + return $value; + } + + // Only add a prefix if the end result validates + $prefix = ArrayHelper::firstValue((array)static::urlPrefix()); + $normalized = "$prefix$value"; + return static::validate($normalized) ? $normalized : $value; + } + + public static function inputHtml(Link $field, ?string $value, string $containerId): string + { + $name = 'value'; + $textInputAttributes = array_merge([ + 'describedBy' => $field->describedBy, + 'class' => ['fullwidth', 'text-link-input'], + 'textInputAttributes' => [ + 'aria' => [ + 'label' => Craft::t('site', $field->name), + ], + ], + ], static::inputAttributes()); + + $view = Craft::$app->getView(); + $view->registerJsWithVars(fn($id, $settings) => << { + new Craft.LinkInput('#' + $id, $settings); +})(); +JS, [ + $containerId, + [ + 'prefixes' => (array)static::urlPrefix(), + 'pattern' => static::pattern(), + 'inputAttributes' => $textInputAttributes, + ], + ]); + + if ($value && static::validate($value)) { + $linkText = $value; + foreach ((array)static::urlPrefix() as $prefix) { + $linkText = StringHelper::removeLeft($linkText, $prefix); + } + $html = + Html::beginTag('div', [ + 'class' => ['chip', 'small'], + ]) . + Html::beginTag('div', [ + 'class' => 'chip-content', + ]) . + Html::a($linkText, $value, [ + 'target' => '_blank', + ]) . + Html::endTag('div') . // .chip-content + Cp::disclosureMenu([], [ + 'omitIfEmpty' => false, + 'hiddenLabel' => Craft::t('app', 'Actions'), + 'buttonAttributes' => [ + 'class' => ['action-btn'], + 'removeClass' => 'menubtn', + 'data' => ['icon' => 'ellipsis'], + ], + ]) . + Html::endTag('div'); // .chip; + } else { + $html = Cp::textHtml(array_merge($textInputAttributes, [ + 'value' => $value, + ])); + } + + return $html . Html::hiddenInput($name, $value); + } + + /** + * Returns any additional attributes that should be set ot the text input. + * + * @return array + */ + protected static function inputAttributes(): array + { + return []; + } + + public static function validate(string $value, ?string &$error = null): bool + { + $pattern = sprintf('/%s/i', static::pattern()); + return (bool)preg_match($pattern, $value); + } + + /** + * Returns the regular expression pattern (sans delimiters) that should be used to validate link values. + * + * @return string + */ + protected static function pattern(): string + { + $prefixes = array_map(fn(string $prefix) => preg_quote($prefix, '/'), (array)static::urlPrefix()); + return sprintf('^(%s)', implode('|', $prefixes)); + } +} diff --git a/src/fields/linktypes/Category.php b/src/fields/linktypes/Category.php new file mode 100644 index 00000000000..743e64e96af --- /dev/null +++ b/src/fields/linktypes/Category.php @@ -0,0 +1,37 @@ + + * @since 5.3.0 + */ +abstract class Category extends BaseElementLinkType +{ + protected static function elementType(): string + { + return CategoryElement::class; + } + + protected static function selectionSources(): array + { + return Collection::make(Craft::$app->getCategories()->getAllGroups()) + ->filter(fn(CategoryGroup $group) => $group->getSiteSettings()[Cp::requestedSite()->id]?->hasUrls ?? false) + ->map(fn(CategoryGroup $group) => "group:$group->uid") + ->values() + ->all(); + } +} diff --git a/src/fields/linktypes/Email.php b/src/fields/linktypes/Email.php new file mode 100644 index 00000000000..e2ffcf9abd9 --- /dev/null +++ b/src/fields/linktypes/Email.php @@ -0,0 +1,50 @@ + + * @since 5.3.0 + */ +abstract class Email extends BaseTextLinkType +{ + public static function id(): string + { + return 'email'; + } + + public static function label(): string + { + return Craft::t('app', 'Email'); + } + + protected static function urlPrefix(): string|array + { + return 'mailto:'; + } + + protected static function inputAttributes(): array + { + return [ + 'type' => 'email', + 'inputmode' => 'email', + ]; + } + + protected static function pattern(): string + { + $emailPattern = trim((new EmailValidator())->pattern, '/^$'); + return "^mailto:$emailPattern(\?.*)?$"; + } +} diff --git a/src/fields/linktypes/Entry.php b/src/fields/linktypes/Entry.php new file mode 100644 index 00000000000..2e9b4549d2c --- /dev/null +++ b/src/fields/linktypes/Entry.php @@ -0,0 +1,60 @@ + + * @since 5.3.0 + */ +abstract class Entry extends BaseElementLinkType +{ + protected static function elementType(): string + { + return EntryElement::class; + } + + protected static function selectionSources(): array + { + $sources = []; + $sections = Craft::$app->getEntries()->getAllSections(); + $sites = Craft::$app->getSites()->getAllSites(); + $showSingles = false; + + foreach ($sections as $section) { + if ($section->type === Section::TYPE_SINGLE) { + $showSingles = true; + } else { + $sectionSiteSettings = $section->getSiteSettings(); + foreach ($sites as $site) { + if (isset($sectionSiteSettings[$site->id]) && $sectionSiteSettings[$site->id]->hasUrls) { + $sources[] = "section:$section->uid"; + break; + } + } + } + } + + $sources = array_values(array_unique($sources)); + + if ($showSingles) { + array_unshift($sources, 'singles'); + } + + if (!empty($sources)) { + array_unshift($sources, '*'); + } + + return $sources; + } +} diff --git a/src/fields/linktypes/Telephone.php b/src/fields/linktypes/Telephone.php new file mode 100644 index 00000000000..6e6563af22e --- /dev/null +++ b/src/fields/linktypes/Telephone.php @@ -0,0 +1,54 @@ + + * @since 5.3.0 + */ +abstract class Telephone extends BaseTextLinkType +{ + public static function id(): string + { + return 'tel'; + } + + public static function label(): string + { + return Craft::t('app', 'Telephone'); + } + + protected static function urlPrefix(): string|array + { + return 'tel:'; + } + + public static function normalize(string $value): string + { + $value = str_replace(' ', '-', $value); + return parent::normalize($value); + } + + protected static function inputAttributes(): array + { + return [ + 'type' => 'tel', + 'inputmode' => 'tel', + ]; + } + + protected static function pattern(): string + { + return "^tel:[\d\+\(\)\-,;]+$"; + } +} diff --git a/src/fields/linktypes/Url.php b/src/fields/linktypes/Url.php new file mode 100644 index 00000000000..f9cfb5cc489 --- /dev/null +++ b/src/fields/linktypes/Url.php @@ -0,0 +1,49 @@ + + * @since 5.3.0 + */ +abstract class Url extends BaseTextLinkType +{ + public static function id(): string + { + return 'url'; + } + + public static function label(): string + { + return Craft::t('app', 'URL'); + } + + protected static function urlPrefix(): array + { + return ['https://', 'http://']; + } + + protected static function inputAttributes(): array + { + return [ + 'type' => 'url', + 'inputmode' => 'url', + ]; + } + + protected static function pattern(): string + { + return str_replace(UrlValidator::URL_PATTERN, '{schemes}', '(https|http)'); + } +} diff --git a/src/services/Fields.php b/src/services/Fields.php index 075e5525743..1b846fa832a 100644 --- a/src/services/Fields.php +++ b/src/services/Fields.php @@ -38,6 +38,7 @@ use craft\fields\Entries as EntriesField; use craft\fields\Icon; use craft\fields\Lightswitch; +use craft\fields\Link; use craft\fields\Matrix as MatrixField; use craft\fields\MissingField; use craft\fields\Money; @@ -48,7 +49,6 @@ use craft\fields\Table as TableField; use craft\fields\Tags as TagsField; use craft\fields\Time; -use craft\fields\Url; use craft\fields\Users as UsersField; use craft\helpers\AdminTable; use craft\helpers\ArrayHelper; @@ -222,6 +222,7 @@ public function getAllFieldTypes(): array EntriesField::class, Icon::class, Lightswitch::class, + Link::class, MatrixField::class, Money::class, MultiSelect::class, @@ -231,7 +232,6 @@ public function getAllFieldTypes(): array TableField::class, TagsField::class, Time::class, - Url::class, UsersField::class, ]; diff --git a/src/translations/en/app.php b/src/translations/en/app.php index bf5fabaf7d9..6b04441f114 100644 --- a/src/translations/en/app.php +++ b/src/translations/en/app.php @@ -98,7 +98,7 @@ 'Allow self relations' => 'Allow self relations', 'Allow subfolders' => 'Allow subfolders', 'Allow uploading directly to the field' => 'Allow uploading directly to the field', - 'Allowed URL Types' => 'Allowed URL Types', + 'Allowed Link Types' => 'Allowed Link Types', 'Allows creating drafts of new {type}.' => 'Allows creating drafts of new {type}.', 'Allows fully saving canonical {type} (directly or by applying drafts).' => 'Allows fully saving canonical {type} (directly or by applying drafts).', 'Allows viewing existing {type} and creating drafts for them.' => 'Allows viewing existing {type} and creating drafts for them.', @@ -1854,7 +1854,6 @@ 'Volumes' => 'Volumes', 'Warning' => 'Warning', 'Warning:' => 'Warning:', - 'Web page' => 'Web page', 'Website' => 'Website', 'Week Start Day' => 'Week Start Day', 'What category URIs should look like for the site.' => 'What category URIs should look like for the site.', diff --git a/src/web/assets/cp/dist/cp.js b/src/web/assets/cp/dist/cp.js index 786bd6e4891..ad0e98e8419 100644 --- a/src/web/assets/cp/dist/cp.js +++ b/src/web/assets/cp/dist/cp.js @@ -1,3 +1,3 @@ /*! For license information please see cp.js.LICENSE.txt */ -(function(){var __webpack_modules__={463:function(){Craft.Accordion=Garnish.Base.extend({$trigger:null,targetSelector:null,_$target:null,init:function(t){var e=this;this.$trigger=$(t),this.$trigger.data("accordion")&&(console.warn("Double-instantiating an accordion trigger on an element"),this.$trigger.data("accordion").destroy()),this.$trigger.data("accordion",this),this.targetSelector=this.$trigger.attr("aria-controls")?"#".concat(this.$trigger.attr("aria-controls")):null,this.targetSelector&&(this._$target=$(this.targetSelector)),this.addListener(this.$trigger,"click","onTriggerClick"),this.addListener(this.$trigger,"keypress",(function(t){var n=t.keyCode;n!==Garnish.SPACE_KEY&&n!==Garnish.RETURN_KEY||(t.preventDefault(),e.onTriggerClick())}))},onTriggerClick:function(){"true"===this.$trigger.attr("aria-expanded")?this.hideTarget(this._$target):this.showTarget(this._$target)},showTarget:function(t){var e=this;if(t&&t.length){this.showTarget._currentHeight=t.height(),t.removeClass("hidden"),this.$trigger.removeClass("collapsed").addClass("expanded").attr("aria-expanded","true");for(var n=0;n=this.settings.maxItems)){var e=$(t).appendTo(this.$tbody),n=e.find(".delete");this.settings.sortable&&this.sorter.addItems(e),this.$deleteBtns=this.$deleteBtns.add(n),this.addListener(n,"click","handleDeleteBtnClick"),this.totalItems++,this.updateUI()}},reorderItems:function(){var t=this;if(this.settings.sortable){for(var e=[],n=0;n=this.settings.maxItems?$(this.settings.newItemBtnSelector).addClass("hidden"):$(this.settings.newItemBtnSelector).removeClass("hidden"))}},{defaults:{tableSelector:null,noItemsSelector:null,newItemBtnSelector:null,idAttribute:"data-id",nameAttribute:"data-name",sortable:!1,allowDeleteAll:!0,minItems:0,maxItems:null,reorderAction:null,deleteAction:null,reorderSuccessMessage:Craft.t("app","New order saved."),reorderFailMessage:Craft.t("app","Couldn’t save new order."),confirmDeleteMessage:Craft.t("app","Are you sure you want to delete “{name}”?"),deleteSuccessMessage:Craft.t("app","“{name}” deleted."),deleteFailMessage:Craft.t("app","Couldn’t delete “{name}”."),onReorderItems:$.noop,onDeleteItem:$.noop}})},6872:function(){Craft.AssetImageEditor=Garnish.Modal.extend({$body:null,$footer:null,$imageTools:null,$buttons:null,$cancelBtn:null,$replaceBtn:null,$saveBtn:null,$focalPointBtn:null,$editorContainer:null,$straighten:null,$croppingCanvas:null,$spinner:null,$constraintContainer:null,$constraintRadioInputs:null,$customConstraints:null,canvas:null,image:null,viewport:null,focalPoint:null,grid:null,croppingCanvas:null,clipper:null,croppingRectangle:null,cropperHandles:null,cropperGrid:null,croppingShade:null,imageStraightenAngle:0,viewportRotation:0,originalWidth:0,originalHeight:0,imageVerticeCoords:null,zoomRatio:1,animationInProgress:!1,currentView:"",assetId:null,cacheBust:null,draggingCropper:!1,scalingCropper:!1,draggingFocal:!1,previousMouseX:0,previousMouseY:0,shiftKeyHeld:!1,editorHeight:0,editorWidth:0,cropperState:!1,scaleFactor:1,flipData:{},focalPointState:!1,maxImageSize:null,lastLoadedDimensions:null,imageIsLoading:!1,mouseMoveEvent:null,croppingConstraint:!1,constraintOrientation:"landscape",showingCustomConstraint:!1,saving:!1,renderImage:null,renderCropper:null,_queue:null,init:function(t,e){var n=this;this._queue=new Craft.Queue,this.cacheBust=Date.now(),this.setSettings(e,Craft.AssetImageEditor.defaults),null===this.settings.allowDegreeFractions&&(this.settings.allowDegreeFractions=Craft.isImagick),Garnish.prefersReducedMotion()&&(this.settings.animationDuration=1),this.assetId=t,this.flipData={x:0,y:0},this.$container=$('').appendTo(Garnish.$bod),this.$body=$('
').appendTo(this.$container),this.$footer=$('