diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 9834be0bbb8..2f6da5a8da6 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -1,6 +1,7 @@ # Release Notes for Craft CMS 5.3 (WIP) ### Content Management +- Added the “Link” field type, which replaces “URL”, and can store URLs, `mailto` and `tel` URIs, and entry/asset/category references. ([#15251](https://github.com/craftcms/cms/pull/15251)) - “Replace file” actions now display success notices on complete. ([#15217](https://github.com/craftcms/cms/issues/15217)) - Double-clicking on folders within asset indexes and folder selection modals now navigates the index/modal into the folder. ([#15238](https://github.com/craftcms/cms/discussions/15238)) @@ -13,5 +14,24 @@ ### Extensibility - Added `craft\base\FieldLayoutComponent::EVENT_DEFINE_SHOW_IN_FORM`. ([#15260](https://github.com/craftcms/cms/issues/15260)) - Added `craft\events\DefineShowFieldLayoutComponentInFormEvent`. ([#15260](https://github.com/craftcms/cms/issues/15260)) +- Added `craft\fields\Link`. +- Added `craft\fields\data\LinkData`. +- Added `craft\fields\linktypes\Asset`. +- Added `craft\fields\linktypes\BaseElementLinkType`. +- Added `craft\fields\linktypes\BaseLinkType`. +- Added `craft\fields\linktypes\BaseTextLinkType`. +- Added `craft\fields\linktypes\Category`. +- Added `craft\fields\linktypes\Email`. +- Added `craft\fields\linktypes\Phone`. +- Added `craft\fields\linktypes\Url`. +- Deprecated `craft\fields\Url`, which is now an alias for `craft\fields\Link`. +- Added `Craft.endsWith()`. +- Added `Craft.removeLeft()`. +- Added `Craft.removeRight()`. +- Added `Craft.ui.addAttributes()`. - `Craft.ElementEditor` now triggers a `checkActivity` event each time author activity is fetched. ([#15237](https://github.com/craftcms/cms/discussions/15237)) +- `Craft.ensureEndsWith()` now has a `caseInsensitive` argument. +- `Craft.ensureStartsWith()` now has a `caseInsensitive` argument. +- `Craft.startsWith()` is no longer deprecated, and now has a `caseInsensitive` argument. - Added `Garnish.once()`, for handling a class-level event only once. +- Checkbox selects now support passing a `targetPrefix`. diff --git a/src/fields/Link.php b/src/fields/Link.php new file mode 100644 index 00000000000..aabc1e021a3 --- /dev/null +++ b/src/fields/Link.php @@ -0,0 +1,477 @@ + + * @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'; + + private static array $_types; + + /** + * @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 = [ + Asset::class, + Category::class, + EmailType::class, + Entry::class, + Phone::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; + } + + /** + * @var array + * @see getLinkTypes()) + */ + private array $_linkTypes; + + /** + * Returns the link types available to the field. + * + * @return array + */ + public function getLinkTypes(): array + { + if (!isset($this->_linkTypes)) { + $this->_linkTypes = []; + $types = self::types(); + + foreach ($this->types as $typeId) { + if (isset($types[$typeId])) { + $this->_linkTypes[$typeId] = Component::createComponent([ + 'type' => $types[$typeId], + 'settings' => $this->typeSettings[$typeId] ?? [], + ], BaseLinkType::class); + } + } + } + + return $this->_linkTypes; + } + + private function resolveType(string $value): string + { + foreach ($this->getLinkTypes() as $id => $linkType) { + if ($id !== UrlType::id() && $linkType->supports($value)) { + return $id; + } + } + + return UrlType::id(); + } + + /** + * @var string[] Allowed link types + */ + public array $types = [ + 'entry', + 'url', + ]; + + /** + * @var array Settings for the allowed types + */ + public array $typeSettings = []; + + /** + * @var int The maximum length (in bytes) the field can hold + */ + public int $maxLength = 255; + + /** + * @inheritdoc + */ + public function __construct($config = []) + { + if (isset($config['types'], $config['typeSettings'])) { + // Filter out any unneeded type settings + foreach (array_keys($config['typeSettings']) as $typeId) { + if (!in_array($typeId, $config)) { + unset($config['typeSettings'][$typeId]); + } + } + } + + 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 + { + $types = self::types(); + $linkTypeOptions = array_map(fn(string $type) => [ + 'label' => $type::displayName(), + 'value' => $type::id(), + ], $types); + + // Sort them by label, with URL at the top + $urlOption = $linkTypeOptions[UrlType::id()]; + unset($linkTypeOptions[UrlType::id()]); + usort($linkTypeOptions, fn(array $a, array $b) => $a['label'] <=> $b['label']); + $linkTypeOptions = [$urlOption, ...$linkTypeOptions]; + + $html = Cp::checkboxSelectFieldHtml([ + 'label' => Craft::t('app', 'Allowed Link Types'), + 'id' => 'types', + 'name' => 'types', + 'options' => $linkTypeOptions, + 'values' => $this->types, + 'required' => true, + 'targetPrefix' => 'types-', + ]); + + $linkTypes = $this->getLinkTypes(); + $view = Craft::$app->getView(); + + foreach ($types as $typeId => $typeClass) { + $linkType = $linkTypes[$typeId] ?? Component::createComponent($typeClass, BaseLinkType::class); + $typeSettingsHtml = $view->namespaceInputs(fn() => $linkType->getSettingsHtml(), "typeSettings[$typeId]"); + if ($typeSettingsHtml) { + $html .= + Html::tag('hr') . + Html::tag('div', $typeSettingsHtml, [ + 'id' => "types-$typeId", + 'class' => array_keys(array_filter([ + 'hidden' => !isset($linkTypes[$typeId]), + ])), + ]); + } + } + + return $html . + Html::tag('hr') . + 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; + } + + $linkTypes = $this->getLinkTypes(); + + if (is_array($value)) { + $typeId = $value['type'] ?? UrlType::id(); + $value = trim($value[$typeId]['value'] ?? ''); + + if (!isset($linkTypes[$typeId])) { + throw new InvalidArgumentException("Invalid link type: $typeId"); + } + + if (!$value) { + return null; + } + + $linkType = $linkTypes[$typeId]; + $value = $linkType->normalizeValue(str_replace(' ', '+', $value)); + } else { + if (!$value) { + return null; + } + + $typeId = $this->resolveType($value); + $linkType = $linkTypes[$typeId]; + } + + return new LinkData($value, $linkType); + } + + /** + * @inheritdoc + */ + public function useFieldset(): bool + { + return true; + } + + /** + * @inheritdoc + */ + protected function inputHtml(mixed $value, ?ElementInterface $element, bool $inline): string + { + $linkTypes = $this->getLinkTypes(); + /** @var LinkData|null $value */ + $valueTypeId = $value?->type ?? UrlType::id(); + $allowedTypeIds = in_array($valueTypeId, $this->types) ? $this->types : array_merge($this->types, [$valueTypeId]); + $allowedTypeIds = array_filter($allowedTypeIds, fn(string $typeId) => isset($linkTypes[$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); + } 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' => $linkTypes[$typeId]::displayName(), + 'value' => $linkTypes[$typeId]::id(), + ], $allowedTypeIds), + 'value' => $valueTypeId, + 'inputAttributes' => [ + 'aria' => [ + 'label' => Craft::t('app', 'URL type'), + ], + ], + 'toggle' => true, + 'targetPrefix' => "$id-", + ]); + } + + foreach ($allowedTypeIds as $typeId) { + $containerId = "$id-$typeId"; + $nsContainerId = $view->namespaceInputId($containerId); + $selected = $typeId === $valueTypeId; + $typeValue = $selected ? $value?->serialize() : null; + $isTextLink = is_subclass_of($linkTypes[$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() => $linkTypes[$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, + ])), + ]) . + 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); + $linkTypes = $this->getLinkTypes(); + $linkType = $linkTypes[$value->type]; + $error = null; + if (!$linkType->validateValue($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..439ee60ebd0 --- /dev/null +++ b/src/fields/data/LinkData.php @@ -0,0 +1,113 @@ + + * @since 5.3.0 + */ +class LinkData extends BaseObject implements Serializable +{ + private string $renderedValue; + + public function __construct( + private readonly string $value, + private readonly BaseLinkType $linkType, + array $config = [], + ) { + parent::__construct($config); + } + + public function __toString(): string + { + return $this->getValue(); + } + + /** + * Returns the link type ID. + * + * @return string + */ + public function getType(): string + { + return $this->linkType::id(); + } + + /** + * Returns the link value. + */ + public function getValue(): string + { + if (!isset($this->renderedValue)) { + $this->renderedValue = $this->linkType->renderValue($this->value); + } + return $this->renderedValue; + } + + /** + * Returns the link label. + * + * @return string + */ + public function getLabel(): string + { + return $this->linkType->linkLabel($this->value); + } + + /** + * Returns an anchor tag for this link. + * + * @return Markup|null + */ + public function getLink(): ?Markup + { + $url = $this->getValue(); + if ($url === '') { + $html = ''; + } else { + $label = $this->getLabel(); + $html = Html::a(Html::encode($label !== '' ? $label : $url), $url); + } + + return Template::raw($html); + } + + /** + * Returns the element linked by the field, if there is one. + * + * @return ElementInterface|null + */ + public function getElement(): ?ElementInterface + { + if (!$this->linkType instanceof BaseElementLinkType) { + return null; + } + return $this->linkType->element($this->value); + } + + public function serialize(): mixed + { + return $this->value; + } +} diff --git a/src/fields/linktypes/Asset.php b/src/fields/linktypes/Asset.php new file mode 100644 index 00000000000..19a785bfc88 --- /dev/null +++ b/src/fields/linktypes/Asset.php @@ -0,0 +1,145 @@ + + * @since 5.3.0 + */ +class Asset extends BaseElementLinkType +{ + /** + * @var array|null The file kinds that the field should be restricted to (only used if [[restrictFiles]] is true). + */ + public ?array $allowedKinds = null; + + /** + * @var bool Whether to show input sources for volumes the user doesn’t have permission to view. + */ + public bool $showUnpermittedVolumes = false; + + /** + * @var bool Whether to show files the user doesn’t have permission to view, per the “View files uploaded by other + * users” permission. + */ + public bool $showUnpermittedFiles = false; + + public function __construct($config = []) + { + if ( + isset($config['allowedKinds']) && + (!is_array($config['allowedKinds']) || empty($config['allowedKinds']) || $config['allowedKinds'] === ['*']) + ) { + unset($config['allowedKinds']); + } + + parent::__construct($config); + } + + protected static function elementType(): string + { + return AssetElement::class; + } + + public function getSettingsHtml(): ?string + { + return + parent::getSettingsHtml() . + Cp::checkboxSelectFieldHtml([ + 'label' => Craft::t('app', 'Allowed File Types'), + 'name' => 'allowedKinds', + 'options' => Collection::make(AssetsHelper::getAllowedFileKinds()) + ->map(fn(array $kind, string $value) => [ + 'value' => $value, + 'label' => $kind['label'], + ]) + ->all(), + 'values' => $this->allowedKinds ?? '*', + 'showAllOption' => true, + ]) . + Cp::lightswitchFieldHtml([ + 'label' => Craft::t('app', 'Show unpermitted volumes'), + 'instructions' => Craft::t('app', 'Whether to show volumes that the user doesn’t have permission to view.'), + 'name' => 'showUnpermittedVolumes', + 'on' => $this->showUnpermittedVolumes, + ]) . + Cp::lightswitchFieldHtml([ + 'label' => Craft::t('app', 'Show unpermitted files'), + 'instructions' => Craft::t('app', 'Whether to show files that the user doesn’t have permission to view, per the “View files uploaded by other users” permission.'), + 'name' => 'showUnpermittedFiles', + 'on' => $this->showUnpermittedFiles, + ]); + } + + protected function availableSourceKeys(): array + { + $volumes = Collection::make(Craft::$app->getVolumes()->getAllVolumes()) + ->filter(fn(Volume $volume) => $volume->getFs()->hasUrls); + + if (!$this->showUnpermittedVolumes) { + $userService = Craft::$app->getUser(); + $volumes = $volumes->filter(fn(Volume $volume) => $userService->checkPermission("viewAssets:$volume->uid")); + } + + return $volumes + ->map(fn(Volume $volume) => "volume:$volume->uid") + ->all(); + } + + protected function selectionCriteria(): array + { + // Ignore the parent value since asset URLs don't get saved to the element + $criteria = [ + 'kind' => $this->allowedKinds, + ]; + + if ($this->showUnpermittedFiles) { + $criteria['uploaderId'] = null; + } + + return $criteria; + } + + protected function elementSelectConfig(): array + { + $config = array_merge(parent::elementSelectConfig(), [ + 'jsClass' => 'Craft.AssetSelectInput', + ]); + + if (!$this->showUnpermittedVolumes) { + $sourceKeys = $this->sources ?? Collection::make($this->availableSources()) + ->map(fn(array $source) => $source['key']) + ->all(); + $userService = Craft::$app->getUser(); + $config['sources'] = Collection::make($sourceKeys) + ->filter(function(string $source) use ($userService) { + // If it’s not a volume folder, let it through + if (!str_starts_with($source, 'volume:')) { + return true; + } + // Only show it if they have permission to view it, or if it's the temp volume + $volumeUid = explode(':', $source)[1]; + return $userService->checkPermission("viewAssets:$volumeUid"); + }) + ->all(); + } + + return $config; + } +} diff --git a/src/fields/linktypes/BaseElementLinkType.php b/src/fields/linktypes/BaseElementLinkType.php new file mode 100644 index 00000000000..c22cda84ff1 --- /dev/null +++ b/src/fields/linktypes/BaseElementLinkType.php @@ -0,0 +1,236 @@ + + * @since 5.3.0 + */ +abstract class BaseElementLinkType extends BaseLinkType +{ + /** + * @var array + * @see element() + */ + private static array $fetchedElements = []; + + /** + * 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 static::elementType()::refHandle(); + } + + public static function displayName(): string + { + return static::elementType()::displayName(); + } + + /** + * @return string|string[] The element sources elements can be linked from + */ + public ?array $sources = null; + + public function __construct($config = []) + { + if ( + isset($config['sources']) && + (!is_array($config['sources']) || empty($config['sources']) || $config['sources'] === ['*']) + ) { + unset($config['sources']); + } + + parent::__construct($config); + } + + public function getSettingsHtml(): ?string + { + return $this->sourcesSettingHtml(); + } + + /** + * Returns the HTML for the “Sources” setting + * @return string|null + */ + protected function sourcesSettingHtml(): ?string + { + $sources = Collection::make($this->availableSources()) + ->keyBy(fn(array $source) => $source['key']) + ->map(fn(array $source) => $source['label']); + + if ($sources->isEmpty()) { + return null; + } + + return Cp::checkboxSelectFieldHtml([ + 'label' => Craft::t('app', '{type} Sources', [ + 'type' => static::elementType()::displayName(), + ]), + 'name' => 'sources', + 'options' => $sources->all(), + 'values' => $this->sources ?? '*', + 'showAllOption' => true, + ]); + } + + public function supports(string $value): bool + { + return (bool)preg_match(sprintf('/^\{%s:(\d+)(@(\d+))?:url\}$/', static::elementType()::refHandle()), $value); + } + + public function renderValue(string $value): string + { + return $this->element($value)?->getUrl() ?? ''; + } + + public function linkLabel(string $value): string + { + $element = $this->element($value); + return $element ? (string)$element : ''; + } + + public function inputHtml(Link $field, ?string $value, string $containerId): string + { + $id = sprintf('elementselect%s', mt_rand()); + + $view = Craft::$app->getView(); + $view->registerJsWithVars(fn($id, $refHandle) => << { + const container = $('#' + $id); + const input = container.next('input'); + const elementSelect = container.data('elementSelect'); + const refHandle = $refHandle; + elementSelect.on('selectElements', (ev) => { + const element = ev.elements[0]; + input.val(`{\${refHandle}:\${element.id}@\${element.siteId}:url}`); + }); + elementSelect.on('removeElements', () => { + input.val(''); + }); +})(); +JS, [ + 'id' => $view->namespaceInputId($id), + 'refHandle' => static::elementType()::refHandle(), + ]); + + return + Cp::elementSelectHtml(array_merge($this->elementSelectConfig(), [ + 'id' => $id, + 'elements' => array_filter([$this->element($value)]), + ])) . + Html::hiddenInput('value', $value); + } + + /** + * Returns all sources available to the field, based on + * [[availableSources()]] plus any custom sources for the element type. + * + * @return array + */ + protected function availableSources(): array + { + $availableSourceKeys = array_flip($this->availableSourceKeys()); + return Collection::make(Craft::$app->getElementSources()->getSources( + static::elementType(), + ElementSources::CONTEXT_FIELD, + )) + ->filter(fn(array $source) => ( + ($source['type'] === ElementSources::TYPE_NATIVE && isset($availableSourceKeys[$source['key']])) || + $source['type'] === ElementSources::TYPE_CUSTOM + )) + ->all(); + } + + /** + * Returns an array of source keys for the element type, filtering out any sources that can’t be linked to. + * + * @return string[] + */ + protected function availableSourceKeys(): array + { + return []; + } + + /** + * Returns the config array that will be passed to [[Cp::elementSelectHtml()]]. + * + * @return array + */ + protected function elementSelectConfig(): array + { + return [ + 'elementType' => static::elementType(), + 'limit' => 1, + 'single' => true, + 'sources' => $this->sources ?? '*', + 'criteria' => $this->selectionCriteria(), + ]; + } + + protected function selectionCriteria(): array + { + return [ + 'uri' => 'not :empty:', + ]; + } + + public function validateValue(string $value, ?string &$error = null): bool + { + return true; + } + + public function element(?string $value): ?ElementInterface + { + if ( + !$value || + !preg_match(sprintf('/^\{%s:(\d+)(?:@(\d+))?:url\}$/', static::elementType()::refHandle()), $value, $match) + ) { + return null; + } + + if (!isset(self::$fetchedElements[$value])) { + $id = $match[1]; + $siteId = $match[2] ?? null; + + $query = static::elementType()::find() + ->id((int)$id) + ->status(null) + ->drafts(null) + ->revisions(null); + + if ($siteId) { + $query->siteId((int)$siteId); + } else { + $query + ->site('*') + ->unique() + ->preferSites([Craft::$app->getSites()->getCurrentSite()->id]); + } + + self::$fetchedElements[$value] = $query->one() ?? false; + } + + return self::$fetchedElements[$value] ?: null; + } +} diff --git a/src/fields/linktypes/BaseLinkType.php b/src/fields/linktypes/BaseLinkType.php new file mode 100644 index 00000000000..19f12da60cc --- /dev/null +++ b/src/fields/linktypes/BaseLinkType.php @@ -0,0 +1,84 @@ + + * @since 5.3.0 + */ +abstract class BaseLinkType extends ConfigurableComponent +{ + /** + * 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 whether the given value is supported by this link type. + * + * @param string $value + * @return bool + */ + abstract public function supports(string $value): bool; + + /** + * Normalizes a posted link value. + * + * @param string $value + * @return string + */ + public function normalizeValue(string $value): string + { + return $value; + } + + /** + * Renders a value for the front end. + * + * @param string $value + * @return string + */ + public function renderValue(string $value): string + { + return $value; + } + + /** + * Returns the default link label for [[\craft\fields\data\LinkData::getLabel()]]. + * + * @return string + */ + abstract public function linkLabel(string $value): string; + + /** + * 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 function inputHtml(Link $field, ?string $value, string $containerId): string; + + /** + * Validates the given value. + * + * @param string $value + * @param string|null $error + * @return bool + */ + abstract public function validateValue(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..ed260aaf3e6 --- /dev/null +++ b/src/fields/linktypes/BaseTextLinkType.php @@ -0,0 +1,148 @@ + + * @since 5.3.0 + */ +abstract class BaseTextLinkType extends BaseLinkType +{ + /** + * Returns the prefix(es) that supported URLs must start with. + * + * @return string|string[] + */ + abstract protected function urlPrefix(): string|array; + + public function supports(string $value): bool + { + $value = mb_strtolower($value); + foreach ((array)$this->urlPrefix() as $prefix) { + if (str_starts_with($value, $prefix)) { + return true; + } + } + return false; + } + + public function normalizeValue(string $value): string + { + if ($this->supports($value)) { + return $value; + } + + // Only add a prefix if the end result validates + $prefix = ArrayHelper::firstValue((array)$this->urlPrefix()); + $normalized = "$prefix$value"; + return $this->validateValue($normalized) ? $normalized : $value; + } + + public function linkLabel(string $value): string + { + foreach ((array)$this->urlPrefix() as $prefix) { + $value = StringHelper::removeLeft($value, $prefix); + } + return $value; + } + + public function inputHtml(Link $field, ?string $value, string $containerId): string + { + $name = 'value'; + $textInputAttributes = array_merge([ + 'describedBy' => $field->describedBy, + 'class' => ['fullwidth', 'text-link-input'], + 'inputAttributes' => [ + 'aria' => [ + 'label' => Craft::t('site', $field->name), + ], + ], + ], $this->inputAttributes()); + + $view = Craft::$app->getView(); + $view->registerJsWithVars(fn($id, $settings) => << { + new Craft.LinkInput('#' + $id, $settings); +})(); +JS, [ + $containerId, + [ + 'prefixes' => (array)$this->urlPrefix(), + 'pattern' => $this->pattern(), + 'inputAttributes' => $textInputAttributes, + ], + ]); + + if ($value && $this->validateValue($value)) { + $linkText = $this->linkLabel($value); + $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 function inputAttributes(): array + { + return []; + } + + public function validateValue(string $value, ?string &$error = null): bool + { + $pattern = sprintf('/%s/i', $this->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 function pattern(): string + { + $prefixes = array_map(fn(string $prefix) => preg_quote($prefix, '/'), (array)$this->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..edbe902e0ff --- /dev/null +++ b/src/fields/linktypes/Category.php @@ -0,0 +1,37 @@ + + * @since 5.3.0 + */ +class Category extends BaseElementLinkType +{ + protected static function elementType(): string + { + return CategoryElement::class; + } + + protected function availableSourceKeys(): 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..4a30a44a673 --- /dev/null +++ b/src/fields/linktypes/Email.php @@ -0,0 +1,50 @@ + + * @since 5.3.0 + */ +class Email extends BaseTextLinkType +{ + public static function id(): string + { + return 'email'; + } + + public static function displayName(): string + { + return Craft::t('app', 'Email'); + } + + protected function urlPrefix(): string|array + { + return 'mailto:'; + } + + protected function inputAttributes(): array + { + return [ + 'type' => 'email', + 'inputmode' => 'email', + ]; + } + + protected 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..2c99ec0b991 --- /dev/null +++ b/src/fields/linktypes/Entry.php @@ -0,0 +1,60 @@ + + * @since 5.3.0 + */ +class Entry extends BaseElementLinkType +{ + protected static function elementType(): string + { + return EntryElement::class; + } + + protected function availableSourceKeys(): 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/Phone.php b/src/fields/linktypes/Phone.php new file mode 100644 index 00000000000..644856c979c --- /dev/null +++ b/src/fields/linktypes/Phone.php @@ -0,0 +1,54 @@ + + * @since 5.3.0 + */ +class Phone extends BaseTextLinkType +{ + public static function id(): string + { + return 'tel'; + } + + public static function displayName(): string + { + return Craft::t('app', 'Phone'); + } + + protected function urlPrefix(): string|array + { + return 'tel:'; + } + + public function normalizeValue(string $value): string + { + $value = str_replace(' ', '-', $value); + return parent::normalizeValue($value); + } + + protected function inputAttributes(): array + { + return [ + 'type' => 'tel', + 'inputmode' => 'tel', + ]; + } + + protected 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..767654abe38 --- /dev/null +++ b/src/fields/linktypes/Url.php @@ -0,0 +1,48 @@ + + * @since 5.3.0 + */ +class Url extends BaseTextLinkType +{ + public static function id(): string + { + return 'url'; + } + + public static function displayName(): string + { + return Craft::t('app', 'URL'); + } + + protected function urlPrefix(): array + { + return ['https://', 'http://']; + } + + protected function inputAttributes(): array + { + return [ + 'type' => 'url', + 'inputmode' => 'url', + ]; + } + + protected function pattern(): string + { + // Don't use the URL validator's pattern, as that doesn't require a TLD + return 'https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&\/\/=]*)'; + } +} 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/templates/_elements/list.twig b/src/templates/_elements/list.twig index dbb348a6eb8..532b640d65e 100644 --- a/src/templates/_elements/list.twig +++ b/src/templates/_elements/list.twig @@ -1,12 +1,14 @@ {% set elements = elements ?? [] %} {% set disabled = disabled ?? null %} {% set viewMode = viewMode ?? null %} +{% set size = size ?? (viewMode == 'large' ? 'large' : 'small') %} {% apply spaceless %} {% tag 'ul' with { class: [ 'elements', 'chips', + "chips-#{size}", (inline ?? false) ? 'inline-chips' : null, ]|filter, } %} @@ -14,7 +16,7 @@
  • {% set element = elementChip(element, { context: context ?? 'index', - size: size ?? (viewMode == 'large' ? 'large' : 'small'), + size, inputName: inputName ?? ((name ?? false) ? ((single ?? false) ? name : "#{name}[]") : null), showActionMenu: showActionMenu ?? false, checkbox: selectable ?? false, diff --git a/src/templates/_includes/forms/checkbox.twig b/src/templates/_includes/forms/checkbox.twig index 6e595cd40b9..b943ba7ca7c 100644 --- a/src/templates/_includes/forms/checkbox.twig +++ b/src/templates/_includes/forms/checkbox.twig @@ -7,7 +7,7 @@ {% set inputAttributes = { id: id, class: (class ?? [])|explodeClass|merge([ - (toggle ?? reverseToggle ?? false) ? 'fieldtoggle' : null, + (targetPrefix ?? toggle ?? reverseToggle ?? false) ? 'fieldtoggle' : null, 'checkbox' ]|filter), checked: (checked ?? false) and checked, @@ -18,6 +18,7 @@ describedby: describedBy ?? aria.describedby ?? false, }), data: (data ?? {})|merge({ + 'target-prefix': targetPrefix ?? false, target: toggle ?? false, 'reverse-target': reverseToggle ?? false, }), diff --git a/src/templates/_includes/forms/checkboxSelect.twig b/src/templates/_includes/forms/checkboxSelect.twig index 7a821b9451d..58cd4d4f015 100644 --- a/src/templates/_includes/forms/checkboxSelect.twig +++ b/src/templates/_includes/forms/checkboxSelect.twig @@ -27,6 +27,7 @@ value: allValue, checked: allChecked, autofocus: (autofocus ?? false) and not craft.app.request.isMobileBrowser(true), + targetPrefix: targetPrefix ?? null, } only %} {%- elseif name is defined and (name|length < 3 or name|slice(-2) != '[]') %} @@ -41,7 +42,8 @@ {% include "_includes/forms/checkbox" with { name: (name ?? false) ? "#{name}[]" : null, checked: ((showAllOption and allChecked) or (option.value is defined and option.value in values)), - disabled: (showAllOption and allChecked) + disabled: (showAllOption and allChecked), + targetPrefix: targetPrefix ?? null, }|merge(option) only %} {% endif %} diff --git a/src/translations/en/app.php b/src/translations/en/app.php index bf5fabaf7d9..1ae82c19e92 100644 --- a/src/translations/en/app.php +++ b/src/translations/en/app.php @@ -98,7 +98,8 @@ '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 File Types' => 'Allowed File 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.', @@ -1519,7 +1520,6 @@ 'Teal' => 'Teal', 'Team permissions can be managed from {link}.' => 'Team permissions can be managed from {link}.', 'Team permissions can be managed from {path} on a development environment.' => 'Team permissions can be managed from {path} on a development environment.', - 'Telephone' => 'Telephone', 'Temp files' => 'Temp files', 'Template caches' => 'Template caches', 'Template that defines the field’s custom “propagation key” format. Entries will be saved to all sites that produce the same key.' => 'Template that defines the field’s custom “propagation key” format. Entries will be saved to all sites that produce the same key.', @@ -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.', @@ -2126,6 +2125,7 @@ '{type} ID' => '{type} ID', '{type} Per Page' => '{type} Per Page', '{type} Settings' => '{type} Settings', + '{type} Sources' => '{type} Sources', '{type} created.' => '{type} created.', '{type} deleted for site.' => '{type} deleted for site.', '{type} deleted.' => '{type} deleted.', diff --git a/src/web/assets/cp/CpAsset.php b/src/web/assets/cp/CpAsset.php index c8cff05d2e1..be00a06c120 100644 --- a/src/web/assets/cp/CpAsset.php +++ b/src/web/assets/cp/CpAsset.php @@ -378,8 +378,9 @@ private function _registerTranslations(View $view): void 'Use for element thumbnails', 'User Groups', 'View in a new tab', - 'View', + 'View in a new tab', 'View settings', + 'View', 'Volume path', 'Warning', 'What do you want to do with their content?', diff --git a/src/web/assets/cp/dist/cp.js b/src/web/assets/cp/dist/cp.js index b217c9cca21..d0bce26670d 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=$('
  • ").appendTo(i);var a=new Garnish.MenuBtn(this.$selectTransformBtn,{onOptionSelect:this.onSelectTransform.bind(this)});a.disable(),this.$selectTransformBtn.data("menuButton",a)}},onSelectionChange:function(t){var e=!1;this.elementIndex.getSelectedElements().length&&this.settings.transforms.length&&(e=!0);var n=null;this.$selectTransformBtn&&(n=this.$selectTransformBtn.data("menuButton")),e?(n&&n.enable(),this.$selectTransformBtn.removeClass("disabled")):this.$selectTransformBtn&&(n&&n.disable(),this.$selectTransformBtn.addClass("disabled")),this.base()},onSelectTransform:function(t){var e=$(t).data("transform");this.selectImagesWithTransform(e)},selectImagesWithTransform:function(t){var e=this;void 0===Craft.AssetSelectorModal.transformUrls[t]&&(Craft.AssetSelectorModal.transformUrls[t]={});for(var n=this.elementIndex.getSelectedElements(),i=[],r=0;r=0;--r){var s=this.tryEntries[r],o=s.completion;if("root"===s.tryLoc)return i("end");if(s.tryLoc<=this.prev){var l=a.call(s,"catchLoc"),c=a.call(s,"finallyLoc");if(l&&c){if(this.prev=0;--n){var i=this.tryEntries[n];if(i.tryLoc<=this.prev&&a.call(i,"finallyLoc")&&this.prev=0;--e){var n=this.tryEntries[e];if(n.finallyLoc===t)return this.complete(n.completion,n.afterLoc),P(n),b}},catch:function(t){for(var e=this.tryEntries.length-1;e>=0;--e){var n=this.tryEntries[e];if(n.tryLoc===t){var i=n.completion;if("throw"===i.type){var r=i.arg;P(n)}return r}}throw new Error("illegal catch attempt")},delegateYield:function(t,e,i){return this.delegate={iterator:M(t),resultName:e,nextLoc:i},"next"===this.method&&(this.arg=n),b}},i}function n(t,e,n,i,r,a,s){try{var o=t[a](s),l=o.value}catch(t){return void n(t)}o.done?e(l):Promise.resolve(l).then(i,r)}function i(t){return function(){var e=this,i=arguments;return new Promise((function(r,a){var s=t.apply(e,i);function o(t){n(s,r,a,o,l,"next",t)}function l(t){n(s,r,a,o,l,"throw",t)}o(void 0)}))}}var r;Craft.AuthManager=Garnish.Base.extend({checkRemainingSessionTimer:null,decrementLogoutWarningInterval:null,showingLogoutWarningModal:!1,showingLoginModal:!1,renewingSession:!1,logoutWarningModal:null,loginModal:null,$logoutWarningPara:null,$passwordInput:null,$loginBtn:null,loginBtn:null,get remainingSessionTime(){return Craft.remainingSessionTime},init:function(){Craft.username&&this.updateRemainingSessionTime(Craft.remainingSessionTime,!1)},setCheckRemainingSessionTimer:function(t){var e=this;this.checkRemainingSessionTimer&&clearTimeout(this.checkRemainingSessionTimer),this.checkRemainingSessionTimer=setTimeout((function(){e.checkRemainingSessionTime()}),1e3*t)},checkRemainingSessionTime:function(t){var n=this;return i(e().mark((function i(){var r,a,s;return e().wrap((function(e){for(;;)switch(e.prev=e.next){case 0:return r=Craft.getActionUrl("users/session-info",t?null:"dontExtendSession=1"),e.prev=1,e.next=4,Craft.sendActionRequest("GET",r);case 4:a=e.sent,s=a.data,void 0!==Craft.csrfTokenValue&&(Craft.csrfTokenValue=s.csrfTokenValue),n.updateRemainingSessionTime(s.timeout,s.isGuest),e.next=13;break;case 10:e.prev=10,e.t0=e.catch(1),n.updateRemainingSessionTime(-1,!1);case 13:case"end":return e.stop()}}),i,null,[[1,10]])})))()},updateRemainingSessionTime:function(t,e){this.checkRemainingSessionTimer&&clearTimeout(this.checkRemainingSessionTimer);var n=!Craft.remainingSessionTime&&t;if(Craft.remainingSessionTime=parseInt(t),-1!==Craft.remainingSessionTime&&Craft.remainingSessionTime'),i=$('
    ').appendTo(n),r=$('
    ').appendTo(i),a=$("
    \n");var l=function(n){function i(){return function(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}(this,i),function(e,n,i){return n=s(n),function(e,n){if(n&&("object"===t(n)||"function"==typeof n))return n;if(void 0!==n)throw new TypeError("Derived constructors may only return object or undefined");return function(t){if(void 0===t)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return t}(e)}(e,r()?Reflect.construct(n,i||[],s(e).constructor):n.apply(e,i))}(this,i,arguments)}var l,c,h;return function(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Super expression must either be null or a function");t.prototype=Object.create(e&&e.prototype,{constructor:{value:t,writable:!0,configurable:!0}}),Object.defineProperty(t,"prototype",{writable:!1}),e&&a(t,e)}(i,n),l=i,h=[{key:"observedAttributes",get:function(){return["visible"]}}],(c=[{key:"connectedCallback",value:function(){this.root=this;var t=o.content.cloneNode(!0);this.root.append(t),"true"===this.visible&&this.wrapper.classList.remove("hidden"),this.initialized=!0}},{key:"visible",get:function(){return this.getAttribute("visible")},set:function(t){this.setAttribute("visible",t)}},{key:"messageWrapper",get:function(){return this.querySelector(".message")}},{key:"wrapper",get:function(){return this.querySelector(".wrapper")}},{key:"attributeChangedCallback",value:function(t,e,n){if(this.initialized)return"visible"===t.toLowerCase()?"true"===n?this.show():this.hide():void 0}},{key:"disconnectedCallback",value:function(){}},{key:"show",value:function(){this.wrapper.classList.remove("hidden"),this.dispatchEvent(new CustomEvent("show"))}},{key:"hide",value:function(){this.wrapper.classList.add("hidden"),this.dispatchEvent(new CustomEvent("hide"))}},{key:"focus",value:function(){this.wrapper.focus()}}])&&e(l.prototype,c),h&&e(l,h),Object.defineProperty(l,"prototype",{writable:!1}),i}(i(HTMLElement));customElements.define("craft-spinner",l)},691:function(){function t(e){return t="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t},t(e)}function e(t,e){return function(t){if(Array.isArray(t))return t}(t)||function(t,e){var n=null==t?null:"undefined"!=typeof Symbol&&t[Symbol.iterator]||t["@@iterator"];if(null!=n){var i,r,a,s,o=[],l=!0,c=!1;try{if(a=(n=n.call(t)).next,0===e){if(Object(n)!==n)return;l=!1}else for(;!(l=(i=a.call(n)).done)&&(o.push(i.value),o.length!==e);l=!0);}catch(t){c=!0,r=t}finally{try{if(!l&&null!=n.return&&(s=n.return(),Object(s)!==s))return}finally{if(c)throw r}}return o}}(t,e)||r(t,e)||function(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function n(){"use strict";n=function(){return i};var e,i={},r=Object.prototype,a=r.hasOwnProperty,s=Object.defineProperty||function(t,e,n){t[e]=n.value},o="function"==typeof Symbol?Symbol:{},l=o.iterator||"@@iterator",c=o.asyncIterator||"@@asyncIterator",h=o.toStringTag||"@@toStringTag";function u(t,e,n){return Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}),t[e]}try{u({},"")}catch(e){u=function(t,e,n){return t[e]=n}}function d(t,e,n,i){var r=e&&e.prototype instanceof y?e:y,a=Object.create(r.prototype),o=new A(i||[]);return s(a,"_invoke",{value:E(t,n,o)}),a}function f(t,e,n){try{return{type:"normal",arg:t.call(e,n)}}catch(t){return{type:"throw",arg:t}}}i.wrap=d;var p="suspendedStart",g="suspendedYield",m="executing",v="completed",b={};function y(){}function C(){}function $(){}var w={};u(w,l,(function(){return this}));var _=Object.getPrototypeOf,S=_&&_(_(M([])));S&&S!==r&&a.call(S,l)&&(w=S);var x=$.prototype=y.prototype=Object.create(w);function T(t){["next","throw","return"].forEach((function(e){u(t,e,(function(t){return this._invoke(e,t)}))}))}function I(e,n){function i(r,s,o,l){var c=f(e[r],e,s);if("throw"!==c.type){var h=c.arg,u=h.value;return u&&"object"==t(u)&&a.call(u,"__await")?n.resolve(u.__await).then((function(t){i("next",t,o,l)}),(function(t){i("throw",t,o,l)})):n.resolve(u).then((function(t){h.value=t,o(h)}),(function(t){return i("throw",t,o,l)}))}l(c.arg)}var r;s(this,"_invoke",{value:function(t,e){function a(){return new n((function(n,r){i(t,e,n,r)}))}return r=r?r.then(a,a):a()}})}function E(t,n,i){var r=p;return function(a,s){if(r===m)throw new Error("Generator is already running");if(r===v){if("throw"===a)throw s;return{value:e,done:!0}}for(i.method=a,i.arg=s;;){var o=i.delegate;if(o){var l=L(o,i);if(l){if(l===b)continue;return l}}if("next"===i.method)i.sent=i._sent=i.arg;else if("throw"===i.method){if(r===p)throw r=v,i.arg;i.dispatchException(i.arg)}else"return"===i.method&&i.abrupt("return",i.arg);r=m;var c=f(t,n,i);if("normal"===c.type){if(r=i.done?v:g,c.arg===b)continue;return{value:c.arg,done:i.done}}"throw"===c.type&&(r=v,i.method="throw",i.arg=c.arg)}}}function L(t,n){var i=n.method,r=t.iterator[i];if(r===e)return n.delegate=null,"throw"===i&&t.iterator.return&&(n.method="return",n.arg=e,L(t,n),"throw"===n.method)||"return"!==i&&(n.method="throw",n.arg=new TypeError("The iterator does not provide a '"+i+"' method")),b;var a=f(r,t.iterator,n.arg);if("throw"===a.type)return n.method="throw",n.arg=a.arg,n.delegate=null,b;var s=a.arg;return s?s.done?(n[t.resultName]=s.value,n.next=t.nextLoc,"return"!==n.method&&(n.method="next",n.arg=e),n.delegate=null,b):s:(n.method="throw",n.arg=new TypeError("iterator result is not an object"),n.delegate=null,b)}function k(t){var e={tryLoc:t[0]};1 in t&&(e.catchLoc=t[1]),2 in t&&(e.finallyLoc=t[2],e.afterLoc=t[3]),this.tryEntries.push(e)}function P(t){var e=t.completion||{};e.type="normal",delete e.arg,t.completion=e}function A(t){this.tryEntries=[{tryLoc:"root"}],t.forEach(k,this),this.reset(!0)}function M(n){if(n||""===n){var i=n[l];if(i)return i.call(n);if("function"==typeof n.next)return n;if(!isNaN(n.length)){var r=-1,s=function t(){for(;++r=0;--r){var s=this.tryEntries[r],o=s.completion;if("root"===s.tryLoc)return i("end");if(s.tryLoc<=this.prev){var l=a.call(s,"catchLoc"),c=a.call(s,"finallyLoc");if(l&&c){if(this.prev=0;--n){var i=this.tryEntries[n];if(i.tryLoc<=this.prev&&a.call(i,"finallyLoc")&&this.prev=0;--e){var n=this.tryEntries[e];if(n.finallyLoc===t)return this.complete(n.completion,n.afterLoc),P(n),b}},catch:function(t){for(var e=this.tryEntries.length-1;e>=0;--e){var n=this.tryEntries[e];if(n.tryLoc===t){var i=n.completion;if("throw"===i.type){var r=i.arg;P(n)}return r}}throw new Error("illegal catch attempt")},delegateYield:function(t,n,i){return this.delegate={iterator:M(t),resultName:n,nextLoc:i},"next"===this.method&&(this.arg=e),b}},i}function i(t){return function(t){if(Array.isArray(t))return a(t)}(t)||function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}(t)||r(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function r(t,e){if(t){if("string"==typeof t)return a(t,e);var n=Object.prototype.toString.call(t).slice(8,-1);return"Object"===n&&t.constructor&&(n=t.constructor.name),"Map"===n||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?a(t,e):void 0}}function a(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,i=new Array(e);n').appendTo(Garnish.$bod);this.$sidebar=$('
    ').appendTo(i).attr({role:"navigation","aria-label":Craft.t("app","Source")}),this.$sourcesContainer=$('
    ').appendTo(this.$sidebar),this.$sourceSettingsContainer=$('
    ').appendTo(i),this.$footer=$('