diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md new file mode 100644 index 00000000000..01e35b37cbe --- /dev/null +++ b/CHANGELOG-WIP.md @@ -0,0 +1,101 @@ +# Release Notes for Craft CMS 5.10 (WIP) + +### Content Management +- Collapsed Matrix blocks now show their entries’ UI labels as preview text, whenever possible. ([#18484](https://github.com/craftcms/cms/discussions/18484)) +- Element-level actions within nested element management fields (Matrix, Addresses, etc.) now consistently affect all selected elements, when performed on a selected element. ([#18561](https://github.com/craftcms/cms/pull/18561)) +- Elements within Matrix and Addresses fields now have “Paste above” actions when a compatible element is copied. ([#17406](https://github.com/craftcms/cms/discussions/17406)) +- Elements now keep track of the index page’s URL their edit page was linked to from, and explicitly redirect back to that page after save, rather than always redirecting to the referrer. ([#18680](https://github.com/craftcms/cms/pull/18680)) +- Addresses fields now have a “Copy all addresses” field-level action. ([#18561](https://github.com/craftcms/cms/pull/18561)) +- Matrix fields’ “Expand”, “Collapse”, and “Copy” field-level actions now always affect all nested entries, regardless of whether any entries are selected. ([#18561](https://github.com/craftcms/cms/pull/18561)) +- Matrix fields no longer have “Duplicate” and “Delete” field-level actions. ([#18561](https://github.com/craftcms/cms/pull/18561)) +- Number fields now show their selected currency beside their input, if their Preview Format setting is set to “As currency values”. ([#18498](https://github.com/craftcms/cms/pull/18498)) +- Color field previews are now blank for fields without a value. ([#18614](https://github.com/craftcms/cms/issues/18614)) +- Text condition rules now have “does not equal”, “is one of” and “is not one of” operators. +- Numeric condition rules now have “is one of” and “is not one of” operators. ([#18734](https://github.com/craftcms/cms/pull/18734)) +- Editable table columns now set `min-width` styles based on their configured widths, if set. ([#18534](https://github.com/craftcms/cms/issues/18534)) +- Entry post dates are no longer automatically set until the entry is fully saved as enabled. ([#18642](https://github.com/craftcms/cms/pull/18642)) +- Element edit screens now have a “Save as a new draft” action when editing an explicitly-created draft. ([#18722](https://github.com/craftcms/cms/pull/18722)) +- Address edit screens now have “Field settings” action menu items. ([#18544](https://github.com/craftcms/cms/discussions/18544)) +- Asset edit screens now have “Volume settings” and “Filesystem settings” action menu items. ([#18544](https://github.com/craftcms/cms/discussions/18544)) +- Entries’ “Entry type settings” and “Section settings” action menu items are now only shown for element edit screens’ primary action menus. +- Category indexes can now have “Group” columns. ([#18553](https://github.com/craftcms/cms/discussions/18553)) +- Element slideouts now automatically refresh when the same element is updated in another tab/slideout. ([#18625](https://github.com/craftcms/cms/pull/18625)) +- Added the “Time Zone” user preference. ([#8518](https://github.com/craftcms/cms/discussions/8518)) +- Element indexes now automatically refresh after duplicating elements and the queue is completed, if there’s an active search term. ([#18636](https://github.com/craftcms/cms/issues/18636)) +- Timestamps in the control panel now include their time zone abbreviation. ([#18639](https://github.com/craftcms/cms/pull/18639)) +- Generated field values are no longer truncated within element cards. ([#18646](https://github.com/craftcms/cms/discussions/18646)) +- Assets’ Alternative Text values are now automatically set on upload, based on descriptive text data found in the uploaded file’s metadata. ([#18744](https://github.com/craftcms/cms/pull/18744)) +- When deleting elements, a modal window is now shown alerting the user of any potential issues, such as existing relationships. ([#18728](https://github.com/craftcms/cms/pull/18728)) +- “Verification Code” and “Recovery Code” forms no longer get auto-submitted when entering a value. + +### Administration +- It’s now possible to replace the selected custom field for existing field layout elements. ([#18814](https://github.com/craftcms/cms/pull/18814)) +- Sections now have a “Min Authors” setting. ([#18662](https://github.com/craftcms/cms/pull/18662)) +- Time fields’ “Max Time” settings can now be set to an earlier time than “Min Time”, for overnight time ranges. ([#18575](https://github.com/craftcms/cms/pull/18575)) +- Component chips within component select inputs now have “Replace” actions. +- Newlines in system message bodies are now replaced with `
` tags. ([#18058](https://github.com/craftcms/cms/discussions/18058)) +- Added the `--to-default` option to `resave` commands. ([#18522](https://github.com/craftcms/cms/pull/18522)) +- Added the `--method` option to the `users/remove-2fa` command. ([#18732](https://github.com/craftcms/cms/pull/18732)) + +### Development +- Added the `heading()`/`h()` and `h1()`…`h6()` Twig functions. ([#18524](https://github.com/craftcms/cms/pull/18524)) +- The `tag()` Twig function now accepts a string for its second argument. ([#18524](https://github.com/craftcms/cms/pull/18524)) +- The `|attr`, `|parseAttr`, and `|removeClass` Twig filters no longer log warnings when performed on a string without an HTML tag. ([#17622](https://github.com/craftcms/cms/discussions/17622)) +- The `|default` Twig filter and `is empty` Twig test now treat all `yii\base\Model` instances as not empty. ([#18727](https://github.com/craftcms/cms/issues/18727)) +- The `|time` and `|datetime` Twig filters now have `$withTimeZone` arguments. ([#18639](https://github.com/craftcms/cms/pull/18639)) +- The `|timestamp` Twig filter now returns the current time, if applied to a `null`/empty string value. ([#18642](https://github.com/craftcms/cms/pull/18642)) +- `delete` GraphQL queries now have a `hardDelete` argument. ([#18511](https://github.com/craftcms/cms/pull/18511)) +- Entry `postDate` values are now `null` on creation, rather than set to the `dateCreated` value. ([#18642](https://github.com/craftcms/cms/pull/18642)) +- Assets’ `url` GraphQL fields’ `immediately` arguments are no longer deprecated. ([#18581](https://github.com/craftcms/cms/issues/18581)) +- JSON fields now support array values in POST data. ([#18705](https://github.com/craftcms/cms/pull/18705)) +- Added `craft\filters\SecFetchSiteFilter` for request origin verification. ([#18641](https://github.com/craftcms/cms/pull/18641)) +- `craft\fields\data\LinkData::getUrl()` now has an `$anyStatus` argument, which can be set to `false` to prevent a value from being returned if a disabled/pending/expired element is linked. ([#18527](https://github.com/craftcms/cms/issues/18527)) +- Markdown parsing now respects the first number of ordered lists. ([#18671](https://github.com/craftcms/cms/issues/18671)) + +### Extensibility +- Added `craft\base\DefaultableFieldInterface`. ([#18522](https://github.com/craftcms/cms/pull/18522)) +- Added `craft\base\Element::EVENT_DEFINE_DELETION_BLOCKERS`. ([#18728](https://github.com/craftcms/cms/pull/18728)) +- Added `craft\base\ElementActionInterface::getTriggerId()`. +- Added `craft\base\ElementInterface::deletionBlockers()`. ([#18728](https://github.com/craftcms/cms/pull/18728)) +- Added `craft\base\ElementInterface::setDirtyFieldTracking()`. +- Added `craft\elements\PopulateElementEvent::$content`. +- Added `craft\elements\db\ElementQuery::$activeQuery`. +- Added `craft\elements\db\ElementQueryInterface::collectIds()`. +- Added `craft\elements\deletionblockers\BaseDeletionBlocker`. ([#18728](https://github.com/craftcms/cms/pull/18728)) +- Added `craft\elements\deletionblockers\DeletionBlockerInterface`. ([#18728](https://github.com/craftcms/cms/pull/18728)) +- Added `craft\elements\deletionblockers\EntryAuthorsBlocker`. ([#18728](https://github.com/craftcms/cms/pull/18728)) +- Added `craft\elements\deletionblockers\RelationDeletionBlocker`. ([#18728](https://github.com/craftcms/cms/pull/18728)) +- Added `craft\errors\FieldNotFoundException::$fieldId`. +- Added `craft\events\DefineElementDeletionBlockersEvent`. ([#18728](https://github.com/craftcms/cms/pull/18728)) +- Added `craft\fieldlayoutelements\CustomField::setFieldId()`. +- Added `craft\helpers\Html::jsWithVars()`. +- Added `craft\helpers\Markdown`. ([#18671](https://github.com/craftcms/cms/issues/18671)) +- Added `craft\models\Section::$minAuthors`. ([#18662](https://github.com/craftcms/cms/pull/18662)) +- Added `craft\queue\jobs\ReplaceRelations`. ([#18728](https://github.com/craftcms/cms/pull/18728)) +- Added `craft\services\Elements::REF_TAG_PATTERN`. +- Added `craft\services\Entries::reassignEntries()`. +- Added `craft\validators\TimeValidator::$outOfRange`. ([#18575](https://github.com/craftcms/cms/pull/18575)) +- Added `Craft.CpScreenSlideout::reload()`. ([#18625](https://github.com/craftcms/cms/pull/18625)) +- Added `Craft.ElementDeletionManager`. +- `craft\elements\PopulateElementEvent::$row` no longer includes `fieldValues` or `generatedFieldValues` keys. +- `craft\helpers\DateTimeHelper::timeZoneAbbreviation()` is no longer deprecated, and now has a `$date` argument. +- `craft\i18n\Formatter::asTime()` and `asDatetime()` now have `$withTimeZone` arguments. ([#18639](https://github.com/craftcms/cms/pull/18639)) +- Removed `craft\controllers\AppController::actionResourceJs()`. ([#18559](https://github.com/craftcms/cms/pull/18559)) +- `Craft.CP` now triggers a `queueCompleted` event when the last queue job is completed. +- Deprecated `craft\controllers\UsersController::EVENT_DEFINE_CONTENT_SUMMARY`. ([#18728](https://github.com/craftcms/cms/pull/18728)) +- Deprecated `craft\elements\User::$inheritorOnDelete`. ([#18728](https://github.com/craftcms/cms/pull/18728)) +- Deprecated `craft\elements\actions\DeleteUsers`. ([#18728](https://github.com/craftcms/cms/pull/18728)) +- Deprecated `craft\events\DefineUserContentSummaryEvent`. ([#18728](https://github.com/craftcms/cms/pull/18728)) +- Deprecated `Craft.DeleteUserModal`. ([#18728](https://github.com/craftcms/cms/pull/18728)) + +### System +- Improve the image quality of WEBP transforms, when `optimizeImageFilesize` is disabled. ([#18635](https://github.com/craftcms/cms/pull/18635)) +- Cross-domain script tags added by JavaScript are now loaded directly, rather than via a proxy. ([#18559](https://github.com/craftcms/cms/pull/18559)) +- Updated Twig to 3.24. ([#18259](https://github.com/craftcms/cms/discussions/18259), [#18454](https://github.com/craftcms/cms/issues/18454)) +- Updated bacon/bacon-qr-code to 3.x. ([#18742](https://github.com/craftcms/cms/discussions/18742)) +- Updated the built-in composer.phar to 2.9.7. ([#18761](https://github.com/craftcms/cms/issues/18761)) +- Fixed a bug where nested entries weren’t getting loaded with their content, if they had an entry type that was no longer allowed by their Matrix field. +- Fixed the wording of the validation error when saving a nested entry with an invalid entry type. ([#18506](https://github.com/craftcms/cms/issues/18506)) +- Fixed a bug where relation fields’ element query params weren’t limiting results based on the query’s target site(s). ([#18781](https://github.com/craftcms/cms/issues/18781)) +- Fixed a [high-severity](https://github.com/craftcms/cms/security/policy#severity--remediation) potential RCE vulnerability. (GHSA-f74w-488g-8x5r) +- Fixed a [moderate-severity](https://github.com/craftcms/cms/security/policy#severity--remediation) JavaScript injection vulnerability. (GHSA-c55v-343g-5xff) diff --git a/composer.json b/composer.json index 8e5f9071185..6d636989ae5 100644 --- a/composer.json +++ b/composer.json @@ -34,7 +34,7 @@ "ext-pcre": "*", "ext-pdo": "*", "ext-zip": "*", - "bacon/bacon-qr-code": "^2.0", + "bacon/bacon-qr-code": "^3.0", "commerceguys/addressing": "^2.1.1", "composer/semver": "^3.3.2", "craftcms/plugin-installer": "~1.6.0", @@ -52,7 +52,7 @@ "phpdocumentor/reflection-docblock": "^5.3", "phpoffice/phpspreadsheet": "^5.3", "pixelandtonic/graphql-php": "~14.11.10.1", - "pixelandtonic/imagine": "~1.3.3.1", + "pixelandtonic/imagine": "~1.5.2.1", "pragmarx/google2fa": "^8.0", "pragmarx/recovery": "^0.2.1", "samdark/yii2-psr-log-target": "^1.1.3", @@ -67,7 +67,7 @@ "symfony/var-dumper": "^5.0|^6.0|^7.0", "symfony/yaml": "^5.2.3|^6.0|^7.0", "theiconic/name-parser": "^1.2", - "twig/twig": "~3.21.1", + "twig/twig": "~3.24.0", "voku/portable-ascii": "^2.0", "web-auth/webauthn-lib": "~5.2.4", "yiisoft/yii2": "~2.0.54.0", diff --git a/composer.lock b/composer.lock index 36cec126e29..ae820139b5c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,32 +4,33 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c2da154a30399dc26ba5cd459cea62c9", + "content-hash": "f6d67944b5d5c2a43a4082a1b6120ee8", "packages": [ { "name": "bacon/bacon-qr-code", - "version": "2.0.8", + "version": "v3.1.1", "source": { "type": "git", "url": "https://github.com/Bacon/BaconQrCode.git", - "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22" + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/8674e51bb65af933a5ffaf1c308a660387c35c22", - "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", "shasum": "" }, "require": { "dasprid/enum": "^1.0.3", "ext-iconv": "*", - "php": "^7.1 || ^8.0" + "php": "^8.1" }, "require-dev": { - "phly/keep-a-changelog": "^2.1", - "phpunit/phpunit": "^7 | ^8 | ^9", - "spatie/phpunit-snapshot-assertions": "^4.2.9", - "squizlabs/php_codesniffer": "^3.4" + "phly/keep-a-changelog": "^2.12", + "phpunit/phpunit": "^10.5.11 || ^11.0.4", + "spatie/phpunit-snapshot-assertions": "^5.1.5", + "spatie/pixelmatch-php": "^1.2.0", + "squizlabs/php_codesniffer": "^3.9" }, "suggest": { "ext-imagick": "to generate QR code images" @@ -56,9 +57,9 @@ "homepage": "https://github.com/Bacon/BaconQrCode", "support": { "issues": "https://github.com/Bacon/BaconQrCode/issues", - "source": "https://github.com/Bacon/BaconQrCode/tree/2.0.8" + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1" }, - "time": "2022-12-07T17:46:57+00:00" + "time": "2026-04-05T21:06:35+00:00" }, { "name": "brick/math", @@ -3016,20 +3017,20 @@ }, { "name": "pixelandtonic/imagine", - "version": "1.3.3.1", + "version": "1.5.2.1", "source": { "type": "git", "url": "https://github.com/pixelandtonic/Imagine.git", - "reference": "4d9bb596ff60504e37ccf9103c0bb705dba7fec6" + "reference": "8e6c5cf929400142724b31482da51dc556277e15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pixelandtonic/Imagine/zipball/4d9bb596ff60504e37ccf9103c0bb705dba7fec6", - "reference": "4d9bb596ff60504e37ccf9103c0bb705dba7fec6", + "url": "https://api.github.com/repos/pixelandtonic/Imagine/zipball/8e6c5cf929400142724b31482da51dc556277e15", + "reference": "8e6c5cf929400142724b31482da51dc556277e15", "shasum": "" }, "require": { - "php": ">=5.5" + "php": ">=7.1" }, "require-dev": { "phpunit/phpunit": "^4.8 || ^5.7 || ^6.5 || ^7.5 || ^8.4 || ^9.3" @@ -3062,7 +3063,7 @@ "homepage": "http://avalanche123.com" } ], - "description": "Image processing for PHP 5.3", + "description": "Image processing for PHP", "homepage": "http://imagine.readthedocs.org/", "keywords": [ "drawing", @@ -3071,9 +3072,9 @@ "image processing" ], "support": { - "source": "https://github.com/pixelandtonic/Imagine/tree/1.3.3.1" + "source": "https://github.com/pixelandtonic/Imagine/tree/1.5.2.1" }, - "time": "2023-01-03T19:18:06+00:00" + "time": "2026-02-25T23:13:43+00:00" }, { "name": "pragmarx/google2fa", @@ -6706,16 +6707,16 @@ }, { "name": "twig/twig", - "version": "v3.21.1", + "version": "v3.24.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "285123877d4dd97dd7c11842ac5fb7e86e60d81d" + "reference": "a6769aefb305efef849dc25c9fd1653358c148f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/285123877d4dd97dd7c11842ac5fb7e86e60d81d", - "reference": "285123877d4dd97dd7c11842ac5fb7e86e60d81d", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/a6769aefb305efef849dc25c9fd1653358c148f0", + "reference": "a6769aefb305efef849dc25c9fd1653358c148f0", "shasum": "" }, "require": { @@ -6725,7 +6726,8 @@ "symfony/polyfill-mbstring": "^1.3" }, "require-dev": { - "phpstan/phpstan": "^2.0", + "php-cs-fixer/shim": "^3.0@stable", + "phpstan/phpstan": "^2.0@stable", "psr/container": "^1.0|^2.0", "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" }, @@ -6769,7 +6771,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.21.1" + "source": "https://github.com/twigphp/Twig/tree/v3.24.0" }, "funding": [ { @@ -6781,7 +6783,7 @@ "type": "tidelift" } ], - "time": "2025-05-03T07:21:55+00:00" + "time": "2026-03-17T21:31:11+00:00" }, { "name": "voku/portable-ascii", diff --git a/lib/composer.phar b/lib/composer.phar index 15c4a2081c9..ae06efc42e2 100755 Binary files a/lib/composer.phar and b/lib/composer.phar differ diff --git a/src/base/ApplicationTrait.php b/src/base/ApplicationTrait.php index 26de9be34f0..b959ea1c087 100644 --- a/src/base/ApplicationTrait.php +++ b/src/base/ApplicationTrait.php @@ -46,6 +46,7 @@ use craft\fieldlayoutelements\users\UsernameField; use craft\helpers\App; use craft\helpers\Db; +use craft\helpers\Markdown as MarkdownHelper; use craft\helpers\Session; use craft\i18n\Formatter; use craft\i18n\I18N; @@ -125,7 +126,6 @@ use yii\db\ColumnSchemaBuilder; use yii\db\Exception as DbException; use yii\db\Expression; -use yii\helpers\Markdown as MarkdownHelper; use yii\mutex\Mutex; use yii\queue\Queue; use yii\web\ServerErrorHttpException; @@ -1582,11 +1582,13 @@ private function _preInit(): void $this->getRequest(); $this->getLog(); + $isCpRequest = $this->getRequest()->getIsCpRequest(); + // Set the timezone - $this->_setTimeZone(); + $this->_setTimeZone($isCpRequest); // Set the language - $this->updateTargetLanguage(); + $this->updateTargetLanguage($isCpRequest); // Register the variable dumper VarDumper::setHandler(function($var) { @@ -1641,9 +1643,22 @@ private function _postInit(): void /** * Sets the system timezone. */ - private function _setTimeZone(): void + private function _setTimeZone(bool $useUserTz): void { - $timeZone = $this->getConfig()->getGeneral()->timezone ?? $this->getProjectConfig()->get('system.timeZone'); + $timeZone = null; + + if ($useUserTz && $this instanceof WebApplication) { + // If the user is logged in *and* has a preferred time zone, use that + // (don't actually try to fetch the user, as plugins haven't been loaded yet) + $id = Session::get($this->getUser()->idParam); + if ($id) { + $timeZone = $this->getUsers()->getUserPreference($id, 'timeZone'); + } + } + + if (!$timeZone) { + $timeZone = $this->getConfig()->getGeneral()->timezone ?? $this->getProjectConfig()->get('system.timeZone'); + } if ($timeZone) { $this->setTimeZone(App::parseEnv($timeZone)); diff --git a/src/base/DefaultableFieldInterface.php b/src/base/DefaultableFieldInterface.php new file mode 100644 index 00000000000..f904cfa0bb4 --- /dev/null +++ b/src/base/DefaultableFieldInterface.php @@ -0,0 +1,26 @@ + + * @since 5.10.0 + * @mixin Field + */ +interface DefaultableFieldInterface extends FieldInterface +{ + /** + * Returns the default value that should be set on existing elements. + * + * @return mixed + */ + public function getDefaultValue(): mixed; +} diff --git a/src/base/Element.php b/src/base/Element.php index 8c08866051b..b49161bf10e 100644 --- a/src/base/Element.php +++ b/src/base/Element.php @@ -33,6 +33,7 @@ use craft\elements\db\ElementQuery; use craft\elements\db\ElementQueryInterface; use craft\elements\db\NestedElementQueryInterface; +use craft\elements\deletionblockers\RelationDeletionBlocker; use craft\elements\ElementCollection; use craft\elements\Entry; use craft\elements\exporters\Expanded; @@ -47,6 +48,7 @@ use craft\events\DefineAttributeHtmlEvent; use craft\events\DefineAttributeKeywordsEvent; use craft\events\DefineEagerLoadingMapEvent; +use craft\events\DefineElementDeletionBlockersEvent; use craft\events\DefineHtmlEvent; use craft\events\DefineMenuItemsEvent; use craft\events\DefineMetadataEvent; @@ -80,6 +82,7 @@ use craft\helpers\Db; use craft\helpers\ElementHelper; use craft\helpers\Html; +use craft\helpers\Json; use craft\helpers\StringHelper; use craft\helpers\Template; use craft\helpers\UrlHelper; @@ -754,6 +757,24 @@ abstract class Element extends Component implements ElementInterface */ public const EVENT_AFTER_PROPAGATE = 'afterPropagate'; + /** + * @event DefineElementDeletionBlockersEvent The event that is triggered when defining any blockers that should prevent a user from being deleted + * + * --- + * ```php + * use craft\elements\User; + * use craft\events\DefineUserDeletionBlockersEvent; + * use yii\base\Event; + * + * Event::on(User::class, User::EVENT_DEFINE_DELETION_BLOCKERS, function(DefineElementDeletionBlockersEvent $event) { + * $event->blockers[] = // ... + * }); + * ``` + * + * @since 5.10.0 + */ + public const EVENT_DEFINE_DELETION_BLOCKERS = 'defineDeletionBlockers'; + /** * @event ModelEvent The event that is triggered before the element is deleted. * @@ -1285,6 +1306,7 @@ public static function indexHtml( 'nestedInputNamespace' => $viewState['nestedInputNamespace'] ?? null, 'tableName' => static::pluralDisplayName(), 'elementQuery' => self::elementQueryWithAllDescendants($elementQuery), + 'returnUrl' => $viewState['returnUrl'] ?? null, ]; $db = Craft::$app->getDb(); @@ -2212,6 +2234,36 @@ private static function _mapRevisionCreators(array $sourceElements): array ]; } + /** + * @inheritdoc + */ + public static function deletionBlockers(ElementCollection $elements, bool $hardDelete): array + { + $blockers = [ + new RelationDeletionBlocker(Entry::class, $elements, $hardDelete, [ + 'elementIndexSettings' => [ + 'defaultTableColumns' => [ + ['section'], + ], + 'defaultSort' => ['section', 'asc'], + ], + ]), + ]; + + // Fire a 'defineDeletionBlockers' event + if (Event::hasHandlers(static::class, self::EVENT_DEFINE_DELETION_BLOCKERS)) { + $event = new DefineElementDeletionBlockersEvent([ + 'elements' => $elements, + 'hardDelete' => $hardDelete, + 'blockers' => $blockers, + ]); + Event::trigger(static::class, self::EVENT_DEFINE_DELETION_BLOCKERS, $event); + $blockers = $event->blockers; + } + + return $blockers; + } + /** * @inheritdoc */ @@ -2399,7 +2451,7 @@ private static function _indexOrderByColumns( /** * @var bool */ - private bool $_initialized = false; + private bool $_trackDirtyFields = false; /** * @var string|null @@ -2722,7 +2774,7 @@ public function init(): void $this->_savedTitle = $this->title; } - $this->_initialized = true; + $this->_trackDirtyFields = true; // Stop allowing setting custom field values directly on the behavior /** @var CustomFieldBehavior $behavior */ @@ -3946,12 +3998,20 @@ public function getAltActions(): array $elementsService = Craft::$app->getElements(); $canSaveCanonical = $elementsService->canSaveCanonical($this); + $returnUrl = Craft::$app->getRequest()->getQueryParam('returnUrl'); + $redirectParams = array_filter([ + 'returnUrl' => $returnUrl, + ]); + $altActions = [ [ 'label' => $isUnpublishedDraft && $canSaveCanonical ? Craft::t('app', 'Create and continue editing') : Craft::t('app', 'Save and continue editing'), 'redirect' => '{cpEditUrl}', + 'params' => array_filter([ + 'redirectParams' => !empty($redirectParams) ? Json::encode($redirectParams) : null, + ]), 'shortcut' => true, 'retainScroll' => true, 'eventData' => ['autosave' => false], @@ -3968,7 +4028,10 @@ public function getAltActions(): array 'shortcut' => true, 'shift' => true, 'eventData' => ['autosave' => false], - 'params' => ['addAnother' => 1], + 'params' => [ + 'addAnother' => 1, + 'returnUrl' => $returnUrl, + ], ]; } @@ -3993,11 +4056,21 @@ public function getAltActions(): array 'params' => [ 'asUnpublishedDraft' => true, 'deleteProvisionalDraft' => true, + 'redirectParams' => !empty($redirectParams) ? Json::encode($redirectParams) : null, ], ]; } } + if ($this->getIsDraft() && !$this->getIsUnpublishedDraft() && !$this->isProvisionalDraft) { + $altActions[] = [ + 'label' => Craft::t('app', 'Save as a new {type}', [ + 'type' => Craft::t('app', 'draft'), + ]), + 'action' => 'elements/duplicate', + ]; + } + // Fire a 'defineAltActions' event if ($this->hasEventHandlers(self::EVENT_DEFINE_ALT_ACTIONS)) { $event = new DefineAltActionsEvent([ @@ -4242,22 +4315,47 @@ protected function destructiveActionMenuItems(): array // Delete if ($canDeleteCanonical) { + $view = Craft::$app->getView(); + $deleteId = sprintf('action-delete-%s', mt_rand()); $items[] = [ + 'id' => $deleteId, 'icon' => 'trash', 'label' => StringHelper::upperCaseFirst(Craft::t('app', 'Delete {type}', [ 'type' => $isUnpublishedDraft ? Craft::t('app', 'draft') : static::lowerDisplayName(), ])), - 'action' => $isUnpublishedDraft ? 'elements/delete-draft' : 'elements/delete', - 'params' => [ - 'elementId' => $this->getCanonicalId(), - 'siteId' => $this->siteId, - ], - 'redirect' => "$redirectUrl#", - 'confirm' => Craft::t('app', 'Are you sure you want to delete this {type}?', [ - 'type' => $isUnpublishedDraft ? Craft::t('app', 'draft') : static::lowerDisplayName(), - ]), - 'destructive' => true, ]; + + $view->registerJsWithVars(fn( + $id, + $elementType, + $elementId, + $siteId, + $ownerId, + $confirmationMessage, + $redirect, + ) => << { + new Craft.ElementDeletionManager($elementType, [$elementId], { + siteId: $siteId, + ownerId: $ownerId, + confirmationMessage: $confirmationMessage, + onSuccess: () => { + document.location.href = $redirect; + }, + }); +}); +JS, + [ + $view->namespaceInputId($deleteId), + static::class, + $this->id, + $this->siteId, + $this instanceof NestedElementInterface ? $this->getOwnerId() : null, + Craft::t('app', 'Are you sure you want to delete this {type}?', [ + 'type' => $isDraft ? Craft::t('app', 'draft') : static::lowerDisplayName(), + ]), + "$redirectUrl#", + ]); } } elseif ($isDraft && $canDeleteDraft) { // Delete draft for site @@ -5305,7 +5403,7 @@ public function setFieldValue(string $fieldHandle, mixed $value): void unset($this->_normalizedFieldValues[$fieldHandle]); // If the element is fully initialized, mark the value as dirty - if ($this->_initialized) { + if ($this->_trackDirtyFields) { $this->_dirtyFields[$fieldHandle] = true; } @@ -5335,6 +5433,14 @@ public function setFieldValueFromRequest(string $fieldHandle, mixed $value): voi $this->_normalizedFieldValues[$field->handle] = true; } + /** + * @inheritdoc + */ + public function setDirtyFieldTracking(bool $enabled = true): void + { + $this->_trackDirtyFields = $enabled; + } + /** * @inheritdoc */ @@ -6454,10 +6560,10 @@ public function getMetadata(): array }, ], $metadata, [ Craft::t('app', 'Created at') => $this->dateCreated && !$this->getIsUnpublishedDraft() - ? $formatter->asDatetime($this->dateCreated, Formatter::FORMAT_WIDTH_SHORT) + ? $formatter->asDatetime($this->dateCreated, Formatter::FORMAT_WIDTH_SHORT, true) : false, Craft::t('app', 'Updated at') => $this->dateUpdated && !$this->getIsUnpublishedDraft() - ? $formatter->asDatetime($this->dateUpdated, Formatter::FORMAT_WIDTH_SHORT) + ? $formatter->asDatetime($this->dateUpdated, Formatter::FORMAT_WIDTH_SHORT, true) : false, Craft::t('app', 'Notes') => function() { if ($this->getIsRevision()) { diff --git a/src/base/ElementAction.php b/src/base/ElementAction.php index 83ea1a59233..1a87756a1a6 100644 --- a/src/base/ElementAction.php +++ b/src/base/ElementAction.php @@ -52,6 +52,14 @@ public function setElementType(string $elementType): void $this->elementType = $elementType; } + /** + * @inheritdoc + */ + public function getTriggerId(): string + { + return sprintf('%s-actiontrigger', static::class); + } + /** * @inheritdoc */ diff --git a/src/base/ElementActionInterface.php b/src/base/ElementActionInterface.php index ce2ef8c577e..488fc643393 100644 --- a/src/base/ElementActionInterface.php +++ b/src/base/ElementActionInterface.php @@ -45,6 +45,19 @@ public static function isDownload(): bool; */ public function setElementType(string $elementType): void; + /** + * Returns the ID the trigger element should have. + * + * This should be overridden with something unique if the same action can be + * included multiple times for the same elements. + * + * If this is overridden, ensure you configure the `Craft.ElementActionTrigger` + * JavaScript object with a `triggerId` setting, set to the same value. + * + * @return string + */ + public function getTriggerId(): string; + /** * Returns the action’s trigger label. * diff --git a/src/base/ElementInterface.php b/src/base/ElementInterface.php index 4eaad2e808b..37b0c6140b9 100644 --- a/src/base/ElementInterface.php +++ b/src/base/ElementInterface.php @@ -11,6 +11,7 @@ use craft\elements\conditions\ElementConditionInterface; use craft\elements\db\EagerLoadPlan; use craft\elements\db\ElementQueryInterface; +use craft\elements\deletionblockers\DeletionBlockerInterface; use craft\elements\ElementCollection; use craft\elements\User; use craft\enums\AttributeStatus; @@ -650,6 +651,16 @@ public static function attributePreviewHtml(array $attribute): mixed; */ public static function eagerLoadingMap(array $sourceElements, string $handle): array|null|false; + /** + * Returns any deletion blockers for the given elements. + * + * @param ElementCollection $elements The elements to be deleted + * @param bool $hardDelete Whether the elements will be hard-deleted + * @return DeletionBlockerInterface[] + * @since 5.10.0 + */ + public static function deletionBlockers(ElementCollection $elements, bool $hardDelete): array; + /** * Returns the base GraphQL type name that represents elements of this type. * @@ -1523,6 +1534,15 @@ public function setFieldValue(string $fieldHandle, mixed $value): void; */ public function setFieldValueFromRequest(string $fieldHandle, mixed $value): void; + /** + * Enables or disables dirty field tracking. + * + * @param bool $enabled + * @see getDirtyFields() + * @since 5.10.0 + */ + public function setDirtyFieldTracking(bool $enabled = true): void; + /** * Returns the field handles that have been updated on the canonical element since the last time it was * merged into this element. diff --git a/src/base/conditions/BaseNumberConditionRule.php b/src/base/conditions/BaseNumberConditionRule.php index c0c226c376b..ad2d81a4910 100644 --- a/src/base/conditions/BaseNumberConditionRule.php +++ b/src/base/conditions/BaseNumberConditionRule.php @@ -58,6 +58,8 @@ protected function operators(): array self::OPERATOR_BETWEEN, self::OPERATOR_NOT_EMPTY, self::OPERATOR_EMPTY, + self::OPERATOR_IN, + self::OPERATOR_NOT_IN, ]; } @@ -128,7 +130,7 @@ protected function inputHtml(): string /** * @inheritdoc */ - protected function paramValue(): ?string + protected function paramValue(): string|array|null { if ($this->operator === self::OPERATOR_BETWEEN) { if (empty($this->value) && empty($this->maxValue)) { diff --git a/src/base/conditions/BaseTextConditionRule.php b/src/base/conditions/BaseTextConditionRule.php index c9793e7f4cb..74f16c07c2e 100644 --- a/src/base/conditions/BaseTextConditionRule.php +++ b/src/base/conditions/BaseTextConditionRule.php @@ -2,9 +2,11 @@ namespace craft\base\conditions; +use craft\helpers\ArrayHelper; use craft\helpers\Cp; use craft\helpers\Db; use craft\helpers\Html; +use craft\helpers\Json; use craft\helpers\StringHelper; use yii\base\InvalidConfigException; @@ -41,6 +43,20 @@ public function getConfig(): array ]); } + public function __set($name, $value) + { + if ( + $name === 'attributes' && + isset($value['operator'], $value['value']) && + in_array($value['operator'], [self::OPERATOR_IN, self::OPERATOR_NOT_IN]) && + is_array($value['value']) + ) { + $value['value'] = Json::encode($value['value']); + } + + parent::__set($name, $value); + } + /** * Returns the operators that should be allowed for this rule. * @@ -50,11 +66,14 @@ protected function operators(): array { return [ self::OPERATOR_EQ, + self::OPERATOR_NE, self::OPERATOR_BEGINS_WITH, self::OPERATOR_ENDS_WITH, self::OPERATOR_CONTAINS, self::OPERATOR_NOT_EMPTY, self::OPERATOR_EMPTY, + self::OPERATOR_IN, + self::OPERATOR_NOT_IN, ]; } @@ -77,6 +96,11 @@ protected function inputHtml(): string if ($this->operator === self::OPERATOR_EMPTY || $this->operator === self::OPERATOR_NOT_EMPTY) { return ''; } + + if (in_array($this->operator, [self::OPERATOR_IN, self::OPERATOR_NOT_IN])) { + return Cp::selectizeHtml($this->inputOptions()); + } + return Html::hiddenLabel(Html::encode($this->getLabel()), 'value') . Cp::textHtml($this->inputOptions()); @@ -90,14 +114,37 @@ protected function inputHtml(): string */ protected function inputOptions(): array { - return [ - 'type' => $this->inputType(), - 'id' => 'value', + $defaults = [ + 'id' => 'value' . mt_rand(), 'name' => 'value', - 'value' => $this->value, - 'autocomplete' => false, 'class' => 'flex-grow flex-shrink', ]; + + if (in_array($this->operator, [self::OPERATOR_IN, self::OPERATOR_NOT_IN])) { + $values = Json::decodeIfJson($this->value); + $values = is_array($values) ? array_values($values) : []; + + return [...$defaults, ...[ + 'values' => $values, + 'options' => array_map(fn($v) => ['value' => $v, 'label' => $v], $values), + 'multi' => true, + 'allowEmptyOption' => true, + 'selectizeOptions' => [ + 'create' => true, + 'persist' => false, + 'createOnBlur' => true, + ], + ]]; + } + + return [ + ...$defaults, + ...[ + 'type' => $this->inputType(), + 'value' => $this->value, + 'autocomplete' => false, + ], + ]; } /** @@ -113,15 +160,23 @@ protected function defineRules(): array /** * Returns the rule’s value, prepped for [[Db::parseParam()]] based on the selected operator. * - * @return string|null + * @return array|string|null */ - protected function paramValue(): ?string + protected function paramValue(): string|array|null { switch ($this->operator) { case self::OPERATOR_EMPTY: return ':empty:'; case self::OPERATOR_NOT_EMPTY: return 'not :empty:'; + case self::OPERATOR_IN: + return Json::decodeIfJson($this->value); + case self::OPERATOR_NOT_IN: + $value = Json::decodeIfJson($this->value); + $value = is_array($value) ? $value : []; + ArrayHelper::prependOrAppend($value, 'not', true); + + return $value; } if ($this->value === '') { @@ -167,6 +222,8 @@ protected function matchValue(mixed $value): bool self::OPERATOR_BEGINS_WITH => is_string($value) && StringHelper::startsWith($value, $this->value, false), self::OPERATOR_ENDS_WITH => is_string($value) && StringHelper::endsWith($value, $this->value, false), self::OPERATOR_CONTAINS => is_string($value) && StringHelper::contains($value, $this->value, false), + self::OPERATOR_IN => in_array($value, Json::decodeIfJson($this->value)), + self::OPERATOR_NOT_IN => !in_array($value, Json::decodeIfJson($this->value)), default => throw new InvalidConfigException("Invalid operator: $this->operator"), }; } diff --git a/src/config/app.php b/src/config/app.php index 67a38857c1e..4ffa0195b73 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -4,7 +4,7 @@ 'id' => 'CraftCMS', 'name' => 'Craft CMS', 'version' => '5.9.22', - 'schemaVersion' => '5.9.0.8', + 'schemaVersion' => '5.10.0.0', 'minVersionRequired' => '4.5.0', 'basePath' => dirname(__DIR__), // Defines the @app alias 'runtimePath' => '@storage/runtime', // Defines the @runtime alias diff --git a/src/console/controllers/ResaveController.php b/src/console/controllers/ResaveController.php index 18b5922046f..9f878a58980 100644 --- a/src/console/controllers/ResaveController.php +++ b/src/console/controllers/ResaveController.php @@ -8,8 +8,10 @@ namespace craft\console\controllers; use Craft; +use craft\base\DefaultableFieldInterface; use craft\base\Element; use craft\base\ElementInterface; +use craft\base\FieldInterface; use craft\console\Controller; use craft\elements\Address; use craft\elements\Asset; @@ -217,7 +219,7 @@ final public static function normalizeTo(?string $to): callable public ?string $countryCode = null; /** - * @var string[] Only resave elements that have custom fields with these global field handles. + * @var string[]|FieldInterface[] Only resave elements that have custom fields with these global field handles. * @since 5.5.0 */ public array $withFields = []; @@ -248,6 +250,12 @@ final public static function normalizeTo(?string $to): callable */ public ?string $to = null; + /** + * @var bool Sets the specified fields to their default values. + * @since 5.10.0 + */ + public bool $toDefault = false; + /** * @var bool Whether the `--set` attribute should only be set if it doesn’t have a value. * @since 3.7.29 @@ -313,6 +321,7 @@ public function options($actionID): array $options[] = 'set'; $options[] = 'to'; + $options[] = 'toDefault'; $options[] = 'ifEmpty'; $options[] = 'ifInvalid'; @@ -357,11 +366,51 @@ public function beforeAction($action): bool } } - if (isset($this->set) && !isset($this->to)) { - $this->stderr('--to is required when using --set.' . PHP_EOL, Console::FG_RED); + if (isset($this->set) && !isset($this->to) && !$this->toDefault) { + $this->stderr('--to or --to-default is required when using --set.' . PHP_EOL, Console::FG_RED); return false; } + if (!empty($this->withFields)) { + $fieldsService = Craft::$app->getFields(); + + foreach ($this->withFields as $i => $field) { + if (!$field instanceof FieldInterface) { + $handle = $field; + $field = $fieldsService->getFieldByHandle($handle); + if (!$field) { + $this->stderr("Invalid field: `$handle`" . PHP_EOL, Console::FG_RED); + return false; + } + } + $this->withFields[$i] = $field; + } + } + + if ($this->toDefault) { + if (empty($this->withFields) && !isset($this->set)) { + $this->stderr('--with-fields or --set is required when using --to-default.' . PHP_EOL, Console::FG_RED); + return false; + } + + $fieldsService = Craft::$app->getFields(); + + if (isset($this->set)) { + $field = $fieldsService->getFieldByHandle($this->set); + if (!$field) { + $this->stderr("Invalid field handle: $this->set", Console::FG_RED); + return false; + } + } else { + foreach ($this->withFields as $field) { + if (!$field instanceof DefaultableFieldInterface) { + $this->stderr("$field->handle doesn’t support --to-default." . PHP_EOL, Console::FG_RED); + return false; + } + } + } + } + return true; } @@ -631,10 +680,8 @@ public function actionUsers(): int */ public function hasTheFields(FieldLayout $fieldLayout): bool { - $fieldsService = Craft::$app->getFields(); - foreach ($this->withFields as $handle) { - $field = $fieldsService->getFieldByHandle($handle); - if ($field && $fieldLayout->getFieldByUid($field->uid)) { + foreach ($this->withFields as $field) { + if ($fieldLayout->getFieldByUid($field->uid)) { return true; } } @@ -655,8 +702,10 @@ public function resaveElements(string $elementType, array $criteria = []): int Queue::push(new ResaveElements([ 'elementType' => $elementType, 'criteria' => $criteria, + 'withFields' => array_map(fn(FieldInterface $field) => $field->handle, $this->withFields), 'set' => $this->set, 'to' => $this->to, + 'toDefault' => $this->toDefault, 'ifEmpty' => $this->ifEmpty, 'ifInvalid' => $this->ifInvalid, 'touch' => $this->touch, @@ -785,7 +834,37 @@ private function _resaveElements(ElementQueryInterface $query): int } try { - if (isset($this->set)) { + if ($this->toDefault) { + if ($this->set) { + $fields = [$element->getFieldLayout()?->getFieldByHandle($this->set)]; + } else { + $fields = array_map( + fn(FieldInterface $field) => $element->getFieldLayout()?->getFieldByUid($field->uid), + $this->withFields, + ); + } + + $fields = array_filter($fields, fn(?FieldInterface $field) => $field instanceof DefaultableFieldInterface); + + foreach ($fields as $field) { + $set = true; + if ($this->ifEmpty) { + if (!ElementHelper::isAttributeEmpty($element, $field->handle)) { + $set = false; + } + } elseif ($this->ifInvalid) { + $element->setScenario(Element::SCENARIO_LIVE); + if ($element->validate("field:$field->handle")) { + $set = false; + } + } + + if ($set) { + /** @var DefaultableFieldInterface $field */ + $element->setFieldValue($field->handle, $field->getDefaultValue()); + } + } + } elseif (isset($this->set)) { $set = true; if ($this->ifEmpty) { if (!ElementHelper::isAttributeEmpty($element, $this->set)) { diff --git a/src/console/controllers/UsersController.php b/src/console/controllers/UsersController.php index 4a4461f43ed..49b4f778454 100644 --- a/src/console/controllers/UsersController.php +++ b/src/console/controllers/UsersController.php @@ -97,6 +97,12 @@ class UsersController extends Controller */ public bool $hard = false; + /** + * @var string|null The name of the two-step verification method you would like to remove for user, e.g. Authenticator App, Recovery Codes. Use "all" to remove all 2FA methods. + * @since 5.9.21 + */ + public ?string $method = null; + /** * @inheritdoc */ @@ -122,6 +128,9 @@ public function options($actionID): array case 'set-password': $options[] = 'password'; break; + case 'remove-2fa': + $options[] = 'method'; + break; } return $options; @@ -549,13 +558,22 @@ public function remove2fa(string $user): int return ExitCode::OK; } - $methodToRemove = $this->select( - "Which two-step verification method would you like to remove for user “{$user->username}”", - [ - 'all' => 'all', - ...array_combine(array_keys($activeMethods), array_keys($activeMethods)), - ], - ); + // if method was provided, check if it's in the active methods; if so - use it + if ($this->method) { + if ($this->method !== 'all' && !isset($activeMethods[$this->method])) { + $this->stdout("User “{$user->username}” doesn’t have the “{$this->method}” two-step verification method." . PHP_EOL); + return ExitCode::OK; + } + $methodToRemove = $this->method; + } else { + $methodToRemove = $this->select( + "Which two-step verification method would you like to remove for user “{$user->username}”", + [ + 'all' => 'all', + ...array_combine(array_keys($activeMethods), array_keys($activeMethods)), + ], + ); + } if ($methodToRemove === 'all') { $this->stdout('Removing all two-step verification methods for the user ...' . PHP_EOL); diff --git a/src/controllers/AppController.php b/src/controllers/AppController.php index 83423b275f6..f32048f5f5d 100644 --- a/src/controllers/AppController.php +++ b/src/controllers/AppController.php @@ -28,10 +28,7 @@ use craft\helpers\ElementHelper; use craft\helpers\Html; use craft\helpers\Json; -use craft\helpers\Path; use craft\helpers\Search; -use craft\helpers\Session; -use craft\helpers\StringHelper; use craft\helpers\Update as UpdateHelper; use craft\helpers\UrlHelper; use craft\models\Update; @@ -111,49 +108,6 @@ public function actionHealthCheck(): Response return $this->response; } - /** - * Loads the given JavaScript resource URL and returns it. - * - * @param string $url - * @return Response - */ - public function actionResourceJs(string $url): Response - { - $assetManager = Craft::$app->getAssetManager(); - $baseUrl = StringHelper::ensureRight($assetManager->baseUrl, '/'); - if (!str_starts_with($url, $baseUrl)) { - throw new BadRequestHttpException("$url does not appear to be a resource URL"); - } - - $resourceUri = preg_replace('/^(.*)\?.*/', '$1', substr($url, strlen($baseUrl))); - - if (!Path::ensurePathIsContained($resourceUri)) { - throw new BadRequestHttpException("Invalid resource: $resourceUri"); - } - - // If we aren’t caching source paths in the resourcepaths table, - // then we’re going to have to fetch the file over HTTP - if (!$assetManager->cacheSourcePaths) { - // Close the PHP session in case this takes a while - Session::close(); - - $response = Craft::createGuzzleClient()->get($url); - $this->response->setCacheHeaders(); - $this->response->getHeaders()->set('content-type', 'application/javascript'); - return $this->asRaw($response->getBody()); - } - - try { - $publishedPath = App::resourcePathByUri($resourceUri); - } catch (InvalidArgumentException $e) { - throw new BadRequestHttpException($e->getMessage(), previous: $e); - } - - return $this->response->sendFile($publishedPath, null, [ - 'inline' => true, - ]); - } - /** * Returns the HTML for a control panel icon. * diff --git a/src/controllers/AssetsController.php b/src/controllers/AssetsController.php index 0b1ca900997..b88ce6e55ca 100644 --- a/src/controllers/AssetsController.php +++ b/src/controllers/AssetsController.php @@ -489,7 +489,11 @@ public function actionReplaceFile(): Response 'filename' => $resultingAsset->getFilename(), 'formattedSize' => $resultingAsset->getFormattedSize(0), 'formattedSizeInBytes' => $resultingAsset->getFormattedSizeInBytes(false), - 'formattedDateUpdated' => Craft::$app->getFormatter()->asDatetime($resultingAsset->dateUpdated, Formatter::FORMAT_WIDTH_SHORT), + 'formattedDateUpdated' => Craft::$app->getFormatter()->asDatetime( + $resultingAsset->dateUpdated, + Formatter::FORMAT_WIDTH_SHORT, + true, + ), 'dimensions' => $resultingAsset->getDimensions(), 'updatedTimestamp' => $resultingAsset->dateUpdated->getTimestamp(), 'resultingUrl' => $resultingAsset->getUrl(), diff --git a/src/controllers/DeleteElementsController.php b/src/controllers/DeleteElementsController.php new file mode 100644 index 00000000000..8af532c4d16 --- /dev/null +++ b/src/controllers/DeleteElementsController.php @@ -0,0 +1,287 @@ + + * @since 5.10.0 + */ +class DeleteElementsController extends Controller +{ + /** + * @var class-string + */ + protected string $elementType; + /** + * @var ElementCollection + */ + protected ElementCollection $elements; + protected bool $hardDelete; + + /** + * @inheritdoc + */ + public function beforeAction($action): bool + { + if (!parent::beforeAction($action)) { + return false; + } + + $this->requireCpRequest(); + + $this->elementType = $this->request->getRequiredParam('elementType'); + $this->hardDelete = App::normalizeBooleanValue($this->request->getParam('hardDelete')) ?? false; + + if (!Component::validateComponentClass($this->elementType, ElementInterface::class)) { + throw new BadRequestHttpException("Invalid element type: $this->elementType"); + } + + $this->elements = $this->elements(); + + return true; + } + + private function elements(): ElementCollection + { + $elementIds = array_map(fn($id) => (int)$id, $this->request->getRequiredParam('elementIds')); + $siteId = $this->request->getParam('siteId'); + + $query = $this->elementType::find() + ->id($elementIds) + ->siteId($siteId ?? '*') + ->unique() + ->status(null) + ->drafts(null) + ->savedDraftsOnly(false) + ->trashed($this->hardDelete); + + $withDescendants = !$this->hardDelete && $this->request->getParam('withDescendants'); + if ($withDescendants) { + $query + ->with([ + [ + 'descendants', + [ + 'orderBy' => ['structureelements.lft' => SORT_DESC], + 'status' => null, + ], + ], + ]) + ->orderBy(['structureelements.lft' => SORT_DESC]); + } + + if ($query instanceof NestedElementQueryInterface) { + $ownerId = $this->request->getParam('ownerId'); + $query->ownerId($ownerId); + } + + $elements = []; + $elementIds = []; + $user = static::currentUser(); + $elementsService = Craft::$app->getElements(); + + foreach ($query->all() as $element) { + if ( + isset($elementIds[$element->id]) || + !$elementsService->canView($element, $user) || + !$elementsService->canDelete($element, $user) + ) { + continue; + } + + $elements[] = $element; + $elementIds[$element->id] = true; + + if ($withDescendants) { + foreach ($element->getDescendants()->all() as $descendant) { + if ( + isset($elementIds[$descendant->id]) || + !$elementsService->canView($descendant, $user) || + !$elementsService->canDelete($descendant, $user) + ) { + continue; + } + + $elements[] = $descendant; + $elementIds[$descendant->id] = true; + } + } + } + + return ElementCollection::make($elements); + } + + /** + * Returns any issues that should block the posted elements from being deleted. + */ + public function actionDeletionBlockers(): Response + { + $this->requirePostRequest(); + + $elements = $this->elements; + + if (is_subclass_of($this->elementType, NestedElementInterface::class)) { + // filter out elements that primarily belong to a different element, + // as they won't actually be getting deleted + /** @phpstan-ignore-next-line */ + $elements = $elements->filter(fn(NestedElementInterface $element) => $this->elementOwnedByPrimaryOwner($element)); + } + + $blockers = Collection::make($this->elementType::deletionBlockers($elements, $this->hardDelete)) + ->filter(fn(DeletionBlockerInterface $blocker) => $blocker->isActive()) + ->map(fn(DeletionBlockerInterface $blocker) => [ + 'summary' => $blocker->getSummary(), + 'details' => $blocker->getDetails(), + 'actions' => $blocker->getActions(), + ]) + ->values() + ->all(); + + $elementPreview = Cp::elementPreviewHtml( + elements: $this->elements->all(), + showStatus: false, + ); + + return $this->asJson([ + 'blockers' => $blockers, + 'elementPreview' => $elementPreview, + 'totalElements' => $elements->count(), + 'headHtml' => $this->view->getHeadHtml(), + 'bodyHtml' => $this->view->getBodyHtml(), + ]); + } + + /** + * Deletes the posted elements. + */ + public function actionDelete(): Response + { + $this->requirePostRequest(); + + $deleteOwnership = []; + $elementsService = Craft::$app->getElements(); + + foreach ($this->elements as $element) { + if ( + $element instanceof NestedElementInterface && + !$this->elementOwnedByPrimaryOwner($element) + ) { + $deleteOwnership[$element->getOwnerId()][] = $element->id; + continue; + } + + $elementsService->deleteElement($element, $this->hardDelete); + } + + foreach ($deleteOwnership as $ownerId => $elementIds) { + Db::delete(Table::ELEMENTS_OWNERS, [ + 'elementId' => $elementIds, + 'ownerId' => $ownerId, + ]); + } + + return $this->asJson([]); + } + + private function elementOwnedByPrimaryOwner(NestedElementInterface $element): bool + { + $ownerId = $element->getOwnerId(); + return !$ownerId || $element->getPrimaryOwnerId() === $ownerId; + } + + public function actionReplaceRelationsModal(): Response + { + $this->requireAcceptsJson(); + + /** @var class-string $sourceElementType */ + $sourceElementType = $this->request->getRequiredParam('sourceElementType'); + $targetElementIds = $this->elements->ids(); + + return $this->asCpModal() + ->action('delete-elements/replace-relations') + ->contentHtml(fn() => + Cp::elementSelectFieldHtml([ + 'label' => Craft::t('app', 'Choose a new {type}', [ + 'type' => $this->elementType::lowerDisplayName(), + ]), + 'name' => 'newTargetId', + 'elementType' => $this->elementType, + 'criteria' => [ + 'id' => $targetElementIds->map(fn(int $id) => "not $id")->all(), + ], + 'single' => true, + ]) . + Html::hiddenInput('elementType', $this->elementType) . + $targetElementIds->map(fn(int $id) => Html::hiddenInput('elementIds[]', (string)$id))->join('') . + Html::hiddenInput('hardDelete', $this->hardDelete ? '1' : '0') . + Html::hiddenInput('sourceElementType', $sourceElementType) + ) + ->submitButtonLabel(Craft::t('app', 'Replace')); + } + + public function actionReplaceRelations(): Response + { + $this->requirePostRequest(); + $this->requireAcceptsJson(); + + /** @var class-string $sourceElementType */ + $sourceElementType = $this->request->getRequiredBodyParam('sourceElementType'); + $newTargetId = $this->request->getBodyParam('newTargetId'); + + if (!$newTargetId) { + return $this->asFailure(Craft::t('app', 'No new {type} selected.', [ + 'type' => $this->elementType::lowerDisplayName(), + ])); + } + + $oldTargetIds = $this->elements->ids()->all(); + $sourceIds = $sourceElementType::find() + ->siteId('*') + ->unique() + ->relatedTo(['targetElement' => $oldTargetIds]) + ->status(null) + ->drafts(null) + ->withProvisionalDrafts() + ->revisions(null) + ->ids(); + + Queue::push(new ReplaceRelations([ + 'sourceElementType' => $sourceElementType, + 'targetElementType' => $this->elementType, + 'sourceIds' => $sourceIds, + 'oldTargetIds' => $oldTargetIds, + 'newTargetId' => $newTargetId, + ])); + + return $this->asSuccess(Craft::t('app', '{numRelations, plural, =1{Relation} other{Relations}} queued to be replaced.', [ + 'numRelations' => count($sourceIds), + ])); + } +} diff --git a/src/controllers/ElementIndexesController.php b/src/controllers/ElementIndexesController.php index 139a1746c3c..755ab060501 100644 --- a/src/controllers/ElementIndexesController.php +++ b/src/controllers/ElementIndexesController.php @@ -884,6 +884,7 @@ protected function elementResponseData(bool $includeContainer, bool $includeActi [ ...$this->viewState, 'fieldLayouts' => $this->fieldLayouts, + 'returnUrl' => $this->request->getParam('returnUrl'), ], $this->sourceKey, $this->context, diff --git a/src/controllers/ElementsController.php b/src/controllers/ElementsController.php index 53b5786520f..65afafbac81 100644 --- a/src/controllers/ElementsController.php +++ b/src/controllers/ElementsController.php @@ -35,6 +35,7 @@ use craft\helpers\ElementHelper; use craft\helpers\Html; use craft\helpers\Json; +use craft\helpers\Markdown; use craft\helpers\StringHelper; use craft\helpers\Template; use craft\helpers\UrlHelper; @@ -48,7 +49,6 @@ use craft\web\View; use Illuminate\Support\Collection; use Throwable; -use yii\helpers\Markdown; use yii\web\BadRequestHttpException; use yii\web\ForbiddenHttpException; use yii\web\Response; @@ -368,7 +368,19 @@ public function actionEdit(?ElementInterface $element, ?int $elementId = null): [$docTitle, $title] = $this->_editElementTitles($element); $enabledForSite = $element->getEnabledForSite(); $hasRoute = $element->getRoute() !== null; - $redirectUrl = $this->request->getValidatedQueryParam('returnUrl') ?? UrlHelper::cpReferralUrl() ?? ElementHelper::postEditUrl($element); + + $redirectUrl = $this->request->getQueryParam('returnUrl'); + if ($redirectUrl) { + // only require the URL to be hashed if it contains Twig code + $validated = Craft::$app->getSecurity()->validateData($redirectUrl); + if ($validated !== false) { + $redirectUrl = $validated; + } elseif (str_contains($redirectUrl, '{')) { + throw new BadRequestHttpException("Invalid returnUrl param: $redirectUrl"); + } + } else { + $redirectUrl = ElementHelper::postEditUrl($element); + } // Site statuses if ($canEditMultipleSites) { @@ -894,7 +906,7 @@ private function _contextMenuItems( /** @var ElementInterface&DraftBehavior $draft */ $creator = $draft->getCreator(); $timestamp = $formatter->asTimestamp($draft->dateUpdated, Locale::LENGTH_SHORT, true); - $timestampWithDate = $formatter->asDatetime($draft->dateUpdated, Locale::LENGTH_SHORT); + $timestampWithDate = $formatter->asDatetime($draft->dateUpdated, Locale::LENGTH_SHORT, true); return [ 'label' => $draft->draftName, @@ -925,7 +937,7 @@ private function _contextMenuItems( /** @var ElementInterface&RevisionBehavior $revision */ $creator = $revision->getCreator(); $timestamp = $formatter->asTimestamp($revision->dateCreated, Locale::LENGTH_SHORT, true); - $timestampWithDate = $formatter->asDatetime($revision->dateCreated, Locale::LENGTH_SHORT); + $timestampWithDate = $formatter->asDatetime($revision->dateCreated, Locale::LENGTH_SHORT, true); return [ 'label' => $revision->getRevisionLabel(), @@ -1044,9 +1056,16 @@ private function _additionalButtons( // Revert content from this revision if ($isRevision && $canSaveCanonical && $element->hasRevisions()) { + $returnUrl = $this->request->getQueryParam('returnUrl'); $components[] = Html::beginForm() . Html::actionInput('elements/revert') . Html::redirectInput('{cpEditUrl}') . + ($returnUrl + ? Html::hiddenInput('redirectParams', Json::encode([ + 'returnUrl' => $returnUrl, + ])) + : '' + ) . Html::hiddenInput('elementId', (string)$canonical->id) . Html::hiddenInput('revisionId', (string)$element->revisionId) . Html::button(Craft::t('app', 'Revert content from this revision'), [ @@ -1644,9 +1663,11 @@ public function actionDuplicate(): ?Response $elementsService = Craft::$app->getElements(); $user = static::currentUser(); + $isExplicitDraft = $element->getIsDraft() && !$element->getIsUnpublishedDraft() && !$element->isProvisionalDraft; + // save as a new is now available to people who can create drafts $asUnpublishedDraft = $this->_asUnpublishedDraft && $element::hasDrafts(); - if ($asUnpublishedDraft) { + if ($asUnpublishedDraft || $isExplicitDraft) { $authorized = $elementsService->canDuplicateAsDraft($element, $user); } else { $authorized = $elementsService->canDuplicate($element, $user); @@ -1658,7 +1679,7 @@ public function actionDuplicate(): ?Response $newAttributes = [ 'isProvisionalDraft' => false, - 'draftId' => null, + 'draftId' => $isExplicitDraft ? $element->draftId : null, ]; if ($asUnpublishedDraft && @@ -2442,6 +2463,7 @@ public function actionUpdateFieldLayout(): ?Response $data += [ 'initialDeltaValues' => Craft::$app->getView()->getInitialDeltaValues(), + 'uiLabel' => $this->element->getUiLabel(), ]; return $this->_asSuccess('Field layout updated.', $element, $data, true); @@ -3024,6 +3046,11 @@ private function _asSuccess( ]); } + $returnUrl = $this->request->getParam('returnUrl'); + if ($returnUrl) { + $url = UrlHelper::urlWithParams($url, ['returnUrl' => $returnUrl]); + } + $response->redirect($url); } diff --git a/src/controllers/EntriesController.php b/src/controllers/EntriesController.php index b781d42acc6..e7762a8380a 100644 --- a/src/controllers/EntriesController.php +++ b/src/controllers/EntriesController.php @@ -12,6 +12,7 @@ use craft\db\Query; use craft\db\Table; use craft\elements\Entry; +use craft\elements\User; use craft\enums\PropagationMethod; use craft\errors\InvalidElementException; use craft\errors\MutexException; @@ -172,14 +173,9 @@ public function actionCreate(?string $section = null): ?Response $entry->slug = ElementHelper::tempSlug(); } - // Pause time so postDate will definitely be equal to dateCreated, if not explicitly defined - DateTimeHelper::pause(); - // Post & expiry dates if (($postDate = $this->request->getParam('postDate')) !== null) { $entry->postDate = DateTimeHelper::toDateTime($postDate); - } else { - $entry->postDate = DateTimeHelper::now(); } if (($expiryDate = $this->request->getParam('expiryDate')) !== null) { @@ -197,9 +193,6 @@ public function actionCreate(?string $section = null): ?Response $entry->setScenario(Element::SCENARIO_ESSENTIALS); $success = Craft::$app->getDrafts()->saveElementAsDraft($entry, $user->id, markAsSaved: false); - // Resume time - DateTimeHelper::resume(); - if (!$success) { return $this->asModelFailure($entry, StringHelper::upperCaseFirst(Craft::t('app', 'Couldn’t create {type}.', [ 'type' => Entry::lowerDisplayName(), @@ -632,4 +625,55 @@ private function _populateEntryModel(Entry $entry): void // Revision notes $entry->setRevisionNotes($this->request->getBodyParam('notes')); } + + /** + * @since 5.10.0 + */ + public function actionReassignModal(): Response + { + $this->requireCpRequest(); + $this->requireAcceptsJson(); + $this->requirePermission('deleteUsers'); + + $oldUserIds = $this->request->getRequiredParam('oldUserIds'); + + return $this->asCpModal() + ->action('entries/reassign') + ->contentHtml(fn() => + Cp::elementSelectFieldHtml([ + 'label' => Craft::t('app', 'Choose a new author'), + 'name' => 'newUserId', + 'elementType' => User::class, + 'criteria' => [ + 'id' => array_map(fn($id) => "not $id", $oldUserIds), + ], + 'single' => true, + ]) . + implode('', array_map(fn($id) => Html::hiddenInput('oldUserIds[]', $id), $oldUserIds)) + ) + ->submitButtonLabel(Craft::t('app', 'Reassign')); + } + + /** + * @since 5.10.0 + */ + public function actionReassign(): Response + { + $this->requireCpRequest(); + $this->requireAcceptsJson(); + $this->requirePermission('deleteUsers'); + + $oldUserIds = array_map(fn($id) => (int)$id, $this->request->getRequiredParam('oldUserIds')); + $newUserId = (int)$this->request->getRequiredBodyParam('newUserId'); + + if (!$newUserId) { + return $this->asFailure(Craft::t('app', 'No new author selected.')); + } + + $count = Craft::$app->getEntries()->reassignEntries($oldUserIds, $newUserId); + + return $this->asSuccess(Craft::t('app', '{type} reassigned.', [ + 'type' => $count === 1 ? Entry::displayName() : Entry::pluralDisplayName(), + ])); + } } diff --git a/src/controllers/EntryTypesController.php b/src/controllers/EntryTypesController.php index 76acd1ff379..2239278b2b8 100644 --- a/src/controllers/EntryTypesController.php +++ b/src/controllers/EntryTypesController.php @@ -20,7 +20,6 @@ use craft\helpers\Cp; use craft\helpers\Html; use craft\helpers\StringHelper; -use craft\helpers\UrlHelper; use craft\models\EntryType; use craft\models\Section; use craft\web\Controller; @@ -125,7 +124,7 @@ public function actionEdit(?int $entryTypeId = null, ?EntryType $entryType = nul if (!$this->readOnly) { $response ->action('entry-types/save') - ->redirectUrl(UrlHelper::cpReferralUrl() ?? 'settings/entry-types') + ->redirectUrl('settings/entry-types') ->addAltAction(Craft::t('app', 'Save and continue editing'), [ 'redirect' => 'settings/entry-types/{id}', 'shortcut' => true, diff --git a/src/controllers/FieldsController.php b/src/controllers/FieldsController.php index 6659b1f1846..ce33fb1834f 100644 --- a/src/controllers/FieldsController.php +++ b/src/controllers/FieldsController.php @@ -205,7 +205,7 @@ public function actionEditField(?int $fieldId = null, ?FieldInterface $field = n if (!$this->readOnly) { $response ->action('fields/save-field') - ->redirectUrl(UrlHelper::cpReferralUrl() ?? 'settings/fields') + ->redirectUrl('settings/fields') ->addAltAction(Craft::t('app', 'Save and continue editing'), [ 'redirect' => 'settings/fields/edit/{id}', 'shortcut' => true, @@ -661,6 +661,16 @@ private function _fldComponent(?array &$settings = null): FieldLayoutComponent foreach ($tabConfig['elements'] as &$elementConfig) { if (isset($elementConfig['uid']) && $elementConfig['uid'] === $uid) { $elementConfig = array_merge($elementConfig, $componentConfig); + + // If fieldId is set, we're replacing the selected field + if ($elementConfig['type'] === CustomField::class && isset($elementConfig['fieldId'])) { + if (!empty($elementConfig['fieldId'])) { + unset($elementConfig['fieldUid']); + } else { + unset($elementConfig['fieldId']); + } + } + break 2; } } diff --git a/src/controllers/SectionsController.php b/src/controllers/SectionsController.php index 534f30660b1..14c334d240f 100644 --- a/src/controllers/SectionsController.php +++ b/src/controllers/SectionsController.php @@ -175,7 +175,9 @@ public function actionSaveSection(): ?Response $section->handle = $this->request->getBodyParam('handle'); $section->type = $this->request->getBodyParam('type') ?? Section::TYPE_CHANNEL; $section->enableVersioning = $this->request->getBodyParam('enableVersioning', true); + $minAuthors = $this->request->getBodyParam('minAuthors'); $maxAuthors = $this->request->getBodyParam('maxAuthors'); + $section->minAuthors = is_numeric($minAuthors) ? (int)$minAuthors : 0; $section->maxAuthors = is_numeric($maxAuthors) ? (int)$maxAuthors : null; $section->propagationMethod = PropagationMethod::tryFrom($this->request->getBodyParam('propagationMethod') ?? '') ?? PropagationMethod::All; diff --git a/src/controllers/UsersController.php b/src/controllers/UsersController.php index ff6b17903b6..1f991b655a0 100644 --- a/src/controllers/UsersController.php +++ b/src/controllers/UsersController.php @@ -29,6 +29,7 @@ use craft\helpers\App; use craft\helpers\ArrayHelper; use craft\helpers\Assets; +use craft\helpers\DateTimeHelper; use craft\helpers\Db; use craft\helpers\FileHelper; use craft\helpers\Html; @@ -154,6 +155,7 @@ class UsersController extends Controller * ``` * * @since 3.0.13 + * @deprecated in 5.10.0 */ public const EVENT_DEFINE_CONTENT_SUMMARY = 'defineContentSummary'; @@ -1351,6 +1353,7 @@ public function actionPreferences(): Response $response = $this->asEditUserScreen($user, self::SCREEN_PREFERENCES); $i18n = Craft::$app->getI18n(); + $generalConfig = Craft::$app->getConfig()->getGeneral(); // user language $userLanguage = $user->getPreferredLanguage(); @@ -1369,13 +1372,19 @@ public function actionPreferences(): Response !$userLocale || !ArrayHelper::contains($i18n->getAllLocales(), fn(Locale $locale) => $locale->id === App::parseEnv($userLocale)) ) { - $userLocale = Craft::$app->getConfig()->getGeneral()->defaultCpLocale; + $userLocale = $generalConfig->defaultCpLocale; } + // time zone + // (can't call `Craft::$app->getTimeZone()` here because that could be set to the user preference) + $timeZone = $generalConfig->timezone ?? Craft::$app->getProjectConfig()->get('system.timeZone'); + $timeZoneAbbr = $timeZone ? DateTimeHelper::timeZoneAbbreviation(App::parseEnv($timeZone)) : 'UTC'; + $response->action('users/save-preferences'); $response->contentTemplate('users/_preferences', compact( 'userLanguage', 'userLocale', + 'timeZoneAbbr', )); return $response; @@ -1396,10 +1405,15 @@ public function actionSavePreferences(): Response if ($preferredLocale === '__blank__') { $preferredLocale = null; } + $timeZone = $this->request->getBodyParam('timeZone', $user->getPreference('timezone')) ?: null; + if ($timeZone === '__blank__') { + $timeZone = null; + } $preferences = [ 'language' => $this->request->getBodyParam('preferredLanguage', $user->getPreference('language')), 'locale' => $preferredLocale, 'weekStartDay' => $this->request->getBodyParam('weekStartDay', $user->getPreference('weekStartDay')), + 'timeZone' => $timeZone, 'useShapes' => (bool)$this->request->getBodyParam('useShapes', $user->getPreference('useShapes')), 'underlineLinks' => (bool)$this->request->getBodyParam('underlineLinks', $user->getPreference('underlineLinks')), 'disableAutofocus' => $this->request->getBodyParam('disableAutofocus', $user->getPreference('disableAutofocus')), @@ -2197,6 +2211,7 @@ public function actionDeactivateUser(): ?Response * Deletes a user. * * @return Response|null + * @deprecated in 5.10.0 */ public function actionDeleteUser(): ?Response { diff --git a/src/elements/Address.php b/src/elements/Address.php index 889ee637fda..d9479d38ede 100644 --- a/src/elements/Address.php +++ b/src/elements/Address.php @@ -11,11 +11,13 @@ use craft\base\NameTrait; use craft\base\NestedElementInterface; use craft\base\NestedElementTrait; +use craft\controllers\ElementsController; use craft\db\Table; use craft\elements\actions\Copy; use craft\elements\conditions\addresses\AddressCondition; use craft\elements\conditions\ElementConditionInterface; use craft\elements\db\AddressQuery; +use craft\enums\MenuItemType; use craft\fieldlayoutelements\addresses\LatLongField; use craft\fieldlayoutelements\addresses\OrganizationField; use craft\fieldlayoutelements\addresses\OrganizationTaxIdField; @@ -478,6 +480,46 @@ public function canCreateDrafts(User $user): bool return true; } + /** + * @inheritdoc + */ + protected function safeActionMenuItems(): array + { + $items = parent::safeActionMenuItems(); + + if ( + Craft::$app->controller instanceof ElementsController && + Craft::$app->controller->element === $this && + Craft::$app->getUser()->getIsAdmin() && + Craft::$app->getConfig()->getGeneral()->allowAdminChanges && + !empty($this->fieldId) + ) { + $items[] = ['type' => MenuItemType::HR]; + + // Field settings + $fieldEditId = sprintf('edit-field-%s', mt_rand()); + $items[] = [ + 'id' => $fieldEditId, + 'icon' => 'gear', + 'label' => Craft::t('app', 'Field settings'), + ]; + + $view = Craft::$app->getView(); + $view->registerJsWithVars(fn($id, $params) => << { + $('#' + $id).on('activate', function() { + new Craft.CpScreenSlideout('fields/edit-field', {params: $params}); + }); + })(); + JS, [ + $view->namespaceInputId($fieldEditId), + ['fieldId' => $this->fieldId], + ]); + } + + return $items; + } + /** * @inheritdoc */ diff --git a/src/elements/Asset.php b/src/elements/Asset.php index 1170bbd3506..bec5a56d84f 100644 --- a/src/elements/Asset.php +++ b/src/elements/Asset.php @@ -15,6 +15,7 @@ use craft\base\FsInterface; use craft\base\LocalFsInterface; use craft\controllers\ElementIndexesController; +use craft\controllers\ElementsController; use craft\controllers\ElementSelectorModalsController; use craft\db\Query; use craft\db\QueryAbortedException; @@ -76,6 +77,8 @@ use DateTime; use GraphQL\Type\Definition\Type; use Illuminate\Support\Collection; +use Imagick; +use Throwable; use Twig\Markup; use yii\base\Exception; use yii\base\InvalidArgumentException; @@ -1827,6 +1830,55 @@ protected function safeActionMenuItems(): array ]); } + if ( + Craft::$app->controller instanceof ElementsController && + Craft::$app->controller->element === $this && + Craft::$app->getUser()->getIsAdmin() && + Craft::$app->getConfig()->getGeneral()->allowAdminChanges + ) { + $items[] = ['type' => MenuItemType::HR]; + + // Volume settings + $volumeEditId = sprintf('edit-volume-%s', mt_rand()); + $items[] = [ + 'id' => $volumeEditId, + 'icon' => 'gear', + 'label' => Craft::t('app', 'Volume settings'), + ]; + + $view->registerJsWithVars(fn($id, $params) => << { + $('#' + $id).on('activate', function() { + const params = $params; + new Craft.CpScreenSlideout('volumes/edit-volume', {params}); + }); +})(); +JS, [ + $view->namespaceInputId($volumeEditId), + ['volumeId' => $this->volumeId], + ]); + + // Filesystem settings + $fsEditId = sprintf('edit-fs-%s', mt_rand()); + $items[] = [ + 'id' => $fsEditId, + 'icon' => 'gear', + 'label' => Craft::t('app', 'Filesystem settings'), + ]; + + $view->registerJsWithVars(fn($id, $params) => << { + $('#' + $id).on('activate', function() { + const params = $params; + new Craft.CpScreenSlideout('fs/edit', {params}); + }); +})(); +JS, [ + $view->namespaceInputId($fsEditId), + ['handle' => $this->getVolume()->getFs()->handle], + ]); + } + return $items; } @@ -3262,6 +3314,15 @@ private function _setKind(): void public function afterSave(bool $isNew): void { if (!$this->propagating) { + // Auto-populate alt text from IPTC/XMP metadata on upload, before any cleaning strips it + if ( + $this->alt === null && + isset($this->tempFilePath) && + in_array($this->getScenario(), [self::SCENARIO_CREATE, self::SCENARIO_REPLACE], true) + ) { + $this->alt = $this->_getAltFromXmpMetadata($this->tempFilePath) ?? $this->_getAltFromIptcMetadata($this->tempFilePath); + } + // Are we uploading an image that needs to be sanitized? if ( isset($this->tempFilePath) && @@ -3756,4 +3817,134 @@ private function allowTransforms(): bool default => true, }; } + + /** + * Attempts to extract alt text from XMP metadata embedded in an image file. + * Checks Iptc4xmpCore:AltTextAccessibility first, then dc:description. + * + * @param string $filePath + * @return string|null + */ + private function _getAltFromXmpMetadata(string $filePath): ?string + { + try { + $xmp = null; + + if (Craft::$app->getImages()->getIsImagick() && class_exists(Imagick::class)) { + $imagick = new Imagick($filePath); + $xmp = $imagick->getImageProfile('xmp') ?: null; + $imagick->clear(); + } + + if ($xmp === null) { + // Fall back to scanning the raw file for the XMP packet + $handle = fopen($filePath, 'rb'); + if ($handle === false) { + return null; + } + $chunk = fread($handle, 131072); // 128KB covers the XMP packet in most images + fclose($handle); + + if ($chunk !== false) { + $xmpStart = strpos($chunk, '', $xmpStart); + if ($xmpEnd !== false) { + $xmp = substr($chunk, $xmpStart, $xmpEnd - $xmpStart + strlen('')); + } + } + } + } + + if (empty($xmp)) { + return null; + } + + $dom = new \DOMDocument(); + if (!@$dom->loadXML($xmp)) { + return null; + } + + $xpath = new \DOMXPath($dom); + $xpath->registerNamespace('rdf', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#'); + $xpath->registerNamespace('dc', 'http://purl.org/dc/elements/1.1/'); + $xpath->registerNamespace('Iptc4xmpCore', 'http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/'); + $xpath->registerNamespace('Iptc4xmpExt', 'http://iptc.org/std/Iptc4xmpExt/2008-02-29/'); + + // Try Iptc4xmpCore:AltTextAccessibility (LangAlt rdf:Alt/rdf:li structure) + foreach ([ + '//Iptc4xmpCore:AltTextAccessibility/rdf:Alt/rdf:li', + '//Iptc4xmpExt:AltTextAccessibility/rdf:Alt/rdf:li', + '//Iptc4xmpCore:AltTextAccessibility[not(rdf:Alt)]', + '//Iptc4xmpExt:AltTextAccessibility[not(rdf:Alt)]', + ] as $query) { + $nodes = $xpath->query($query); + if ($nodes !== false) { + foreach ($nodes as $node) { + $value = trim($node->textContent); + if ($value !== '') { + return $value; + } + } + } + } + + // Try dc:description as an rdf:Alt structure + $nodes = $xpath->query('//dc:description/rdf:Alt/rdf:li'); + if ($nodes !== false) { + foreach ($nodes as $node) { + $value = trim($node->textContent); + if ($value !== '') { + return $value; + } + } + } + + // Try dc:description as a plain string value + $nodes = $xpath->query('//dc:description[not(rdf:Alt)]'); + if ($nodes !== false) { + foreach ($nodes as $node) { + $value = trim($node->textContent); + if ($value !== '') { + return $value; + } + } + } + } catch (Throwable) { + // Ignore errors and fall through to IPTC + } + + return null; + } + + /** + * Attempts to extract alt text from IPTC Caption/Abstract (Iptc.Application2.Caption, field 2:120). + * + * @param string $filePath + * @return string|null + */ + private function _getAltFromIptcMetadata(string $filePath): ?string + { + try { + $imageInfo = []; + @getimagesize($filePath, $imageInfo); + + if (!isset($imageInfo['APP13'])) { + return null; + } + + $iptc = iptcparse($imageInfo['APP13']); + + if (!empty($iptc['2#120'])) { + $value = trim(implode(' ', $iptc['2#120'])); + if ($value !== '') { + return $value; + } + } + } catch (Throwable) { + // Ignore errors + } + + return null; + } } diff --git a/src/elements/Category.php b/src/elements/Category.php index 64f97b39dcf..490657aea00 100644 --- a/src/elements/Category.php +++ b/src/elements/Category.php @@ -11,6 +11,8 @@ use craft\base\Element; use craft\behaviors\DraftBehavior; use craft\controllers\ElementIndexesController; +use craft\db\Connection; +use craft\db\FixedOrderExpression; use craft\db\Query; use craft\db\Table; use craft\elements\actions\Delete; @@ -33,6 +35,7 @@ use craft\services\ElementSources; use craft\services\Structures; use GraphQL\Type\Definition\Type; +use Illuminate\Support\Collection; use yii\base\Exception; use yii\base\InvalidConfigException; @@ -312,6 +315,19 @@ protected static function defineSortOptions(): array 'title' => Craft::t('app', 'Title'), 'slug' => Craft::t('app', 'Slug'), 'uri' => Craft::t('app', 'URI'), + [ + 'label' => Craft::t('app', 'Group'), + 'orderBy' => function(int $dir, Connection $db) { + $groupIds = Collection::make(Craft::$app->getCategories()->getAllGroups()) + ->sort(fn(CategoryGroup $a, CategoryGroup $b) => $dir === SORT_ASC + ? $a->name <=> $b->name + : $b->name <=> $a->name) + ->map(fn(CategoryGroup $group) => $group->id) + ->all(); + return new FixedOrderExpression('categories.groupId', $groupIds, $db); + }, + 'attribute' => 'group', + ], [ 'label' => Craft::t('app', 'Date Created'), 'orderBy' => 'dateCreated', @@ -331,6 +347,7 @@ protected static function defineSortOptions(): array protected static function defineTableAttributes(): array { return array_merge(parent::defineTableAttributes(), [ + 'group' => ['label' => Craft::t('app', 'Group')], 'ancestors' => ['label' => Craft::t('app', 'Ancestors')], 'parent' => ['label' => Craft::t('app', 'Parent')], ]); @@ -789,6 +806,19 @@ public function getGroup(): CategoryGroup // Indexes, etc. // ------------------------------------------------------------------------- + /** + * @inheritdoc + */ + protected function attributeHtml(string $attribute): string + { + switch ($attribute) { + case 'group': + return Html::encode($this->getGroup()->getUiLabel()); + default: + return parent::attributeHtml($attribute); + } + } + /** * @inheritdoc */ diff --git a/src/elements/Entry.php b/src/elements/Entry.php index 94272cde06f..4c90b044a40 100644 --- a/src/elements/Entry.php +++ b/src/elements/Entry.php @@ -67,6 +67,7 @@ use craft\records\Entry as EntryRecord; use craft\services\ElementSources; use craft\services\Structures; +use craft\validators\ArrayValidator; use craft\validators\DateCompareValidator; use craft\validators\DateTimeValidator; use craft\web\twig\AllowedInSandbox; @@ -1019,6 +1020,8 @@ public function attributeLabels(): array */ protected function defineRules(): array { + $section = $this->getSection(); + $rules = parent::defineRules(); $rules[] = [['sectionId', 'fieldId', 'ownerId', 'primaryOwnerId', 'typeId', 'sortOrder'], 'number', 'integerOnly' => true]; $rules[] = [['authorIds'], 'each', 'rule' => ['number', 'integerOnly' => true]]; @@ -1033,9 +1036,15 @@ protected function defineRules(): array ['typeId'], function(string $attribute) { if (!$this->isEntryTypeAllowed()) { - $this->addError($attribute, Craft::t('app', '{type} entries are no longer allowed in this section. Please choose a different entry type.', [ - 'type' => $this->getType()->getUiLabel(), - ])); + if (isset($this->sectionId)) { + $this->addError($attribute, Craft::t('app', '{type} entries are no longer allowed in this section. Please choose a different entry type.', [ + 'type' => $this->getType()->getUiLabel(), + ])); + } else { + $this->addError($attribute, Craft::t('app', '{type} entries are no longer allowed in this field. Please choose a different entry type.', [ + 'type' => $this->getType()->getUiLabel(), + ])); + } } }, 'skipOnEmpty' => false, @@ -1057,15 +1066,15 @@ function(string $attribute) { 'when' => fn() => $this->postDate && $this->expiryDate, 'on' => self::SCENARIO_LIVE, ]; - $rules[] = [ - ['authorIds'], - 'required', - 'when' => function() { - $section = $this->getSection(); - return $section && $section->type !== Section::TYPE_SINGLE && $section->maxAuthors !== 0; - }, - 'on' => self::SCENARIO_LIVE, - ]; + if ($section && $section->type !== Section::TYPE_SINGLE && $section->maxAuthors !== 0) { + $rules[] = [ + ['authorIds'], + ArrayValidator::class, + 'min' => $section->minAuthors, + 'max' => $section->maxAuthors, + 'on' => self::SCENARIO_LIVE, + ]; + } $rules[] = [ ['typeId'], function(string $attribute) { @@ -2299,6 +2308,8 @@ protected function safeActionMenuItems(): array $actions = parent::safeActionMenuItems(); if ( + Craft::$app->controller instanceof ElementsController && + Craft::$app->controller->element === $this && Craft::$app->getUser()->getIsAdmin() && Craft::$app->getConfig()->getGeneral()->allowAdminChanges ) { @@ -2353,11 +2364,7 @@ protected function safeActionMenuItems(): array } // Field settings - if ( - !empty($this->fieldId) && - Craft::$app->controller instanceof ElementsController && - Craft::$app->controller->element === $this - ) { + if (!empty($this->fieldId)) { $fieldEditId = sprintf('edit-field-%s', mt_rand()); $actions[] = [ 'id' => $fieldEditId, @@ -2417,14 +2424,7 @@ protected function attributeHtml(string $attribute): string { switch ($attribute) { case 'authors': - $authors = $this->getAuthors(); - $html = ''; - if (!empty($authors)) { - foreach ($authors as $author) { - $html .= Cp::elementChipHtml($author); - } - } - return $html; + return Cp::elementPreviewHtml($this->getAuthors()); case 'section': $section = $this->getSection(); if (!$section) { @@ -2709,7 +2709,7 @@ public function metaFieldsHtml(bool $static): string 'label' => Craft::t('app', 'Post Date'), 'id' => 'postDate', 'name' => 'postDate', - 'value' => $this->_userPostDate(), + 'value' => $this->postDate, 'errors' => $this->getErrors('postDate'), 'disabled' => $static, ]); @@ -2909,21 +2909,6 @@ public function updateTitle(): void Craft::$app->set('formattingLocale', $formattingLocale); } - /** - * Returns the Post Date value that should be shown on the edit form. - * - * @return DateTime|null - */ - private function _userPostDate(): ?DateTime - { - if (!$this->postDate || ($this->getIsUnpublishedDraft() && $this->postDate == $this->dateCreated)) { - // Pretend the post date hasn't been set yet, even if it has - return null; - } - - return $this->postDate; - } - // Events // ------------------------------------------------------------------------- @@ -2954,7 +2939,7 @@ public function beforeSave(bool $isNew): bool Craft::$app->getRevisions()->createRevision( $current, $current->getAuthorId(), - sprintf('Revision from %s', Craft::$app->getFormatter()->asDatetime($current->dateUpdated)), + sprintf('Revision from %s', Craft::$app->getFormatter()->asDatetime($current->dateUpdated, withTimeZone: true)), ); } } @@ -3015,7 +3000,7 @@ private function maybeSetDefaultAttributes(): void $section = $this->getSection(); if ( $section?->type !== Section::TYPE_SINGLE && - $section?->maxAuthors !== 0 && + $section?->minAuthors === 1 && empty($this->getAuthors()) ) { $user = Craft::$app->getUser()->getIdentity(); @@ -3025,11 +3010,9 @@ private function maybeSetDefaultAttributes(): void } if ( - !$this->_userPostDate() && - ( - in_array($this->scenario, [self::SCENARIO_LIVE, self::SCENARIO_DEFAULT]) || - !$this->getIsDraft() - ) + !$this->postDate && + $this->enabled && + in_array($this->scenario, [self::SCENARIO_LIVE, self::SCENARIO_DEFAULT]) ) { // Default the post date to the current date/time $this->postDate = new DateTime(); diff --git a/src/elements/NestedElementManager.php b/src/elements/NestedElementManager.php index 007950fb4e0..00fcc8fe6f9 100644 --- a/src/elements/NestedElementManager.php +++ b/src/elements/NestedElementManager.php @@ -400,6 +400,9 @@ function(string $id, array $config, $attribute, &$settings) use ($owner) { 'deleteConfirmationMessage' => Craft::t('app', 'Are you sure you want to delete the selected {type}?', [ 'type' => $this->elementType::lowerDisplayName(), ]), + 'bulkDeleteConfirmationMessage' => Craft::t('app', 'Are you sure you want to delete the selected {type}?', [ + 'type' => $this->elementType::pluralLowerDisplayName(), + ]), 'showInGrid' => $config['showInGrid'], 'selectable' => $config['selectable'], ]; diff --git a/src/elements/User.php b/src/elements/User.php index 026135448a7..cbba047d941 100644 --- a/src/elements/User.php +++ b/src/elements/User.php @@ -13,7 +13,6 @@ use craft\base\NameTrait; use craft\db\Query; use craft\db\Table; -use craft\elements\actions\DeleteUsers; use craft\elements\actions\Restore; use craft\elements\actions\SuspendUsers; use craft\elements\actions\UnsuspendUsers; @@ -23,6 +22,7 @@ use craft\elements\db\EagerLoadPlan; use craft\elements\db\ElementQueryInterface; use craft\elements\db\UserQuery; +use craft\elements\deletionblockers\EntryAuthorsBlocker; use craft\enums\CmsEdition; use craft\enums\Color; use craft\enums\MenuItemType; @@ -56,7 +56,6 @@ use DateInterval; use DateTime; use DateTimeZone; -use Throwable; use Webauthn\Exception\InvalidUserHandleException; use Webauthn\PublicKeyCredentialRequestOptions; use yii\base\Exception; @@ -382,11 +381,6 @@ protected static function defineActions(string $source): array $actions[] = UnsuspendUsers::class; } - if (Craft::$app->getUser()->checkPermission('deleteUsers')) { - // Delete - $actions[] = DeleteUsers::class; - } - // Restore $actions[] = Restore::class; @@ -789,6 +783,7 @@ public static function findIdentityByAccessToken($token, $type = null): ?self /** * @var self|null The user who should take over the user’s content if the user is deleted. + * @deprecated in 5.10.0 */ public ?User $inheritorOnDelete = null; @@ -1820,14 +1815,17 @@ public function canDuplicate(User $user): bool */ public function canDelete(User $user): bool { + if (Craft::$app->edition === CmsEdition::Solo) { + return false; + } + if (parent::canDelete($user)) { return true; } return ( - $user->id !== $this->id && - $user->can('deleteUsers') && - (!$this->admin || $user->admin) + $user->id === $this->id || + ($user->can('deleteUsers') && (!$this->admin || $user->admin)) ); } @@ -2187,9 +2185,6 @@ protected function destructiveActionMenuItems(): array return parent::destructiveActionMenuItems(); } - // Intentionally not calling parent::destructiveActionMenuItems() here, - // because we want to override the user deletion UX. - $currentUser = Craft::$app->getUser()->getIdentity(); $usersService = Craft::$app->getUsers(); @@ -2226,41 +2221,13 @@ protected function destructiveActionMenuItems(): array 'confirm' => Craft::t('app', 'Deactivating a user revokes their ability to sign in. Are you sure you want to continue?'), ]; } - - if ($isCurrentUser || $currentUser->can('deleteUsers')) { - $view = Craft::$app->getView(); - $deleteId = sprintf('action-delete-%s', mt_rand()); - $items[] = [ - 'id' => $deleteId, - 'icon' => 'trash', - 'label' => StringHelper::upperCaseFirst(Craft::t('app', 'Delete {type}', [ - 'type' => static::lowerDisplayName(), - ])), - ]; - - $view->registerJsWithVars(fn($id, $userId, $redirect) => << { - Craft.sendActionRequest('POST', 'users/user-content-summary', { - data: {userId: $userId} - }).then((response) => { - new Craft.DeleteUserModal($userId, { - contentSummary: response.data, - redirect: $redirect, - }); - }); -}); -JS, - [ - $view->namespaceInputId($deleteId), - $this->id, - /** @phpstan-ignore-next-line */ - Craft::$app->getSecurity()->hashData(Craft::$app->edition === CmsEdition::Solo ? 'dashboard' : 'users'), - ]); - } } } - return $items; + return [ + ...$items, + ...parent::destructiveActionMenuItems(), + ]; } private function _copyPasswordResetUrlActionItem(string $label, View $view): array @@ -2522,7 +2489,7 @@ protected function metadata(): array } return $formatter->asDuration($duration); }, - Craft::t('app', 'Created at') => $formatter->asDatetime($this->dateCreated, Formatter::FORMAT_WIDTH_SHORT), + Craft::t('app', 'Created at') => $formatter->asDatetime($this->dateCreated, Formatter::FORMAT_WIDTH_SHORT, true), Craft::t('app', 'Last login') => function() use ($formatter) { if ($this->pending) { return false; @@ -2530,13 +2497,13 @@ protected function metadata(): array if (!$this->lastLoginDate) { return Craft::t('app', 'Never'); } - return $formatter->asDatetime($this->lastLoginDate, Formatter::FORMAT_WIDTH_SHORT); + return $formatter->asDatetime($this->lastLoginDate, Formatter::FORMAT_WIDTH_SHORT, true); }, Craft::t('app', 'Last login fail') => function() use ($formatter) { if (!$this->locked || !$this->lastInvalidLoginDate) { return false; } - return $formatter->asDatetime($this->lastInvalidLoginDate, Formatter::FORMAT_WIDTH_SHORT); + return $formatter->asDatetime($this->lastInvalidLoginDate, Formatter::FORMAT_WIDTH_SHORT, true); }, Craft::t('app', 'Login fail count') => function() use ($formatter) { if (!$this->locked) { @@ -2547,6 +2514,17 @@ protected function metadata(): array ]; } + /** + * @inheritdoc + */ + public static function deletionBlockers(ElementCollection $elements, bool $hardDelete): array + { + return [ + new EntryAuthorsBlocker($elements, $hardDelete), + ...parent::deletionBlockers($elements, $hardDelete), + ]; + } + /** * @inheritdoc * @since 3.3.0 @@ -2718,52 +2696,9 @@ public function beforeDelete(): bool return false; } - $elementsService = Craft::$app->getElements(); - - // Do all this stuff within a transaction - $transaction = Craft::$app->getDb()->beginTransaction(); - - try { - // Should we transfer the content to a new user? - if ($this->inheritorOnDelete) { - // Invalidate all entry caches - $elementsService->invalidateCachesForElementType(Entry::class); - - // Update the entry/version/draft tables to point to the new user - $userRefs = [ - Table::DRAFTS => 'creatorId', - Table::REVISIONS => 'creatorId', - Table::ENTRIES_AUTHORS => 'authorId', - ]; - - foreach ($userRefs as $table => $column) { - Db::update($table, [ - $column => $this->inheritorOnDelete->id, - ], [ - $column => $this->id, - ], [], false); - } - } else { - // Delete the entries - $entryQuery = Entry::find() - ->authorId($this->id) - ->status(null) - ->site('*') - ->unique(); - - foreach (Db::each($entryQuery) as $entry) { - /** @var Entry $entry */ - // only delete their entry if they're the sole author - if ($entry->getAuthorIds() === [$this->id]) { - $elementsService->deleteElement($entry); - } - } - } - - $transaction->commit(); - } catch (Throwable $e) { - $transaction->rollBack(); - throw $e; + // Reassign the user's entries? + if ($this->inheritorOnDelete) { + Craft::$app->getEntries()->reassignEntries($this->id, $this->inheritorOnDelete->id); } $this->getAddressManager()->deleteNestedElements($this, $this->hardDelete); diff --git a/src/elements/actions/ChangeSortOrder.php b/src/elements/actions/ChangeSortOrder.php index 26c3e13a665..732524b1477 100644 --- a/src/elements/actions/ChangeSortOrder.php +++ b/src/elements/actions/ChangeSortOrder.php @@ -86,7 +86,7 @@ public function getTriggerHtml(): ?string async function moveToPage(selectedItems, elementIndex, page, button, hud) { button.addClass('loading'); - await elementIndex.settings.onBeforeMoveElementsToPage(selectedItems, page); + await elementIndex.onBeforeMoveElementsToPage(selectedItems, page); const data = Object.assign($params, { elementIds: elementIndex.getSelectedElementIds(), @@ -111,7 +111,7 @@ public function getTriggerHtml(): ?string hud.hide(); Craft.cp.displayNotice(response.data.message); - await elementIndex.settings.onMoveElementsToPage(selectedItems, page); + await elementIndex.onMoveElementsToPage(selectedItems, page); elementIndex.setPage(page); elementIndex.updateElements(true, true) } diff --git a/src/elements/actions/Delete.php b/src/elements/actions/Delete.php index c6125e5d6f8..9ad4880683b 100644 --- a/src/elements/actions/Delete.php +++ b/src/elements/actions/Delete.php @@ -14,7 +14,6 @@ use craft\db\Table; use craft\elements\db\ElementQueryInterface; use craft\helpers\Db; -use craft\helpers\Html; use craft\services\Elements; /** @@ -50,6 +49,22 @@ class Delete extends ElementAction implements DeleteActionInterface */ public ?string $successMessage = null; + private string $triggerId; + + /** + * @inheritdoc + */ + public function init(): void + { + parent::init(); + $this->triggerId = sprintf('action-trigger-%s', mt_rand()); + } + + public function getTriggerId(): string + { + return $this->triggerId; + } + /** * @inheritdoc */ @@ -73,10 +88,16 @@ public function setHardDelete(): void public function getTriggerHtml(): ?string { // Only enable for deletable elements, per canDelete() - Craft::$app->getView()->registerJsWithVars(fn($type) => <<getView()->registerJsWithVars(fn( + $triggerId, + $elementType, + $withDescendants, + $hardDelete, + $confirmationMessage, + ) => << { new Craft.ElementActionTrigger({ - type: $type, + triggerId: $triggerId, validateSelection: (selectedItems, elementIndex) => { for (let i = 0; i < selectedItems.length; i++) { if (!Garnish.hasAttr(selectedItems.eq(i).find('.element'), 'data-deletable')) { @@ -86,21 +107,35 @@ public function getTriggerHtml(): ?string return elementIndex.settings.canDeleteElements(selectedItems); }, - beforeActivate: async (selectedItems, elementIndex) => { - await elementIndex.settings.onBeforeDeleteElements(selectedItems); - }, - afterActivate: async (selectedItems, elementIndex) => { - await elementIndex.settings.onDeleteElements(selectedItems); + activate: async (selectedItems, elementIndex) => { + await elementIndex.onBeforeDeleteElements(selectedItems); + elementIndex.setIndexBusy(); + const elementIds = elementIndex.getSelectedElementIds(); + + new Craft.ElementDeletionManager($elementType, elementIds, { + siteId: elementIndex.siteId, + ownerId: elementIndex.settings.criteria?.ownerId, + withDescendants: $withDescendants, + hardDelete: $hardDelete, + confirmationMessage: $confirmationMessage, + onLoadBlockers: () => { + elementIndex.setIndexAvailable(); + }, + onSuccess: async () => { + elementIndex.updateElements(true, true); + await elementIndex.onDeleteElements(selectedItems); + }, + }); }, }); })(); -JS, [static::class]); - - if ($this->hard) { - return Html::tag('div', $this->getTriggerLabel(), [ - 'class' => ['btn', 'formsubmit'], - ]); - } +JS, [ + $this->getTriggerId(), + $this->elementType, + $this->withDescendants, + $this->hard, + $this->confirmationMessage, + ]); return null; } diff --git a/src/elements/actions/DeleteAssets.php b/src/elements/actions/DeleteAssets.php index 06546d4652b..b46cefaeb03 100644 --- a/src/elements/actions/DeleteAssets.php +++ b/src/elements/actions/DeleteAssets.php @@ -24,10 +24,15 @@ class DeleteAssets extends Delete public function getTriggerHtml(): ?string { // Only enable for deletable elements, per canDelete() - Craft::$app->getView()->registerJsWithVars(fn($type) => <<getView()->registerJsWithVars(fn( + $triggerId, + $elementType, + $hardDelete, + $confirmationMessage, + ) => << { - const trigger = new Craft.ElementActionTrigger({ - type: $type, + new Craft.ElementActionTrigger({ + triggerId: $triggerId, requireId: false, validateSelection: (selectedItems, elementIndex) => { for (let i = 0; i < selectedItems.length; i++) { @@ -48,24 +53,42 @@ public function getTriggerHtml(): ?string } } - return true; + return elementIndex.settings.canDeleteElements(selectedItems); }, - - activate: (selectedItems, elementIndex) => { + activate: async (selectedItems, elementIndex) => { const element = selectedItems.find('.element:first'); if (Garnish.hasAttr(element, 'data-is-folder')) { const sourcePath = element.data('source-path'); - elementIndex.deleteFolder(sourcePath[sourcePath.length - 1]) - .then(() => { - elementIndex.updateElements(); - }); + await elementIndex.deleteFolder(sourcePath[sourcePath.length - 1]); + elementIndex.updateElements(); } else { - elementIndex.submitAction(trigger.\$trigger.data('action'), Garnish.getPostData(trigger.\$trigger)); + await elementIndex.onBeforeDeleteElements(selectedItems); + elementIndex.setIndexBusy(); + const elementIds = elementIndex.getSelectedElementIds(); + + new Craft.ElementDeletionManager($elementType, elementIds, { + siteId: elementIndex.siteId, + ownerId: elementIndex.settings.criteria?.ownerId, + hardDelete: $hardDelete, + confirmationMessage: $confirmationMessage, + onLoadBlockers: () => { + elementIndex.setIndexAvailable(); + }, + onSuccess: async () => { + elementIndex.updateElements(true, true); + await elementIndex.onDeleteElements(selectedItems); + }, + }); } }, }); })(); -JS, [static::class]); +JS, [ + $this->getTriggerId(), + $this->elementType, + $this->hard, + $this->confirmationMessage, + ]); return null; } diff --git a/src/elements/actions/DeleteUsers.php b/src/elements/actions/DeleteUsers.php index 3af1ed307f4..966651e90fc 100644 --- a/src/elements/actions/DeleteUsers.php +++ b/src/elements/actions/DeleteUsers.php @@ -19,6 +19,7 @@ * * @author Pixel & Tonic, Inc. * @since 3.0.0 + * @deprecated in 5.10.0 */ class DeleteUsers extends ElementAction implements DeleteActionInterface { diff --git a/src/elements/actions/Duplicate.php b/src/elements/actions/Duplicate.php index 2cd27aca872..71f36328817 100644 --- a/src/elements/actions/Duplicate.php +++ b/src/elements/actions/Duplicate.php @@ -69,10 +69,10 @@ public function getTriggerHtml(): ?string return elementIndex.settings.canDuplicateElements(selectedItems); }, beforeActivate: async (selectedItems, elementIndex) => { - await elementIndex.settings.onBeforeDuplicateElements(selectedItems); + await elementIndex.onBeforeDuplicateElements(selectedItems); }, afterActivate: async (selectedItems, elementIndex) => { - await elementIndex.settings.onDuplicateElements(selectedItems); + await elementIndex.onDuplicateElements(selectedItems); }, }); })(); diff --git a/src/elements/actions/MoveDown.php b/src/elements/actions/MoveDown.php index 36d825829be..732fe47799d 100644 --- a/src/elements/actions/MoveDown.php +++ b/src/elements/actions/MoveDown.php @@ -61,7 +61,7 @@ public function getTriggerHtml(): ?string activate: async (selectedItems, elementIndex) => { const selectedItemIndex = Object.values(elementIndex.view.getAllElements()).indexOf(selectedItems[0]); const offset = selectedItemIndex + 1; - await elementIndex.settings.onBeforeReorderElements(selectedItems, offset); + await elementIndex.onBeforeReorderElements(selectedItems, offset); const data = Object.assign($params, { elementIds: elementIndex.getSelectedElementIds(), @@ -83,7 +83,7 @@ public function getTriggerHtml(): ?string } Craft.cp.displayNotice(response.data.message); - await elementIndex.settings.onReorderElements(selectedItems, offset); + await elementIndex.onReorderElements(selectedItems, offset); elementIndex.updateElements(true, true); }, }); diff --git a/src/elements/actions/MoveUp.php b/src/elements/actions/MoveUp.php index 29f01c9734b..902f63af3e7 100644 --- a/src/elements/actions/MoveUp.php +++ b/src/elements/actions/MoveUp.php @@ -61,7 +61,7 @@ public function getTriggerHtml(): ?string activate: async (selectedItems, elementIndex) => { const selectedItemIndex = Object.values(elementIndex.view.getAllElements()).indexOf(selectedItems[0]); const offset = selectedItemIndex - 1; - await elementIndex.settings.onBeforeReorderElements(selectedItems, offset); + await elementIndex.onBeforeReorderElements(selectedItems, offset); const data = Object.assign($params, { elementIds: elementIndex.getSelectedElementIds(), @@ -83,7 +83,7 @@ public function getTriggerHtml(): ?string } Craft.cp.displayNotice(response.data.message); - await elementIndex.settings.onReorderElements(selectedItems, offset); + await elementIndex.onReorderElements(selectedItems, offset); elementIndex.updateElements(true, true); }, }); diff --git a/src/elements/db/ElementQuery.php b/src/elements/db/ElementQuery.php index 98b03a4dfc0..ff95e98ef28 100644 --- a/src/elements/db/ElementQuery.php +++ b/src/elements/db/ElementQuery.php @@ -111,6 +111,13 @@ class ElementQuery extends Query implements ElementQueryInterface */ public const EVENT_AFTER_POPULATE_ELEMENTS = 'afterPopulateElements'; + /** + * The current element query instance being prepared, for reference by fields’ `queryCondition()` methods. + * + * @since 5.10.0 + */ + public static ?self $activeQuery = null; + // Base config attributes // ------------------------------------------------------------------------- @@ -2059,6 +2066,14 @@ public function ids(?YiiConnection $db = null): array return $result; } + /** + * @inheritdoc + */ + public function collectIds(?YiiConnection $db = null): Collection + { + return Collection::make($this->ids($db)); + } + /** * Executes the query and renders the resulting elements using their partial templates. * @@ -2339,30 +2354,11 @@ public function createElement(array $row): ElementInterface $row['title'] = (string)($row['title'] ?? ''); } - // Set the field values - $content = ArrayHelper::remove($row, 'content'); - $row['fieldValues'] = []; - - if (!empty($content) && (!empty($this->customFields) || !empty($this->generatedFields))) { - if (is_string($content)) { - $content = Json::decode($content); - } - - foreach ($this->customFields as $field) { - if ($field::dbType() !== null && isset($content[$field->layoutElement->uid])) { - $handle = $field->layoutElement->handle ?? $field->handle; - $row['fieldValues'][$handle] = $content[$field->layoutElement->uid]; - } - } - - foreach ($this->generatedFields as $field) { - if (isset($content[$field['uid']])) { - $row['generatedFieldValues'][$field['uid']] = $content[$field['uid']]; - if (($field['handle'] ?? '') !== '') { - $row['generatedFieldValues'][$field['handle']] = $content[$field['uid']]; - } - } - } + // Remove the field values + // (We'll set them after the element bas been created, and we have its field layout.) + $content = ArrayHelper::remove($row, 'content') ?? []; + if (is_string($content)) { + $content = Json::decode($content); } if (array_key_exists('dateDeleted', $row)) { @@ -2411,6 +2407,7 @@ public function createElement(array $row): ElementInterface if ($this->hasEventHandlers(self::EVENT_BEFORE_POPULATE_ELEMENT)) { $event = new PopulateElementEvent([ 'row' => $row, + 'content' => $content, ]); $this->trigger(self::EVENT_BEFORE_POPULATE_ELEMENT, $event); $row = $event->row ?? $row; @@ -2422,11 +2419,38 @@ public function createElement(array $row): ElementInterface $element ??= new $class($row); $element->attachBehaviors($behaviors); + // Set the custom field values + if (!empty($content)) { + $fieldLayout = $element->getFieldLayout(); + if ($fieldLayout) { + $element->setDirtyFieldTracking(false); + foreach ($fieldLayout->getCustomFields() as $field) { + if ($field::dbType() !== null && isset($content[$field->layoutElement->uid])) { + $handle = $field->layoutElement->handle ?? $field->handle; + $element->setFieldValue($handle, $content[$field->layoutElement->uid]); + } + } + $element->setDirtyFieldTracking(); + + $generatedFieldValues = []; + foreach ($fieldLayout->getGeneratedFields() as $field) { + if (isset($content[$field['uid']])) { + $generatedFieldValues[$field['uid']] = $content[$field['uid']]; + if (($field['handle'] ?? '') !== '') { + $generatedFieldValues[$field['handle']] = $content[$field['uid']]; + } + } + } + $element->setGeneratedFieldValues($generatedFieldValues); + } + } + // Fire an 'afterPopulateElement' event if ($this->hasEventHandlers(self::EVENT_AFTER_POPULATE_ELEMENT)) { $event = new PopulateElementEvent([ 'element' => $element, 'row' => $row, + 'content' => $content, ]); $this->trigger(self::EVENT_AFTER_POPULATE_ELEMENT, $event); return $event->element; @@ -2829,7 +2853,12 @@ private function _applyCustomFieldParams(): void if (isset($fieldsByHandle[$handle])) { foreach ($fieldsByHandle[$handle] as $instances) { $firstInstance = $instances[0]; - $condition = $firstInstance::queryCondition($instances, $fieldAttributes->$handle, $params); + static::$activeQuery = $this; + try { + $condition = $firstInstance::queryCondition($instances, $fieldAttributes->$handle, $params); + } finally { + static::$activeQuery = null; + } // aborting? if ($condition === false) { diff --git a/src/elements/db/ElementQueryInterface.php b/src/elements/db/ElementQueryInterface.php index c4dc61fc1fb..7ca2326f3fa 100644 --- a/src/elements/db/ElementQueryInterface.php +++ b/src/elements/db/ElementQueryInterface.php @@ -11,6 +11,7 @@ use craft\db\Query; use craft\elements\ElementCollection; use craft\models\FieldLayout; +use Illuminate\Support\Collection; use yii\base\Arrayable; use yii\db\Connection; use yii\db\QueryInterface; @@ -1656,6 +1657,16 @@ public function nth(int $n, ?Connection $db = null): mixed; */ public function ids(?Connection $db = null): array; + /** + * Executes the query and returns the IDs of the resulting elements as a collection. + * + * @param Connection|null $db The database connection used to generate the SQL statement. + * If this parameter is not given, the `db` application component will be used. + * @return Collection The resulting element IDs as a collection. + * @since 5.10.0 + */ + public function collectIds(?Connection $db = null): Collection; + /** * Converts a found row into an element instance. * diff --git a/src/elements/db/EntryQuery.php b/src/elements/db/EntryQuery.php index ae24dd03949..3e0f3d7da34 100644 --- a/src/elements/db/EntryQuery.php +++ b/src/elements/db/EntryQuery.php @@ -1072,10 +1072,12 @@ private function _applyAuthParam( ['entries.sectionId' => $section->id], ]; if ($excludePeerEntries) { - $sectionCondition[] = ['exists', (new Query()) - ->from(['entries_authors' => Table::ENTRIES_AUTHORS]) - ->where('[[entries_authors.entryId]] = [[entries.id]]') - ->andWhere(['entries_authors.authorId' => $user->id]), ]; + $sectionCondition[] = [ + 'exists', (new Query()) + ->from(['entries_authors' => Table::ENTRIES_AUTHORS]) + ->where('[[entries_authors.entryId]] = [[entries.id]]') + ->andWhere(['entries_authors.authorId' => $user->id]), + ]; } if ($excludePeerDrafts) { $sectionCondition[] = [ diff --git a/src/elements/deletionblockers/BaseDeletionBlocker.php b/src/elements/deletionblockers/BaseDeletionBlocker.php new file mode 100644 index 00000000000..6919a8dfe27 --- /dev/null +++ b/src/elements/deletionblockers/BaseDeletionBlocker.php @@ -0,0 +1,36 @@ + + * @since 5.10.0 + */ +abstract class BaseDeletionBlocker extends BaseObject implements DeletionBlockerInterface +{ + /** + * Constructor + */ + public function __construct( + protected readonly ElementCollection $elements, + protected readonly bool $hardDelete, + array $config = [], + ) { + parent::__construct($config); + } + + public function getDetails(): ?string + { + return null; + } +} diff --git a/src/elements/deletionblockers/DeletionBlockerInterface.php b/src/elements/deletionblockers/DeletionBlockerInterface.php new file mode 100644 index 00000000000..7c8282add5b --- /dev/null +++ b/src/elements/deletionblockers/DeletionBlockerInterface.php @@ -0,0 +1,74 @@ + + * @since 5.10.0 + */ +interface DeletionBlockerInterface +{ + /** + * Returns whether the blocker should be shown. + */ + public function isActive(): bool; + + /** + * Returns a text summary of the blocker. + */ + public function getSummary(): string; + + /** + * Returns the blocker details HTML, to be shown when the blocker view is expanded. + */ + public function getDetails(): ?string; + + /** + * Returns an array of action buttons that can be taken to resolve the blocker. + * + * Each action button is defined by an array with the following keys: + * + * - `id` _(optional)_ – The button’s ID + * - `class` _(optional)_ – The button’s class + * - `label` – The button’s label + * - `icon` _(optional)_ – The button icon name + * - `action` _(optional)_ – The controller action that the button should trigger; if omitted, the blocker will be treated as resolved when the button is pressed + * - `params` _(optional)_ – Additional request parameters that should be sent to the controller action (an `elementIds` param will be sent automatically) + * - `callback` _(optional)_ – JavaScript code that should be executed when the button is activated + * - `destructive` – Whether the action is destructive + * - `confirm` _(optional)_ – A confirmation message that should be presented to the user before triggering the action + * - `requireElevatedSession` _(optional)_ – Whether an elevated session is required before the action is triggered + * - `attributes` _(optional)_ – Any HTML attributes that should be set on the `
").appendTo(r),s=$('
').appendTo(a),o=$("
').appendTo(this.$container),this.$cursor=$('
').appendTo(this.$container),this.$graduations=$('
').appendTo(this.$container),this.$graduationsUl=$("
    ").attr({"aria-hidden":"true"}).appendTo(this.$graduations),this.$container.attr({role:"slider",tabindex:"0","aria-valuemin":this.slideMin,"aria-valuemax":this.slideMax,"aria-valuenow":"0","aria-valuetext":Craft.t("app","{num, number} {num, plural, =1{degree} other{degrees}}",{num:0})});for(var i=this.graduationsMin;i<=this.graduationsMax;i++){var r=$('
  • '+i+"
  • ").appendTo(this.$graduationsUl);i%5==0&&r.addClass("main-graduation"),0===i&&r.addClass("selected")}this.$options=this.$container.find(".graduation"),this.addListener(this.$container,"resize",this._handleResize.bind(this)),this.addListener(this.$container,"tapstart",this._handleTapStart.bind(this)),this.addListener(Garnish.$bod,"tapmove",this._handleTapMove.bind(this)),this.addListener(Garnish.$bod,"tapend",this._handleTapEnd.bind(this)),this.addListener(this.$container,"keydown",this._handleKeypress.bind(this)),setTimeout((function(){n.graduationsCalculatedWidth=10*(n.$options.length-1),n.$graduationsUl.css("left",-n.graduationsCalculatedWidth/2+n.$container.width()/2)}),50)},_handleResize:function(){var t=this.valueToPosition(this.value);this.$graduationsUl.css("left",t)},_handleKeypress:function(t){var e=parseInt(this.$container.attr("aria-valuenow"),10);switch(t.keyCode){case Garnish.UP_KEY:case Garnish.RIGHT_KEY:this.setValue(e+1);break;case Garnish.DOWN_KEY:case Garnish.LEFT_KEY:this.setValue(e-1);break;case Garnish.PAGE_UP_KEY:this.setValue(e+10);break;case Garnish.PAGE_DOWN_KEY:this.setValue(e-10);break;case Garnish.HOME_KEY:this.setValue(this.slideMin);break;case Garnish.END_KEY:this.setValue(this.slideMax)}this.onChange()},_handleTapStart:function(t,e){t.preventDefault(),this.rotateIntent=$(t.target).is(".graduations *"),this.rotateIntent&&(this.startPositionX=e.position.x,this.startLeft=this.$graduationsUl.position().left,this.onStart())},_handleTapMove:function(t,e){this.rotateIntent&&Math.abs(e.position.x-this.startPositionX)>this.sensitivity&&(this.dragging=!0,this.$container.addClass("dragging"),t.preventDefault(),this._setValueFromTouch(e),this.onChange())},_setValueFromTouch:function(t){var e,n=this.dragging?this.startPositionX:this.$cursor.offset().left+this.$cursor.outerWidth()/2;e=this.dragging?n-t.position.x:t.position.x-n;var i=this.startLeft-e,r=this.positionToValue(i);this.setValue(r)},setValue:function(t){var e=this.valueToPosition(t);tthis.slideMax&&(t=this.slideMax,e=this.valueToPosition(t)),this.$graduationsUl.css("left",e),t>=this.slideMin&&t<=this.slideMax&&(this.$options.removeClass("selected"),$.each(this.$options,(function(e,n){$(n).data("graduation")>0&&$(n).data("graduation")<=t&&$(n).addClass("selected"),$(n).data("graduation")<0&&$(n).data("graduation")>=t&&$(n).addClass("selected"),0==$(n).data("graduation")&&$(n).addClass("selected")}))),this.$container.attr({"aria-valuenow":t,"aria-valuetext":Craft.t("app","{num, number} {num, plural, =1{degree} other{degrees}}",{num:parseInt(t,10)})}),this.value=t},_handleTapEnd:function(t,e){this.rotateIntent&&(this.dragging?(t.preventDefault(),this.dragging=!1,this.$container.removeClass("dragging")):(this._setValueFromTouch(e),this.onChange()),this.onEnd(),this.startPositionX=null,this.rotateIntent=!1)},positionToValue:function(t){var e=-1*this.graduationsMin,n=-1*(this.graduationsMin-this.graduationsMax);return(this.$graduations.width()/2+-1*t)/this.graduationsCalculatedWidth*n-e},valueToPosition:function(t){var e=-1*this.graduationsMin,n=-1*(this.graduationsMin-this.graduationsMax);return-((t+e)*this.graduationsCalculatedWidth/n-this.$graduations.width()/2)},onStart:function(){"function"==typeof this.settings.onChange&&this.settings.onStart(this)},onChange:function(){"function"==typeof this.settings.onChange&&this.settings.onChange(this)},onEnd:function(){"function"==typeof this.settings.onChange&&this.settings.onEnd(this)},defaultSettings:{onStart:$.noop,onChange:$.noop,onEnd:$.noop}})},3254:function(){},3517:function(){Craft.BaseUploader=Garnish.Base.extend({allowedKinds:null,$element:null,$fileInput:null,settings:null,fsType:null,formData:{},events:{},_rejectedFiles:{},_extensionList:null,_inProgressCounter:0,init:function(t,e){this._rejectedFiles={size:[],type:[],limit:[]},this.$element=t,this.settings=$.extend({},Craft.BaseUploader.defaults,e),this.formData=this.settings.formData,this.$fileInput=this.settings.fileInput||t,this.events=this.settings.events,this.settings.url||(this.settings.url=this.settings.replace?Craft.getActionUrl(this.settings.replaceAction):Craft.getActionUrl(this.settings.createAction)),this.settings.allowedKinds&&this.settings.allowedKinds.length&&("string"==typeof this.settings.allowedKinds&&(this.settings.allowedKinds=[this.settings.allowedKinds]),this.allowedKinds=this.settings.allowedKinds,delete this.settings.allowedKinds)},setParams:function(t){void 0!==Craft.csrfTokenName&&void 0!==Craft.csrfTokenValue&&(t[Craft.csrfTokenName]=Craft.csrfTokenValue),this.formData=t},getInProgress:function(){return this._inProgressCounter},isLastUpload:function(){return this.getInProgress()<2},processErrorMessages:function(){var t;this._rejectedFiles.type.length&&(t=1===this._rejectedFiles.type.length?"The file {files} could not be uploaded. The allowed file kinds are: {kinds}.":"The files {files} could not be uploaded. The allowed file kinds are: {kinds}.",t=Craft.t("app",t,{files:this._rejectedFiles.type.join(", "),kinds:this.allowedKinds.join(", ")}),this._rejectedFiles.type=[],Craft.cp.displayError(t)),this._rejectedFiles.size.length&&(t=1===this._rejectedFiles.size.length?"The file {files} could not be uploaded, because it exceeds the maximum upload size of {size}.":"The files {files} could not be uploaded, because they exceeded the maximum upload size of {size}.",t=Craft.t("app",t,{files:this._rejectedFiles.size.join(", "),size:this.humanFileSize(this.settings.maxFileSize)}),this._rejectedFiles.size=[],Craft.cp.displayError(t)),this._rejectedFiles.limit.length&&(t=1===this._rejectedFiles.limit.length?"The file {files} could not be uploaded, because the field limit has been reached.":"The files {files} could not be uploaded, because the field limit has been reached.",t=Craft.t("app",t,{files:this._rejectedFiles.limit.join(", ")}),this._rejectedFiles.limit=[],Craft.cp.displayError(t))},humanFileSize:function(t){var e=1024;if(t=e);return t.toFixed(1)+" "+["kB","MB","GB","TB","PB","EB","ZB","YB"][n]},_createExtensionList:function(){this._extensionList=[];for(var t=0;t=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),k(n),y}},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;k(n)}return r}}throw Error("illegal catch attempt")},delegateYield:function(t,e,i){return this.delegate={iterator:P(t),resultName:e,nextLoc:i},"next"===this.method&&(this.arg=n),y}},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)}Craft.AssetSelectInput=Craft.BaseElementSelectInput.extend({$uploadBtn:null,uploader:null,progressBar:null,openPreviewTimeout:null,init:function(){this.base.apply(this,arguments),this.settings.canUpload&&this._attachUploader(),this.updateAddElementsBtn(),this.addListener(this.$elementsContainer,"keydown",this._onKeyDown.bind(this))},elementSelectSettings:function(){return Object.assign(this.base(),{makeFocusable:!0})},_onKeyDown:function(t){if(t.keyCode===Garnish.SPACE_KEY&&t.shiftKey)return this.openPreview(),t.stopPropagation(),!1},clearOpenPreviewTimeout:function(){this.openPreviewTimeout&&(clearTimeout(this.openPreviewTimeout),this.openPreviewTimeout=null)},openPreview:function(t){Craft.PreviewFileModal.openInstance?Craft.PreviewFileModal.openInstance.hide():(t||(t=this.$elements.filter(":focus").add(this.$elements.has(":focus"))),t.length&&Craft.PreviewFileModal.showForAsset(t,this.elementSelect))},_attachUploader:function(){var t=this;this.progressBar=new Craft.ProgressBar($('
    ').appendTo(this.$container)),this.$addElementBtn&&(this.$uploadBtn=$("