diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 545b06c0501..b8734742140 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -1,15 +1,18 @@ # Release Notes for Craft CMS 5.0 (WIP) ### Content Management +- Redesigned the global breadcrumb bar to include quick links to other areas of the control panel, page context menus, and action menus. ([#13902](https://github.com/craftcms/cms/pull/13902)) - All elements can now have thumbnails, provided by Assets fields. ([#12484](https://github.com/craftcms/cms/discussions/12484), [#12706](https://github.com/craftcms/cms/discussions/12706)) - Element indexes and relational fields now have the option to use card views. ([#6024](https://github.com/craftcms/cms/pull/6024)) - Element indexes now support inline editing for some custom field values. +- Element chips and cards now include quick action menus. ([#13902](https://github.com/craftcms/cms/pull/13902)) +- Entry edit pages now include quick links to other sections’ index sources. +- Asset edit pages now include quick links to other volumes’ index sources. - Entry conditions can now have a “Matrix field” rule. ([#13794](https://github.com/craftcms/cms/discussions/13794)) - User addresses are now displayed within an embedded element index. - Selected elements within relational fields now include a context menu with “View in a new tab”, “Edit”, and “Remove” options. - Selected elements within relational fields now include a dedicated drag handle. -- Selected assets within Assets fields now include a “Preview file” option within their context menu. -- Selected assets within Assets fields no longer open the file preview modal when their thumbnail is clicked on. The “Preview file” context menu option, or Shift + Spacebar keyboard shortcut can be used instead. +- Selected assets within Assets fields no longer open the file preview modal when their thumbnail is clicked on. The “Preview file” quick action, or the Shift + Spacebar keyboard shortcut, can be used instead. - Improved the styling of element chips. - Improved checkbox-style deselection behavior for control panel items, to account for double-clicks. - Table views are no longer available for element indexes on mobile. @@ -48,6 +51,7 @@ - Entry queries now have `field`, `fieldId`, `primaryOwner`, `primaryOwnerId`, `owner`, `ownerId`, `allowOwnerDrafts`, and `allowOwnerRevisions` params. - Entries’ GraphQL type names are now formatted as `_Entry`, and are no longer prefixed with their section’s handle. (That goes for Matrix-nested entries as well.) - Matrix fields’ GraphQL mutation types now expect nested entries to be defined by an `entries` field rather than `blocks`. +- Added the `|firstWhere` and `|flatten` Twig filters. - Removed the `craft.matrixBlocks()` Twig function. `craft.entries()` should be used instead. - Controller actions which require a `POST` request will now respond with a 405 error code if another request method is used. ([#13397](https://github.com/craftcms/cms/discussions/13397)) @@ -59,16 +63,22 @@ - All core element query param methods now return `static` instead of `self`. ([#11868](https://github.com/craftcms/cms/pull/11868)) - Migrations that modify the project config no longer need to worry about whether the same changes were already applied to the incoming project config YAML files. - Selectize menus no longer apply special styling to options with the value `new`. The `_includes/forms/selectize.twig` control panel template should be used instead (or `craft\helpers\Cp::selectizeHtml()`/`selectizeFieldHtml()`), which will append an styled “Add” option when `addOptionFn` and `addOptionLabel` settings are passed. ([#11946](https://github.com/craftcms/cms/issues/11946)) -- Added the `elementChip()` and `elementCard()` global functions for control panel templates. +- Added the `disclosureMenu()`, `elementCard()`, `elementChip()`, `elementIndex()`, and `siteMenuItems()` global functions for control panel templates. - The `assets/move-asset` and `assets/move-folder` actions no longer include `success` keys in responses. ([#12159](https://github.com/craftcms/cms/pull/12159)) - The `assets/upload` controller action now includes `errors` object in failure responses. ([#12159](https://github.com/craftcms/cms/pull/12159)) - Element action triggers’ `validateSelection()` and `activate()` methods are now passed an `elementIndex` argument, with a reference to the trigger’s corresponding element index. +- Added `craft\base\Element::EVENT_DEFINE_ACTION_MENU_ITEMS`. - Added `craft\base\Element::EVENT_DEFINE_INLINE_ATTRIBUTE_INPUT_HTML`. +- Added `craft\base\Element::crumbs()`. +- Added `craft\base\Element::destructiveActionMenuItems()`. - Added `craft\base\Element::inlineAttributeInputHtml()`. +- Added `craft\base\Element::safeActionMenuItems()`. - Added `craft\base\Element::shouldValidateTitle()`. - Added `craft\base\ElementContainerFieldInterface`, which should be implemented by fields which contain nested elements, such as Matrix. +- Added `craft\base\ElementInterface::getActionMenuItems()`. - Added `craft\base\ElementInterface::getCardBodyHtml()`. - Added `craft\base\ElementInterface::getChipLabelHtml()`. +- Added `craft\base\ElementInterface::getCrumbs()`. - Added `craft\base\ElementInterface::getInlineAttributeInputHtml()`. - Added `craft\base\ElementInterface::hasDrafts()`. - Added `craft\base\ElementInterface::hasThumbs()`. @@ -152,9 +162,12 @@ - Added `craft\helpers\ArrayHelper::lastValue()`. - Added `craft\helpers\Cp::checkboxGroupFieldHtml()`. - Added `craft\helpers\Cp::checkboxGroupHtml()`. +- Added `craft\helpers\Cp::disclosureMenu()`. - Added `craft\helpers\Cp::elementCardHtml()`. - Added `craft\helpers\Cp::elementChipHtml()`. - Added `craft\helpers\Cp::elementIndexHtml()`. +- Added `craft\helpers\Cp::normalizeMenuItems()`. +- Added `craft\helpers\Cp::siteMenuItems()`. - Added `craft\helpers\Db::defaultCollation()`. - Added `craft\helpers\Db::prepareForJsonColumn()`. - Added `craft\helpers\ElementHelper::actionConfig()`. @@ -185,6 +198,15 @@ - Added `craft\services\ProjectConfig::find()`. - Added `craft\services\ProjectConfig::flush()`. - Added `craft\services\ProjectConfig::writeYamlFiles()`. +- Added `craft\web\CpScreenResponseBehavior::$actionMenuItems`. +- Added `craft\web\CpScreenResponseBehavior::$contextMenuItems`. +- Added `craft\web\CpScreenResponseBehavior::$selectableSites`. +- Added `craft\web\CpScreenResponseBehavior::$site`. +- Added `craft\web\CpScreenResponseBehavior::actionMenuItems()`. +- Added `craft\web\CpScreenResponseBehavior::contextMenuItems()`. +- Added `craft\web\CpScreenResponseBehavior::selectableSites()`. +- Added `craft\web\CpScreenResponseBehavior::site()`. +- Added `craft\web\Request::getQueryParamsWithoutPath()`. - Added `craft\web\twig\variables\Cp::getEntryTypeOptions()`. - All of the `craft\services\Sections` members have been moved into `craft\services\Entries`. - Renamed `craft\base\BlockElementInterface` to `NestedElementInterface`, and added the `getField()`, `getSortOrder()`, and `setOwner()` methods to it. @@ -242,10 +264,13 @@ - `craft\services\Elements::duplicateElement()` no longer has a `$trackDuplication` argument. - `craft\services\Plugins::getPluginLicenseKeyStatus()` now returns a `craft\enums\LicenseKeyStatus` case. - `craft\services\ProjectConfig::saveModifiedConfigData()` no longer has a `$writeExternalConfig` argument, and no longer writes out updated project config YAML files. +- `craft\helpers\Html::tag()` and `beginTag()` now ensure that the passed-in attributes are normalized. +- `craft\helpers\Html::normalizeTagAttributes()` now supports a `removeClass` key. - Deprecated the `_elements/element.twig` control panel template. `elementChip()` or `elementCard()` should be used instead. - Deprecated the `cp.elements.element` control panel template hook. - Deprecated `craft\events\DefineElementInnerHtmlEvent`. - Deprecated `craft\helpers\Cp::elementHtml()`. `elementChipHtml()` or `elementCardHtml()` should be used instead. +- Removed the `_includes/revisionmenu.twig` control panel template. - Removed `craft\base\ApplicationTrait::getMatrix()`. - Removed `craft\base\Element::$contentId`. - Removed `craft\base\Element::ATTR_STATUS_MODIFIED`. `craft\enums\AttributeStatus::Modified` should be used instead. @@ -269,6 +294,7 @@ - Removed `craft\controllers\Sections::actionEntryTypesIndex()`. - Removed `craft\controllers\Sections::actionReorderEntryTypes()`. - Removed `craft\controllers\Sections::actionSaveEntryType()`. +- Removed `craft\controllers\UsersController::EVENT_REGISTER_USER_ACTIONS`. `craft\base\Element::EVENT_DEFINE_ACTION_MENU_ITEMS` should be used instead. - Removed `craft\db\Table::FIELDGROUPS`. - Removed `craft\elements\MatrixBlock`. - Removed `craft\elements\db\ElementQuery::$contentTable`. @@ -280,6 +306,7 @@ - Removed `craft\errors\MatrixBlockTypeNotFoundException`. - Removed `craft\events\BlockTypesEvent`. - Removed `craft\events\FieldGroupEvent`. +- Removed `craft\events\RegisterUserActionsEvent`. - Removed `craft\fields\Matrix::EVENT_SET_FIELD_BLOCK_TYPES`. - Removed `craft\fields\Matrix::PROPAGATION_METHOD_ALL`. `craft\enums\PropagationMethod::All` should be used instead. - Removed `craft\fields\Matrix::PROPAGATION_METHOD_CUSTOM`. `craft\enums\PropagationMethod::Custom` should be used instead. @@ -348,7 +375,19 @@ - Removed `craft\services\ProjectConfig::PATH_MATRIX_BLOCK_TYPES`. - Removed `craft\services\ProjectConfig::updateStoredConfigAfterRequest()`. - Removed `craft\services\Sections::reorderEntryTypes()`. +- Removed `craft\web\CpScreenResponseBehavior::$contextMenuHtml`. `$contextMenuItems` should be used instead. +- Removed `craft\web\CpScreenResponseBehavior::contextMenuHtml()`. `contextMenuItems()` should be used instead. +- Removed `craft\web\CpScreenResponseBehavior::contextMenuTemplate()`. `contextMenuItems()` should be used instead. - Added `Craft.BaseElementSelectInput::defineElementActions()`. +- Added `Craft.CP::setSiteCrumbMenuItemStatus()`. +- Added `Craft.CP::showSiteCrumbMenuItem()`. +- Added `Craft.CP::updateContext()`. +- Added `Garnish.DisclosureMenu::addGroup()`. +- Added `Garnish.DisclosureMenu::addHr()`. +- Added `Garnish.DisclosureMenu::addItem()`. +- Added `Garnish.DisclosureMenu::createItem()`. +- Added `Garnish.DisclosureMenu::getFirstDestructiveGroup()`. +- Added `Garnish.DisclosureMenu::isPadded()`. - `Craft.appendBodyHtml()` and `appendHeadHtml()` are now promise-based, and load JavaScript resources over Ajax. ### System diff --git a/packages/craftcms-sass/_mixins.scss b/packages/craftcms-sass/_mixins.scss index 2617c3e08ca..e41833fb2eb 100644 --- a/packages/craftcms-sass/_mixins.scss +++ b/packages/craftcms-sass/_mixins.scss @@ -226,7 +226,7 @@ $radioSize: 16px; @mixin icon { font-family: 'Craft'; - speak: none; + speak: never; -webkit-font-feature-settings: 'liga', 'dlig'; -moz-font-feature-settings: 'liga=1, dlig=1'; -moz-font-feature-settings: 'liga', 'dlig'; @@ -612,7 +612,7 @@ $radioSize: 16px; 0 5px 20px transparentize($grey900, 0.75); } -@mixin menu-option-styles { +@mixin menu-item-styles { margin: 0 -14px; padding: 10px 14px; color: $menuOptionColor; @@ -620,7 +620,7 @@ $radioSize: 16px; white-space: nowrap; } -@mixin menu-option-active-styles { +@mixin menu-item-active-styles { color: $menuOptionActiveColor; background-color: $menuOptionActiveBackgroundColor; } diff --git a/src/base/Element.php b/src/base/Element.php index f17c7d1466e..c89ed00eb70 100644 --- a/src/base/Element.php +++ b/src/base/Element.php @@ -30,12 +30,14 @@ use craft\elements\exporters\Raw; use craft\elements\User; use craft\enums\AttributeStatus; +use craft\enums\MenuItemType; use craft\errors\InvalidFieldException; use craft\events\AuthorizationCheckEvent; use craft\events\DefineAttributeHtmlEvent; use craft\events\DefineAttributeKeywordsEvent; use craft\events\DefineEagerLoadingMapEvent; use craft\events\DefineHtmlEvent; +use craft\events\DefineMenuItemsEvent; use craft\events\DefineMetadataEvent; use craft\events\DefineUrlEvent; use craft\events\DefineValueEvent; @@ -344,6 +346,13 @@ abstract class Element extends Component implements ElementInterface */ public const EVENT_DEFINE_ADDITIONAL_BUTTONS = 'defineAdditionalButtons'; + /** + * @event DefineMenuComponentEvent The event that is triggered when defining action menu items.. + * @see getActionMenuItems() + * @since 5.0.0 + */ + public const EVENT_DEFINE_ACTION_MENU_ITEMS = 'defineActionMenuItems'; + /** * @event DefineHtmlEvent The event that is triggered when defining the HTML for the editor sidebar. * @see getSidebarHtml() @@ -3094,6 +3103,25 @@ public function getLink(): ?Markup return Template::raw($a); } + /** + * @inheritdoc + * @see crumbs() + */ + public function getCrumbs(): array + { + if ($this instanceof NestedElementInterface) { + $owner = $this->getPrimaryOwner(); + if ($owner) { + return [ + ...$owner->getCrumbs(), + ['html' => Cp::elementChipHtml($owner)], + ]; + } + } + + return $this->crumbs(); + } + /** * @inheritdoc */ @@ -3126,6 +3154,18 @@ public function setUiLabelPath(array $path): void $this->_uiLabelPath = $path; } + /** + * Returns the breadcrumbs that lead up to the element. + * + * @return array + * @since 5.0.0 + * @see getCrumbs() + */ + protected function crumbs(): array + { + return []; + } + /** * Returns what the element should be called within the control panel. * @@ -3387,6 +3427,232 @@ public function getAdditionalButtons(): string return $event->html; } + /** + * @inheritdoc + */ + public function getActionMenuItems(): array + { + $items = [ + ...$this->safeActionMenuItems(), + ...array_map(fn(array $item) => $item + ['destructive' => true], $this->destructiveActionMenuItems()), + ]; + + // Fire a defineActionMenuItems event + if ($this->hasEventHandlers(self::EVENT_DEFINE_ACTION_MENU_ITEMS)) { + $event = new DefineMenuItemsEvent([ + 'items' => $items, + ]); + $this->trigger(self::EVENT_DEFINE_ACTION_MENU_ITEMS, $event); + return $event->items; + } + + return $items; + } + + /** + * Returns action menu items for the element’s edit screens. + * + * See [[\craft\helpers\Cp::disclosureMenu()]] for documentation on supported item properties. + * + * @return array + * @see getActionMenuItems() + * @since 5.0.0 + */ + protected function safeActionMenuItems(): array + { + $items = []; + + // View + $url = $this->getUrl(); + if ($url) { + $items[] = [ + 'id' => 'action-view', + 'icon' => 'share', + 'label' => Craft::t('app', 'View in a new tab'), + 'url' => $url, + 'attributes' => [ + 'target' => '_blank', + ], + ]; + } + + // Edit + if (Craft::$app->getElements()->canView($this)) { + $editId = 'action-edit'; + $items[] = [ + 'type' => MenuItemType::Button, + 'id' => $editId, + 'icon' => 'edit', + 'label' => Craft::t('app', 'Edit {type}', [ + 'type' => static::lowerDisplayName(), + ]), + ]; + + + $view = Craft::$app->getView(); + $view->registerJsWithVars(fn($id, $elementType, $settings) => << { + Craft.createElementEditor($elementType, $settings); +}); +JS, [ + $view->namespaceInputId($editId), + static::class, + [ + 'elementId' => $this->id, + 'draftId' => $this->draftId, + 'revisionId' => $this->revisionId, + 'siteId' => $this->siteId, + ], + ]); + } + + return $items; + } + + /** + * Returns destructive action menu items for the element’s edit screens. + * + * See [[\craft\helpers\Cp::disclosureMenu()]] for documentation on supported item properties. + * + * `'destructive' => true` will be automatically added to all returned items. + * + * @return array + * @see getActionMenuItems() + * @since 5.0.0 + */ + protected function destructiveActionMenuItems(): array + { + $items = []; + + $elementsService = Craft::$app->getElements(); + $user = Craft::$app->getUser()->getIdentity(); + + // Figure out what we're dealing with here + $isCanonical = $this->getIsCanonical(); + $isDraft = $this->getIsDraft(); + $isUnpublishedDraft = $this->getIsUnpublishedDraft(); + $isCurrent = $isCanonical || $this->isProvisionalDraft; + $canonical = $this->getCanonical(true); + $redirectUrl = $this->getPostEditUrl() ?? Craft::$app->getConfig()->getGeneral()->getPostCpLoginRedirect(); + + // Site info + $supportedSites = ElementHelper::supportedSitesForElement($this, true); + $propSites = array_values(array_filter($supportedSites, fn($site) => $site['propagate'])); + $propSiteIds = array_column($propSites, 'siteId'); + $isMultiSiteElement = count($supportedSites) > 1; + + // Is this a new site that isn’t supported by the canonical element yet? + if ($isUnpublishedDraft) { + $isNewSite = true; + } elseif ($isDraft) { + $isNewSite = !static::find() + ->id($this->getCanonicalId()) + ->siteId($this->siteId) + ->status(null) + ->exists(); + } else { + $isNewSite = false; + } + + // Permissions + $canDeleteDraft = $isDraft && !$this->isProvisionalDraft && $elementsService->canDelete($this, $user); + $canDeleteCanonical = $elementsService->canDelete($canonical, $user); + $canDeleteForSite = ( + $isMultiSiteElement && + count($propSiteIds) > 1 && + (($isCurrent && $canDeleteCanonical) || ($canDeleteDraft && $isNewSite)) && + $elementsService->canDeleteForSite($this, $user) + ); + + if ($isCurrent) { + // Delete for site + if ($canDeleteForSite) { + $items[] = [ + 'id' => 'action-delete-for-site', + 'icon' => 'remove', + 'label' => Craft::t('app', 'Delete {type} for this site', [ + 'type' => $isUnpublishedDraft ? Craft::t('app', 'draft') : static::lowerDisplayName(), + ]), + 'action' => 'elements/delete-for-site', + 'params' => [ + 'elementId' => $this->getCanonicalId(), + 'siteId' => $this->siteId, + ], + 'redirect' => "$redirectUrl#", + 'confirm' => Craft::t('app', 'Are you sure you want to delete the {type} for this site?', [ + 'type' => $isUnpublishedDraft ? Craft::t('app', 'draft') : static::lowerDisplayName(), + ]), + 'destructive' => true, + ]; + } + + // Delete + if ($canDeleteCanonical) { + $items[] = [ + 'id' => 'action-delete', + 'icon' => 'trash', + 'label' => 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, + ]; + } + } elseif ($isDraft && $canDeleteDraft) { + // Delete draft for site + if ($canDeleteForSite) { + $items[] = [ + 'id' => 'action-delete-draft-for-site', + 'icon' => 'remove', + 'label' => Craft::t('app', 'Delete {type} for this site', [ + 'type' => Craft::t('app', 'draft'), + ]), + 'action' => 'elements/delete-for-site', + 'params' => [ + 'elementId' => $this->getCanonicalId(), + 'siteId' => $this->siteId, + 'draftId' => $this->draftId, + ], + 'redirect' => "$redirectUrl#", + 'confirm' => Craft::t('app', 'Are you sure you want to delete the {type} for this site?', [ + 'type' => static::lowerDisplayName(), + ]), + 'destructive' => true, + ]; + } + + // Delete draft + $items[] = [ + 'id' => 'action-delete-draft', + 'icon' => 'trash', + 'label' => Craft::t('app', 'Delete {type}', [ + 'type' => Craft::t('app', 'draft'), + ]), + 'action' => 'elements/delete-draft', + 'params' => [ + 'elementId' => $this->getCanonicalId(), + 'siteId' => $this->siteId, + 'draftId' => $this->draftId, + ], + 'redirect' => $canonical->getCpEditUrl(), + 'confirm' => Craft::t('app', 'Are you sure you want to delete this {type}?', [ + 'type' => Craft::t('app', 'draft'), + ]), + 'destructive' => true, + ]; + } + + return $items; + } + /** * @inheritdoc */ diff --git a/src/base/ElementInterface.php b/src/base/ElementInterface.php index 78eed7f64ef..ab4c005002a 100644 --- a/src/base/ElementInterface.php +++ b/src/base/ElementInterface.php @@ -769,6 +769,14 @@ public function getUrl(): ?string; */ public function getLink(): ?Markup; + /** + * Returns the breadcrumbs that lead up to the element. + * + * @return array + * @since 5.0.0 + */ + public function getCrumbs(): array; + /** * Returns what the element should be called within the control panel. * @@ -955,6 +963,16 @@ public function getCpRevisionsUrl(): ?string; */ public function getAdditionalButtons(): string; + /** + * Returns action menu items for the element’s edit screens. + * + * See [[\craft\helpers\Cp::disclosureMenu()]] for documentation on supported item properties. + * + * @return array + * @since 5.0.0 + */ + public function getActionMenuItems(): array; + /** * Returns the additional locations that should be available for previewing the element, besides its primary [[getUrl()|URL]]. * diff --git a/src/controllers/AppController.php b/src/controllers/AppController.php index c5f51221401..3cb057e0f6a 100644 --- a/src/controllers/AppController.php +++ b/src/controllers/AppController.php @@ -681,8 +681,12 @@ public function actionRenderElements(): Response } } + $view = Craft::$app->getView(); + return $this->asJson([ 'elements' => $elementHtml, + 'headHtml' => $view->getHeadHtml(), + 'bodyHtml' => $view->getBodyHtml(), ]); } } diff --git a/src/controllers/ElementsController.php b/src/controllers/ElementsController.php index 24766c7b11e..34691dd4f04 100644 --- a/src/controllers/ElementsController.php +++ b/src/controllers/ElementsController.php @@ -14,6 +14,7 @@ use craft\behaviors\DraftBehavior; use craft\behaviors\RevisionBehavior; use craft\elements\User; +use craft\enums\MenuItemType; use craft\errors\InvalidElementException; use craft\errors\InvalidTypeException; use craft\errors\UnsupportedSiteException; @@ -25,9 +26,9 @@ use craft\helpers\ElementHelper; use craft\helpers\Html; use craft\helpers\UrlHelper; +use craft\i18n\Locale; use craft\models\ElementActivity; use craft\models\FieldLayoutForm; -use craft\services\Elements; use craft\web\Controller; use craft\web\CpScreenResponseBehavior; use craft\web\View; @@ -300,28 +301,15 @@ public function actionEdit(?ElementInterface $element, ?int $elementId = null): $canonical = $element->getCanonical(true); // Site info + $sitesService = Craft::$app->getSites(); $supportedSites = ElementHelper::supportedSitesForElement($element, true); - $allEditableSiteIds = Craft::$app->getSites()->getEditableSiteIds(); + $allEditableSiteIds = $sitesService->getEditableSiteIds(); $propSites = array_values(array_filter($supportedSites, fn($site) => $site['propagate'])); $propSiteIds = array_column($propSites, 'siteId'); $propEditableSiteIds = array_intersect($propSiteIds, $allEditableSiteIds); - $isMultiSiteElement = count($supportedSites) > 1; $addlEditableSites = array_values(array_filter($supportedSites, fn($site) => !$site['propagate'] && in_array($site['siteId'], $allEditableSiteIds))); $canEditMultipleSites = count($propEditableSiteIds) > 1 || $addlEditableSites; - // Is this a new site that isn’t supported by the canonical element yet? - if ($isUnpublishedDraft) { - $isNewSite = true; - } elseif ($isDraft) { - $isNewSite = !$element::find() - ->id($element->getCanonicalId()) - ->siteId($element->siteId) - ->status(null) - ->exists(); - } else { - $isNewSite = false; - } - // Permissions $canSave = $this->_canSave($element, $user); @@ -332,15 +320,7 @@ public function actionEdit(?ElementInterface $element, ?int $elementId = null): } $canCreateDrafts = $elementsService->canCreateDrafts($canonical, $user); - $canDeleteDraft = $isDraft && !$element->isProvisionalDraft && $elementsService->canDelete($element, $user); $canDuplicateCanonical = $elementsService->canDuplicate($canonical, $user); - $canDeleteCanonical = $elementsService->canDelete($canonical, $user); - $canDeleteForSite = ( - $isMultiSiteElement && - count($propSiteIds) > 1 && - (($isCurrent && $canDeleteCanonical) || ($canDeleteDraft && $isNewSite)) && - $elementsService->canDeleteForSite($element, $user) - ); // Preview targets $previewTargets = ( @@ -386,18 +366,26 @@ public function actionEdit(?ElementInterface $element, ?int $elementId = null): $notice = fn() => $this->_revisionNotice($element::lowerDisplayName()); } + if ($element->enabled) { + $enabledSiteIds = array_flip($elementsService->getEnabledSiteIdsForElement($element->id)); + } else { + $enabledSiteIds = []; + } + $response = $this->asCpScreen() ->editUrl($element->getCpEditUrl()) ->docTitle($docTitle) ->title($title) - ->contextMenuHtml(fn() => $this->_contextMenu( + ->site($element->getSite()) + ->selectableSites(array_map(fn(int $siteId) => [ + 'site' => $sitesService->getSiteById($siteId), + 'status' => isset($enabledSiteIds[$siteId]) ? 'enabled' : 'disabled', + ], $propEditableSiteIds)) + ->crumbs($this->_crumbs($element)) + ->contextMenuItems(fn() => $this->_contextMenuItems( $element, - $isMultiSiteElement, $isUnpublishedDraft, $canCreateDrafts, - $propSiteIds, - $elementsService, - $user, )) ->additionalButtonsHtml(fn() => $this->_additionalButtons( $element, @@ -412,6 +400,10 @@ public function actionEdit(?ElementInterface $element, ?int $elementId = null): $isUnpublishedDraft, $isDraft )) + ->actionMenuItems(fn() => $element->id ? array_filter( + $element->getActionMenuItems(), + fn(array $item) => ($item['id'] ?? null) !== 'action-edit', + ) : []) ->noticeHtml($notice) ->errorSummary(fn() => $this->_errorSummary($element)) ->prepareScreen( @@ -529,54 +521,6 @@ public function actionEdit(?ElementInterface $element, ?int $elementId = null): ]); } } - - if ($canDeleteForSite) { - $response->addAltAction(Craft::t('app', 'Delete {type} for this site', [ - 'type' => $isUnpublishedDraft ? Craft::t('app', 'draft') : $type, - ]), [ - 'destructive' => true, - 'action' => 'elements/delete-for-site', - 'redirect' => "$redirectUrl#", - 'confirm' => Craft::t('app', 'Are you sure you want to delete the {type} for this site?', [ - 'type' => $isUnpublishedDraft ? Craft::t('app', 'draft') : $type, - ]), - ]); - } - - if ($canDeleteCanonical) { - $response->addAltAction(Craft::t('app', 'Delete {type}', [ - 'type' => $isUnpublishedDraft ? Craft::t('app', 'draft') : $type, - ]), [ - 'destructive' => true, - 'action' => $isUnpublishedDraft ? 'elements/delete-draft' : 'elements/delete', - 'redirect' => "$redirectUrl#", - 'confirm' => Craft::t('app', 'Are you sure you want to delete this {type}?', [ - 'type' => $isUnpublishedDraft ? Craft::t('app', 'draft') : $type, - ]), - ]); - } - } elseif ($isDraft && $canDeleteDraft) { - if ($canDeleteForSite) { - $response->addAltAction(Craft::t('app', 'Delete {type} for this site', [ - 'type' => Craft::t('app', 'draft'), - ]), [ - 'destructive' => true, - 'action' => 'elements/delete-for-site', - 'redirect' => "$redirectUrl#", - 'confirm' => Craft::t('app', 'Are you sure you want to delete the {type} for this site?', compact('type')), - ]); - } - - $response->addAltAction(Craft::t('app', 'Delete {type}', [ - 'type' => Craft::t('app', 'draft'), - ]), [ - 'destructive' => true, - 'action' => 'elements/delete-draft', - 'redirect' => $canonical->getCpEditUrl(), - 'confirm' => Craft::t('app', 'Are you sure you want to delete this {type}?', [ - 'type' => Craft::t('app', 'draft'), - ]), - ]); } } @@ -615,30 +559,24 @@ public function actionRevisions(int $elementId): Response ->title(Craft::t('app', 'Revisions for “{title}”', [ 'title' => $element->getUiLabel(), ])) - ->prepareScreen(function(Response $response, string $containerId) use ($element) { - // Give the element a chance to do things here too - $element->prepareEditScreen($response, $containerId); - - /** @var CpScreenResponseBehavior $behavior */ - $behavior = $response->getBehavior(CpScreenResponseBehavior::NAME); - if (!empty($behavior->crumbs)) { - $behavior->crumbs[] = [ - 'label' => $element->getUiLabel(), - 'url' => $element->getCpEditUrl(), - ]; - } - }) - ->contentTemplate('_elements/revisions', [ - 'element' => $element, - 'revisionsQuery' => $element::find() - ->revisionOf($element) - ->site('*') - ->preferSites([$element->siteId]) - ->unique() - ->status(null) - ->andWhere(['!=', 'elements.dateCreated', Db::prepareDateForDb($element->dateUpdated)]) - ->with(['revisionCreator']), - ]); + ->crumbs([ + ...$this->_crumbs($element, false), + [ + 'label' => Craft::t('app', 'Revisions'), + 'current' => true, + ], + ]) + ->contentTemplate('_elements/revisions', [ + 'element' => $element, + 'revisionsQuery' => $element::find() + ->revisionOf($element) + ->site('*') + ->preferSites([$element->siteId]) + ->unique() + ->status(null) + ->andWhere(['!=', 'elements.dateCreated', Db::prepareDateForDb($element->dateUpdated)]) + ->with(['revisionCreator']), + ]); } /** @@ -658,9 +596,7 @@ private function _editElementTitles(ElementInterface $element): array 'type' => $element::lowerDisplayName(), ]); } else { - $title = Craft::t('app', 'Edit {type}', [ - 'type' => $element::displayName(), - ]); + $title = sprintf('%s %s', $element::displayName(), $element->id); } } @@ -681,22 +617,39 @@ private function _editElementTitles(ElementInterface $element): array return [$docTitle, $title]; } - private function _contextMenu( + private function _crumbs(ElementInterface $element, bool $current = true): array + { + if ($element->isProvisionalDraft) { + $element = $element->getCanonical(true); + } + + return [ + ...$element->getCrumbs(), + [ + 'html' => Cp::elementChipHtml($element, ['showDraftName' => !$current]), + 'current' => $current, + ], + ]; + } + + private function _contextMenuItems( ElementInterface $element, - bool $isMultiSiteElement, bool $isUnpublishedDraft, bool $canCreateDrafts, - array $propSiteIds, - Elements $elementsService, - User $user, - ): ?string { - if ($isUnpublishedDraft || !$element->id) { - $drafts = []; - $showDrafts = false; - $revisions = []; - $revisionsPageUrl = null; - $hasMoreRevisions = false; - } else { + ): array { + if ($element->isProvisionalDraft) { + $element = $element->getCanonical(true); + } + + if (!$element->id || $element->getIsUnpublishedDraft()) { + return []; + } + + $elementsService = Craft::$app->getElements(); + + if (!$isUnpublishedDraft) { + $user = Craft::$app->getUser()->getIdentity(); + $drafts = $element::find() ->draftOf($element) ->siteId($element->siteId) @@ -706,54 +659,158 @@ private function _contextMenu( ->collect() ->filter(fn(ElementInterface $draft) => $elementsService->canView($draft, $user)) ->all(); - $showDrafts = !empty($drafts) || $canCreateDrafts; + } else { + $drafts = []; + } - $generalConfig = Craft::$app->getConfig()->getGeneral(); - if ($element->hasRevisions() && (!$generalConfig->maxRevisions || $generalConfig->maxRevisions > 1)) { - $revisionQuery = $element::find() - ->revisionOf($element) - ->siteId($element->siteId) - ->status(null) - ->offset(1) - ->limit($generalConfig->maxRevisions ? min($generalConfig->maxRevisions - 1, 10) : 10) - ->orderBy(['dateCreated' => SORT_DESC]) - ->with(['revisionCreator']); - $revisions = $revisionQuery->all(); - $revisionsPageUrl = $element->getCpRevisionsUrl(); - if ($revisionsPageUrl) { - $hasMoreRevisions = ( - count($revisions) === $revisionQuery->limit && - $revisionQuery->limit < ($generalConfig->maxRevisions - 1) && - ($revisionQuery->count() - 1) > $revisionQuery->limit - ); - } else { - $hasMoreRevisions = false; - } - } else { - $revisions = []; - $revisionsPageUrl = null; - $hasMoreRevisions = false; + $generalConfig = Craft::$app->getConfig()->getGeneral(); + $revisionsPageUrl = null; + $hasMoreRevisions = false; + + if ($element->hasRevisions() && $generalConfig->maxRevisions !== 1) { + $revisionsQuery = $element::find() + ->revisionOf($element) + ->siteId($element->siteId) + ->status(null) + ->offset(1) + ->limit($generalConfig->maxRevisions ? min($generalConfig->maxRevisions - 1, 10) : 10) + ->orderBy(['dateCreated' => SORT_DESC]) + ->with(['revisionCreator']); + + $revisions = $revisionsQuery->all(); + $revisionsPageUrl = $element->getCpRevisionsUrl(); + + if ($revisionsPageUrl) { + $hasMoreRevisions = ($revisionsQuery->count() - 1) > count($revisions); } + } else { + $revisions = []; } + // if we're viewing a revision, make sure it's in the list if ( - $isMultiSiteElement || - $showDrafts || - !empty($revisions) + $element->getIsRevision() && + !ArrayHelper::contains($revisions, fn(ElementInterface $revision) => $revision->id === $element->id) ) { - return Craft::$app->getView()->renderTemplate('_includes/revisionmenu.twig', [ - 'element' => $element, - 'drafts' => $drafts, - 'showDrafts' => $showDrafts, - 'revisions' => $revisions, - 'revisionsPageUrl' => $revisionsPageUrl, - 'hasMoreRevisions' => $hasMoreRevisions, - 'supportedSiteIds' => $propSiteIds, - 'showSiteLabel' => $isMultiSiteElement, - ], View::TEMPLATE_MODE_CP); + $revisions[] = $element; } - return null; + if (empty($drafts) && empty($revisions) && !$canCreateDrafts) { + return []; + } + + $formatter = Craft::$app->getFormatter(); + + $baseParams = $this->request->getQueryParams(); + unset($baseParams['draftId'], $baseParams['revisionId'], $baseParams['siteId'], $baseParams['fresh']); + if (isset($generalConfig->pathParam)) { + unset($baseParams[$generalConfig->pathParam]); + } + + $isDraft = $element->getIsDraft(); + $isRevision = $element->getIsRevision(); + $cpEditUrl = UrlHelper::cpUrl($element->getCpEditUrl(), [ + 'draftId' => null, + 'revisionId' => null, + ]); + + /** @var ElementInterface|RevisionBehavior|null $revision */ + $revision = $element->getCurrentRevision(); + $creator = $revision?->getCreator(); + $timestamp = $formatter->asTimestamp($revision->dateCreated ?? $element->dateUpdated, Locale::LENGTH_SHORT, true); + + $items = [ + [ + 'heading' => Craft::t('app', 'Context'), + 'headingTag' => 'h2', + 'headingAttributes' => ['class' => ['visually-hidden']], + 'listAttributes' => ['class' => ['revision-group-current']], + 'items' => [ + [ + 'label' => Craft::t('app', 'Current'), + 'description' => $creator + ? Craft::t('app', 'Saved {timestamp} by {creator}', [ + 'timestamp' => $timestamp, + 'creator' => $creator->name, + ]) + : Craft::t('app', 'Last saved {timestamp}', [ + 'timestamp' => $timestamp, + ]), + 'url' => $cpEditUrl, + 'selected' => !$isDraft && !$isRevision, + ], + ], + ], + ]; + + if (!empty($drafts)) { + $items[] = [ + 'heading' => Craft::t('app', 'Drafts'), + 'listAttributes' => ['class' => ['revision-group-drafts']], + 'items' => array_map(function($draft) use ($element, $formatter, $cpEditUrl, $baseParams) { + /** @var ElementInterface|DraftBehavior $draft */ + $creator = $draft->getCreator(); + $timestamp = $formatter->asTimestamp($draft->dateUpdated, Locale::LENGTH_SHORT, true); + + return [ + 'label' => $draft->draftName, + 'description' => $creator + ? Craft::t('app', 'Saved {timestamp} by {creator}', [ + 'timestamp' => $timestamp, + 'creator' => $creator->name, + ]) + : Craft::t('app', 'Last saved {timestamp}', [ + 'timestamp' => $timestamp, + ]), + 'url' => UrlHelper::urlWithParams($cpEditUrl, array_merge($baseParams, [ + 'draftId' => $draft->draftId, + ])), + 'selected' => $draft->id === $element->id, + ]; + }, $drafts), + ]; + } + + if (!empty($revisions)) { + $items[] = [ + 'heading' => Craft::t('app', 'Recent Revisions'), + 'listAttributes' => ['class' => ['revision-group-revisions']], + 'items' => array_map(function($revision) use ($element, $formatter, $cpEditUrl, $baseParams) { + /** @var ElementInterface|RevisionBehavior $revision */ + $creator = $revision->getCreator(); + $timestamp = $formatter->asTimestamp($revision->dateCreated, Locale::LENGTH_SHORT, true); + + return [ + 'label' => $revision->getRevisionLabel(), + 'description' => $creator + ? Craft::t('app', 'Saved {timestamp} by {creator}', [ + 'timestamp' => $timestamp, + 'creator' => $creator->name, + ]) + : Craft::t('app', 'Saved {timestamp}', [ + 'timestamp' => $timestamp, + ]), + 'url' => UrlHelper::urlWithParams($cpEditUrl, array_merge($baseParams, [ + 'revisionId' => $revision->revisionId, + ])), + 'selected' => $revision->id === $element->id, + ]; + }, $revisions), + ]; + } + + if ($hasMoreRevisions) { + $items[] = ['type' => MenuItemType::HR]; + $items[] = [ + 'label' => Craft::t('app', 'View all revisions'), + 'url' => $revisionsPageUrl, + 'attributes' => [ + 'class' => ['go'], + ], + ]; + } + + return $items; } private function _additionalButtons( @@ -869,13 +926,15 @@ private function _prepareEditor( $contentHtml = $contentFn($form); $sidebarHtml = $sidebarFn($form); + /** @var CpScreenResponseBehavior|null $behavior */ + $behavior = $response->getBehavior(CpScreenResponseBehavior::NAME); + if ($contentHtml === '' && $sidebarHtml !== '' && $this->request->getAcceptsJson()) { $contentHtml = Html::tag('div', $sidebarHtml, [ 'class' => 'details', ]); $sidebarHtml = ''; - /** @var Response|CpScreenResponseBehavior $response */ - $response->slideoutBodyClass = 'so-full-details'; + $behavior->slideoutBodyClass = 'so-full-details'; } if ($canSave) { @@ -901,11 +960,9 @@ private function _prepareEditor( $contentHtml = implode("\n", $components); } - /** @var Response|CpScreenResponseBehavior $response */ - $response - ->tabs($form?->getTabMenu() ?? []) - ->contentHtml($contentHtml) - ->metaSidebarHtml($sidebarHtml); + $behavior->tabs($form?->getTabMenu() ?? []); + $behavior->contentHtml($contentHtml); + $behavior->metaSidebarHtml($sidebarHtml); if ($canSave && !$element->getIsRevision()) { $this->view->registerJsWithVars(fn($settingsJs) => <<requirePostRequest(); /** @var Element|null $element */ - $element = $this->_element(); + $element = $this->_element(provisional: true); if (!$element || $element->getIsRevision()) { throw new BadRequestHttpException('No element was identified by the request.'); diff --git a/src/controllers/UsersController.php b/src/controllers/UsersController.php index 422850b4674..3e847ad4bb4 100644 --- a/src/controllers/UsersController.php +++ b/src/controllers/UsersController.php @@ -22,7 +22,6 @@ use craft\events\FindLoginUserEvent; use craft\events\InvalidUserTokenEvent; use craft\events\LoginFailureEvent; -use craft\events\RegisterUserActionsEvent; use craft\events\UserEvent; use craft\helpers\ArrayHelper; use craft\helpers\Assets; @@ -107,11 +106,6 @@ class UsersController extends Controller */ public const EVENT_LOGIN_FAILURE = 'loginFailure'; - /** - * @event RegisterUserActionsEvent The event that is triggered when a user’s available actions are being registered - */ - public const EVENT_REGISTER_USER_ACTIONS = 'registerUserActions'; - /** * @event UserEvent The event that is triggered BEFORE user groups and permissions ARE assigned to the user getting saved * @since 3.5.13 @@ -860,164 +854,38 @@ public function actionEditUser(mixed $userId = null, ?User $user = null, ?array } } - $canAdministrateUsers = $currentUser->can('administrateUsers'); - $canModerateUsers = $currentUser->can('moderateUsers'); - $name = trim($user->getName()); - // Determine which actions should be available + // Determine which actions should be available and get status labels // --------------------------------------------------------------------- + $additionalMenuComponents = $user->getActionMenuItems(); + $statusLabel = null; - $statusActions = []; - $sessionActions = []; - $destructiveActions = []; - $miscActions = []; if ($edition === Craft::Pro && !$isNewUser) { switch ($user->getStatus()) { case Element::STATUS_ARCHIVED: case Element::STATUS_DISABLED: $statusLabel = $user->archived ? Craft::t('app', 'Archived') : Craft::t('app', 'Disabled'); - if (Craft::$app->getElements()->canSave($user)) { - $statusActions[] = [ - 'action' => 'users/enable-user', - 'label' => Craft::t('app', 'Enable'), - ]; - } break; case User::STATUS_INACTIVE: case User::STATUS_PENDING: $statusLabel = $user->pending ? Craft::t('app', 'Pending') : Craft::t('app', 'Inactive'); - // Only provide activation actions if they have an email address - if ($user->email) { - if ($user->pending || $canAdministrateUsers) { - $statusActions[] = [ - 'action' => 'users/send-activation-email', - 'label' => Craft::t('app', 'Send activation email'), - ]; - } - if ($canAdministrateUsers) { - // Only need to show the "Copy activation URL" option if they don't have a password - if (!$user->password) { - $statusActions[] = [ - 'id' => 'copy-passwordreset-url', - 'label' => Craft::t('app', 'Copy activation URL…'), - ]; - } - $statusActions[] = [ - 'action' => 'users/activate-user', - 'label' => Craft::t('app', 'Activate account'), - ]; - } - } break; case User::STATUS_SUSPENDED: $statusLabel = Craft::t('app', 'Suspended'); - if (Craft::$app->getUsers()->canSuspend($currentUser, $user)) { - $statusActions[] = [ - 'action' => 'users/unsuspend-user', - 'label' => Craft::t('app', 'Unsuspend'), - ]; - } break; case User::STATUS_ACTIVE: if ($user->locked) { $statusLabel = Craft::t('app', 'Locked'); - if ( - !$isCurrentUser && - ($currentUser->admin || !$user->admin) && - $canModerateUsers && - ( - ($previousUserId = Session::get(User::IMPERSONATE_KEY)) === null || - $user->id != $previousUserId - ) - ) { - $statusActions[] = [ - 'action' => 'users/unlock-user', - 'label' => Craft::t('app', 'Unlock'), - ]; - } } else { $statusLabel = Craft::t('app', 'Active'); } - - if (!$isCurrentUser) { - $statusActions[] = [ - 'action' => 'users/send-password-reset-email', - 'label' => Craft::t('app', 'Send password reset email'), - ]; - if ($canAdministrateUsers) { - $statusActions[] = [ - 'id' => 'copy-passwordreset-url', - 'label' => Craft::t('app', 'Copy password reset URL…'), - ]; - } - } break; } - - if (!$isCurrentUser) { - if (Craft::$app->getUsers()->canImpersonate($currentUser, $user)) { - $sessionActions[] = [ - 'action' => 'users/impersonate', - 'label' => $name - ? Craft::t('app', 'Sign in as {user}', ['user' => $user->getName()]) - : Craft::t('app', 'Sign in as user'), - ]; - $sessionActions[] = [ - 'id' => 'copy-impersonation-url', - 'label' => Craft::t('app', 'Copy impersonation URL…'), - ]; - } - - if (Craft::$app->getUsers()->canSuspend($currentUser, $user) && $user->active && !$user->suspended) { - $destructiveActions[] = [ - 'action' => 'users/suspend-user', - 'label' => Craft::t('app', 'Suspend'), - ]; - } - } - - // Destructive actions that should only be performed on non-admins, unless the current user is also an admin - if (!$user->admin || $currentUser->admin) { - if (($isCurrentUser || $canAdministrateUsers) && ($user->active || $user->pending)) { - $destructiveActions[] = [ - 'action' => 'users/deactivate-user', - 'label' => Craft::t('app', 'Deactivate…'), - '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')) { - $destructiveActions[] = [ - 'id' => 'delete-btn', - 'label' => Craft::t('app', 'Delete…'), - ]; - } - } } - // Give plugins a chance to modify these, or add new ones - $event = new RegisterUserActionsEvent([ - 'user' => $user, - 'statusActions' => $statusActions, - 'sessionActions' => $sessionActions, - 'destructiveActions' => $destructiveActions, - 'miscActions' => $miscActions, - ]); - $this->trigger(self::EVENT_REGISTER_USER_ACTIONS, $event); - - $actions = array_filter([ - $event->statusActions, - $event->miscActions, - $event->sessionActions, - array_map(function(array $action): array { - $action['destructive'] = true; - return $action; - }, $event->destructiveActions), - ]); - // Set the appropriate page title // --------------------------------------------------------------------- @@ -1180,24 +1048,12 @@ public function actionEditUser(mixed $userId = null, ?User $user = null, ?array $this->getView()->registerAssetBundle(EditUserAsset::class); - $deleteModalRedirect = Craft::$app->getSecurity()->hashData(Craft::$app->getEdition() === Craft::Pro ? 'users' : 'dashboard'); - - $this->getView()->registerJsWithVars( - fn($userId, $isCurrent, $deleteModalRedirect) => <<id, $isCurrentUser, $deleteModalRedirect], - View::POS_END - ); - return $this->renderTemplate('users/_edit.twig', compact( 'user', 'isNewUser', 'isCurrentUser', 'statusLabel', - 'actions', + 'additionalMenuComponents', 'languageOptions', 'localeOptions', 'userLanguage', diff --git a/src/elements/Asset.php b/src/elements/Asset.php index 2d8fd73c7f7..2346e4cc38e 100644 --- a/src/elements/Asset.php +++ b/src/elements/Asset.php @@ -31,6 +31,7 @@ use craft\elements\conditions\ElementConditionInterface; use craft\elements\db\AssetQuery; use craft\elements\db\ElementQueryInterface; +use craft\enums\MenuItemType; use craft\errors\AssetException; use craft\errors\FileException; use craft\errors\FsException; @@ -66,8 +67,8 @@ use craft\validators\AssetLocationValidator; use craft\validators\DateTimeValidator; use craft\validators\StringValidator; -use craft\web\CpScreenResponseBehavior; use DateTime; +use Illuminate\Support\Collection; use Throwable; use Twig\Markup; use yii\base\ErrorHandler; @@ -78,7 +79,6 @@ use yii\base\NotSupportedException; use yii\base\UnknownPropertyException; use yii\validators\RequiredValidator; -use yii\web\Response; /** * Asset represents an asset element. @@ -1272,6 +1272,45 @@ protected function cacheTags(): array return $tags; } + /** + * @inheritdoc + */ + protected function crumbs(): array + { + $volume = $this->getVolume(); + + $crumbs = [ + [ + 'label' => Craft::t('app', 'Assets'), + 'url' => UrlHelper::cpUrl('assets'), + ], + [ + 'menu' => Collection::make(Craft::$app->getVolumes()->getViewableVolumes()) + ->map(fn(Volume $v) => [ + 'label' => Craft::t('site', $v->name), + 'url' => "assets/$v->handle", + 'selected' => $v->id === $volume->id, + ]) + ->all(), + ], + ]; + + $uri = "assets/$volume->handle"; + + if ($this->folderPath !== null) { + $subfolders = ArrayHelper::filterEmptyStringsFromArray(explode('/', $this->folderPath)); + foreach ($subfolders as $subfolder) { + $uri .= "/$subfolder"; + $crumbs[] = [ + 'label' => $subfolder, + 'url' => UrlHelper::cpUrl($uri), + ]; + } + } + + return $crumbs; + } + /** * @inheritdoc */ @@ -1383,172 +1422,194 @@ public function getPostEditUrl(): ?string /** * @inheritdoc */ - public function prepareEditScreen(Response $response, string $containerId): void + protected function safeActionMenuItems(): array { - $volume = $this->getVolume(); - $uri = "assets/$volume->handle"; - - $crumbs = [ - [ - 'label' => Craft::t('app', 'Assets'), - 'url' => UrlHelper::cpUrl('assets'), - ], - [ - 'label' => Craft::t('site', $volume->name), - 'url' => UrlHelper::cpUrl($uri), - ], - ]; - - if ($this->folderPath !== null) { - $subfolders = ArrayHelper::filterEmptyStringsFromArray(explode('/', $this->folderPath)); - foreach ($subfolders as $subfolder) { - $uri .= "/$subfolder"; - $crumbs[] = [ - 'label' => $subfolder, - 'url' => UrlHelper::cpUrl($uri), - ]; - } - } + $items = parent::safeActionMenuItems(); - /** @var Response|CpScreenResponseBehavior $response */ - $response->crumbs($crumbs); - } - - /** - * @inheritdoc - */ - public function getAdditionalButtons(): string - { $volume = $this->getVolume(); - $user = Craft::$app->getUser()->getIdentity(); + $userSession = Craft::$app->getUser(); + $user = $userSession->getIdentity(); $view = Craft::$app->getView(); + $updatePreviewThumbJs = $this->_updatePreviewThumbJs(); - $html = Html::beginTag('div', ['class' => 'btngroup']); + $viewItems = []; - if (($url = $this->getUrl()) !== null) { - $html .= Html::a(Craft::t('app', 'View'), $url, [ - 'class' => 'btn', - 'target' => '_blank', - 'data' => [ - 'icon' => 'preview', + // Preview + if (Craft::$app->getAssets()->getAssetPreviewHandler($this) !== null) { + $previewId = 'action-preview'; + $viewItems[] = [ + 'type' => MenuItemType::Button, + 'id' => $previewId, + 'icon' => 'view', + 'label' => Craft::t('app', 'Preview file'), + ]; + + $view->registerJsWithVars(fn($id, $assetId, $settings) => << { + new Craft.PreviewFileModal($assetId, $settings); +}); +JS, [ + $view->namespaceInputId($previewId), + $this->id, + [ + 'startingWidth' => $this->width, + 'startingHeight' => $this->height, ], ]); } - $html .= Html::button(Craft::t('app', 'Download'), [ - 'id' => 'download-btn', - 'class' => 'btn', - 'data' => [ - 'icon' => 'download', - ], - 'aria' => [ - 'label' => Craft::t('app', 'Download'), - ], + // Download + $downloadId = 'action-download'; + $viewItems[] = [ + 'type' => MenuItemType::Button, + 'id' => $downloadId, + 'icon' => 'download', + 'label' => Craft::t('app', 'Download'), + ]; + + $view->registerJsWithVars(fn($id, $assetId) => << { + const form = Craft.createForm().appendTo(Garnish.\$bod); + form.append(Craft.getCsrfInput()); + $('', {type: 'hidden', name: 'action', value: 'assets/download-asset'}).appendTo(form); + $('', {type: 'hidden', name: 'assetId', value: $assetId}).appendTo(form); + $('', {type: 'submit', value: 'Submit'}).appendTo(form); + form.submit(); + form.remove(); +}); +JS, [ + $view->namespaceInputId($downloadId), + $this->id, ]); - $js = << { - const \$form = Craft.createForm().appendTo(Garnish.\$bod); - \$form.append(Craft.getCsrfInput()); - $('', {type: 'hidden', name: 'action', value: 'assets/download-asset'}).appendTo(\$form); - $('', {type: 'hidden', name: 'assetId', value: $this->id}).appendTo(\$form); - $('', {type: 'submit', value: 'Submit'}).appendTo(\$form); - \$form.submit(); - \$form.remove(); - }); -JS; - $view->registerJs($js); + $viewIndex = Collection::make($items)->search(fn(array $item) => ($item['id'] ?? null) === 'view'); + array_splice($items, $viewIndex !== false ? $viewIndex + 1 : 0, 0, $viewItems); - $html .= Html::endTag('div'); + $items[] = ['type' => MenuItemType::HR]; + // Replace file if ( $user->can("replaceFiles:$volume->uid") && ($user->id === $this->uploaderId || $user->can("replacePeerFiles:$volume->uid")) ) { - $html .= Html::button(Craft::t('app', 'Replace file'), [ - 'id' => 'replace-btn', - 'class' => 'btn', - 'data' => [ - 'icon' => 'upload', - ], + $replaceId = 'action-replace'; + $items[] = [ + 'type' => MenuItemType::Button, + 'id' => $replaceId, + 'icon' => 'upload', + 'label' => Craft::t('app', 'Replace file'), + ]; + + $view->registerJsWithVars(fn($id, $namespace, $assetId, $fsType, $dimensionsLabel) => << { + const fileInput = $('', {type: 'file', name: 'replaceFile', class: 'replaceFile hidden'}).appendTo(Garnish.\$bod); + const uploader = Craft.createUploader($fsType, fileInput, { + dropZone: null, + fileInput: fileInput, + paramName: 'replaceFile', + replace: true, + events: { + fileuploadstart: () => { + $('#' + Craft.namespaceId('thumb-container', $namespace)).addClass('loading'); + }, + fileuploaddone: (event, data) => { + const result = event instanceof CustomEvent ? event.detail : data.result; + $('#' + Craft.namespaceId('new-filename', $namespace)).val(result.filename); + $('#' + Craft.namespaceId('file-size-value', $namespace)) + .text(result.formattedSize) + .attr('title', result.formattedSizeInBytes); + let dimensionsVal = $('#' + Craft.namespaceId('dimensions-value', $namespace)); + if (result.dimensions) { + if (!dimensionsVal.length) { + $( + '
' + + '
' + $dimensionsLabel + '
' + + '
' + + '' + ).appendTo($('#' + Craft.namespaceId('details', $namespace) + ' > .meta.read-only')); + dimensionsVal = $('#' + Craft.namespaceId('dimensions-value', $namespace)); + } + dimensionsVal.text(result.dimensions); + } else if (dimensionsVal.length) { + dimensionsVal.parent().remove(); + } + + $updatePreviewThumbJs + Craft.cp.runQueue(); + + if (result.error) { + $('#' + Craft.namespaceId('thumb-container', $namespace)).removeClass('loading'); + alert(result.error); + } + }, + fileuploadfail: (event, data) => { + const response = event instanceof Event + ? event.detail + : data?.jqXHR?.responseJSON; + + let {message, filename} = response || {}; + + if (!message) { + message = filename + ? Craft.t('app', 'Replace file failed for “{filename}”.', {filename}) + : Craft.t('app', 'Replace file failed.'); + } + + Craft.cp.displayError(message); + }, + fileuploadalways: (event, data) => { + $('#' + Craft.namespaceId('thumb-container', $namespace)).removeClass('loading'); + }, + } + }); + + uploader.setParams({ + assetId: $assetId, + }); + + fileInput.click(); +}); +JS, [ + $view->namespaceInputId($replaceId), + $view->getNamespace(), + $this->id, + $this->fs::class, + Craft::t('app', 'Dimensions'), ]); + } - $dimensionsLabel = Html::encode(Craft::t('app', 'Dimensions')); - $updatePreviewThumbJs = $this->_updatePreviewThumbJs(); - $fsClass = addslashes($this->fs::class); - $js = << { - const \$fileInput = $('', {type: 'file', name: 'replaceFile', class: 'replaceFile hidden'}).appendTo(Garnish.\$bod); - const uploader = Craft.createUploader('{$fsClass}', \$fileInput, { - dropZone: null, - fileInput: \$fileInput, - paramName: 'replaceFile', - replace: true, - events: { - fileuploadstart: () => { - $('#thumb-container').addClass('loading'); - }, - fileuploaddone: (event, data) => { - const result = event instanceof CustomEvent ? event.detail : data.result; - - $('#new-filename').val(result.filename); - $('#file-size-value') - .text(result.formattedSize) - .attr('title', result.formattedSizeInBytes); - let \$dimensionsVal = $('#dimensions-value'); - if (result.dimensions) { - if (!\$dimensionsVal.length) { - $( - '
' + - '
$dimensionsLabel' + - '
' + - '' - ).appendTo($('#details > .meta.read-only')); - \$dimensionsVal = $('#dimensions-value'); - } - \$dimensionsVal.text(result.dimensions); - } else if (\$dimensionsVal.length) { - \$dimensionsVal.parent().remove(); - } - $updatePreviewThumbJs - Craft.cp.runQueue(); - if (result.error) { - $('#thumb-container').removeClass('loading'); - alert(result.error); - } else { + // Image editor + if ( + $this->getSupportsImageEditor() && + $userSession->checkPermission("editImages:$volume->uid") && + ($userSession->getId() == $this->uploaderId || $userSession->checkPermission("editPeerImages:$volume->uid")) + ) { + $editImageId = 'action-edit-image'; + $items[] = [ + 'type' => MenuItemType::Button, + 'id' => $editImageId, + 'icon' => 'edit', + 'label' => Craft::t('app', 'Open in Image Editor'), + ]; - } - }, - fileuploadfail: (event, data) => { - const response = event instanceof Event - ? event.detail - : data?.jqXHR?.responseJSON; - - let {message, filename} = response || {}; - - if (!message) { - message = filename - ? Craft.t('app', 'Replace file failed for “{filename}”.', {filename}) - : Craft.t('app', 'Replace file failed.'); - } - - Craft.cp.displayError(message); - }, - fileuploadalways: (event, data) => { - $('#thumb-container').removeClass('loading'); - }, - } - }); - uploader.setParams({ - assetId: $this->id, - }); - \$fileInput.click(); + $view->registerJsWithVars(fn($id, $assetId) => << { + new Craft.AssetImageEditor($assetId, { + allowDegreeFractions: Craft.isImagick, + onSave: (data) => { + if (!data.newAssetId) { + $updatePreviewThumbJs + } + }, + }); }); -JS; - $view->registerJs($js); +JS,[ + $view->namespaceInputId($editImageId), + $this->id, + ]); } - return $html . parent::getAdditionalButtons(); + return $items; } /** @@ -2529,80 +2590,6 @@ public function getPreviewHtml(): string ]) . Html::endTag('div'); // .preview-thumb-container; - if ($previewable || $editable) { - $isMobile = Craft::$app->getRequest()->isMobileBrowser(true); - $imageButtonHtml = Html::beginTag('div', [ - 'class' => array_filter([ - 'image-actions', - 'buttons', - ($isMobile ? 'is-mobile' : null), - ]), - ]); - $view = Craft::$app->getView(); - - if ($previewable) { - $imageButtonHtml .= Html::button(Craft::t('app', 'Preview'), [ - 'id' => 'preview-btn', - 'class' => ['btn', 'preview-btn'], - ]); - - $previewBtnId = $view->namespaceInputId('preview-btn'); - $settings = []; - $width = $this->getWidth(); - $height = $this->getHeight(); - if ($width && $height) { - $settings['startingWidth'] = $width; - $settings['startingHeight'] = $height; - } - $jsSettings = Json::encode($settings); - $js = << { - new Craft.PreviewFileModal($this->id, null, $jsSettings); -}); -JS; - $view->registerJs($js); - } - - if ($editable) { - $imageButtonHtml .= Html::button(Craft::t('app', 'Edit Image'), [ - 'id' => 'edit-btn', - 'class' => ['btn', 'edit-btn'], - ]); - - $editBtnId = $view->namespaceInputId('edit-btn'); - $updatePreviewThumbJs = $this->_updatePreviewThumbJs(); - $js = << { - new Craft.AssetImageEditor($this->id, { - allowDegreeFractions: Craft.isImagick, - onSave: data => { - if (data.newAssetId) { - // If this is within an Assets field’s editor slideout, replace the selected asset - const slideout = $('#$editBtnId').closest('[data-slideout]').data('slideout'); - if (slideout && slideout.settings.elementSelectInput) { - slideout.settings.elementSelectInput.replaceElement(slideout.\$element.data('id'), data.newAssetId) - .catch(() => {}); - } - return; - } - - $updatePreviewThumbJs - }, - }); -}); -JS; - $view->registerJs($js); - } - - $imageButtonHtml .= Html::endTag('div'); // .image-actions - - if (Craft::$app->getRequest()->isMobileBrowser(true)) { - $previewThumbHtml .= $imageButtonHtml; - } else { - $previewThumbHtml = Html::appendToTag($previewThumbHtml, $imageButtonHtml); - } - } - $html .= $previewThumbHtml; } catch (NotSupportedException) { // NBD diff --git a/src/elements/Category.php b/src/elements/Category.php index 082100f0d69..8983bdacfb4 100644 --- a/src/elements/Category.php +++ b/src/elements/Category.php @@ -29,10 +29,8 @@ use craft\records\Category as CategoryRecord; use craft\services\ElementSources; use craft\services\Structures; -use craft\web\CpScreenResponseBehavior; use yii\base\Exception; use yii\base\InvalidConfigException; -use yii\web\Response; /** * Category represents a category element. @@ -445,6 +443,36 @@ protected function route(): array|string|null ]; } + /** + * @inheritdoc + */ + protected function crumbs(): array + { + $group = $this->getGroup(); + + $crumbs = [ + [ + 'label' => Craft::t('app', 'Categories'), + 'url' => UrlHelper::url('categories'), + ], + [ + 'label' => Craft::t('site', $group->name), + 'url' => UrlHelper::url('categories/' . $group->handle), + ], + ]; + + $elementsService = Craft::$app->getElements(); + $user = Craft::$app->getUser()->getIdentity(); + + foreach ($this->getAncestors()->all() as $ancestor) { + if ($elementsService->canView($ancestor, $user)) { + $crumbs[] = ['html' => Cp::elementChipHtml($ancestor)]; + } + } + + return $crumbs; + } + /** * @inheritdoc */ @@ -583,40 +611,6 @@ public function getPostEditUrl(): ?string return UrlHelper::cpUrl('categories'); } - /** - * @inheritdoc - */ - public function prepareEditScreen(Response $response, string $containerId): void - { - $group = $this->getGroup(); - - $crumbs = [ - [ - 'label' => Craft::t('app', 'Categories'), - 'url' => UrlHelper::url('categories'), - ], - [ - 'label' => Craft::t('site', $group->name), - 'url' => UrlHelper::url('categories/' . $group->handle), - ], - ]; - - $elementsService = Craft::$app->getElements(); - $user = Craft::$app->getUser()->getIdentity(); - - foreach ($this->getCanonical()->getAncestors()->all() as $ancestor) { - if ($elementsService->canView($ancestor, $user)) { - $crumbs[] = [ - 'label' => $ancestor->title, - 'url' => $ancestor->getCpEditUrl(), - ]; - } - } - - /** @var Response|CpScreenResponseBehavior $response */ - $response->crumbs($crumbs); - } - /** * @inheritdoc */ diff --git a/src/elements/Entry.php b/src/elements/Entry.php index 17023ecc3e7..e37858689bc 100644 --- a/src/elements/Entry.php +++ b/src/elements/Entry.php @@ -53,13 +53,11 @@ use craft\services\Structures; use craft\validators\DateCompareValidator; use craft\validators\DateTimeValidator; -use craft\web\CpScreenResponseBehavior; use DateTime; use Illuminate\Support\Collection; use yii\base\Exception; use yii\base\InvalidConfigException; use yii\db\Expression; -use yii\web\Response; /** * Entry represents an entry element. @@ -1022,6 +1020,62 @@ protected function route(): array|string|null ]; } + /** + * @inheritdoc + */ + protected function crumbs(): array + { + $section = $this->getSection(); + + if (!$section) { + return []; + } + + $sections = Collection::make(Craft::$app->getEntries()->getEditableSections()); + /** @var Collection $sectionOptions */ + $sectionOptions = $sections + ->filter(fn(Section $s) => $s->type !== Section::TYPE_SINGLE) + ->map(fn(Section $s) => [ + 'label' => Craft::t('site', $s->name), + 'url' => "entries/$s->handle", + 'selected' => $s->id === $section->id, + ]); + + if ($sections->contains(fn(Section $s) => $s->type === Section::TYPE_SINGLE)) { + $sectionOptions->prepend([ + 'label' => Craft::t('app', 'Singles'), + 'url' => 'entries/singles', + 'selected' => $section->type === Section::TYPE_SINGLE, + ]); + } + + $crumbs = [ + [ + 'label' => Craft::t('app', 'Entries'), + 'url' => 'entries', + ], + [ + 'menu' => [ + 'label' => Craft::t('app', 'Select entry type'), + 'items' => $sectionOptions->all(), + ], + ], + ]; + + if ($section->type === Section::TYPE_STRUCTURE) { + $elementsService = Craft::$app->getElements(); + $user = Craft::$app->getUser()->getIdentity(); + + foreach ($this->getAncestors()->all() as $ancestor) { + if ($elementsService->canView($ancestor, $user)) { + $crumbs[] = ['html' => Cp::elementChipHtml($ancestor)]; + } + } + } + + return $crumbs; + } + /** * @inheritdoc */ @@ -1640,66 +1694,6 @@ protected function cpRevisionsUrl(): ?string return sprintf('%s/revisions', $this->cpEditUrl()); } - /** - * @inheritdoc - */ - public function prepareEditScreen(Response $response, string $containerId): void - { - if ($this->fieldId) { - $crumbs = []; - $owner = $this->getOwner(); - - do { - array_unshift($crumbs, ['html' => Cp::elementChipHtml($owner)]); - if (!$owner instanceof NestedElementInterface) { - break; - } - $owner = $owner->getOwner(); - if (!$owner) { - break; - } - } while (true); - } else { - $section = $this->getSection(); - - $crumbs = [ - [ - 'label' => Craft::t('app', 'Entries'), - 'url' => 'entries', - ], - ]; - - if ($section->type === Section::TYPE_SINGLE) { - $crumbs[] = [ - 'label' => Craft::t('app', 'Singles'), - 'url' => 'entries/singles', - ]; - } else { - $crumbs[] = [ - 'label' => Craft::t('site', $section->name), - 'url' => "entries/$section->handle", - ]; - - if ($section->type === Section::TYPE_STRUCTURE) { - $elementsService = Craft::$app->getElements(); - $user = Craft::$app->getUser()->getIdentity(); - - foreach ($this->getCanonical()->getAncestors()->all() as $ancestor) { - if ($elementsService->canView($ancestor, $user)) { - $crumbs[] = [ - 'label' => $ancestor->title, - 'url' => $ancestor->getCpEditUrl(), - ]; - } - } - } - } - } - - /** @var Response|CpScreenResponseBehavior $response */ - $response->crumbs($crumbs); - } - /** * @inheritdoc * @since 3.3.0 diff --git a/src/elements/User.php b/src/elements/User.php index a1aee083da1..dbec139194e 100644 --- a/src/elements/User.php +++ b/src/elements/User.php @@ -21,6 +21,7 @@ use craft\elements\db\AddressQuery; use craft\elements\db\ElementQueryInterface; use craft\elements\db\UserQuery; +use craft\enums\MenuItemType; use craft\enums\PropagationMethod; use craft\events\AuthenticateUserEvent; use craft\events\DefineValueEvent; @@ -34,7 +35,6 @@ use craft\helpers\StringHelper; use craft\helpers\UrlHelper; use craft\i18n\Formatter; -use craft\i18n\Locale; use craft\models\FieldLayout; use craft\models\UserGroup; use craft\records\User as UserRecord; @@ -42,6 +42,7 @@ use craft\validators\UniqueValidator; use craft\validators\UsernameValidator; use craft\validators\UserPasswordValidator; +use craft\web\View; use DateInterval; use DateTime; use DateTimeZone; @@ -105,6 +106,11 @@ class User extends Element implements IdentityInterface public const IMPERSONATE_KEY = 'Craft.UserSessionService.prevImpersonateUserId'; + /** + * @event RegisterUserActionsEvent The event that is triggered when a user’s available actions are being registered + */ + public const EVENT_REGISTER_USER_ACTIONS = 'registerUserActions'; + private static array $photoColors = [ 'red-100', 'orange-200', @@ -1542,6 +1548,288 @@ protected function cpEditUrl(): ?string return null; } + /** + * @inheritdoc + */ + protected function safeActionMenuItems(): array + { + $edition = Craft::$app->getEdition(); + $currentUser = Craft::$app->getUser()->getIdentity(); + $view = Craft::$app->getView(); + $usersService = Craft::$app->getUsers(); + + $canAdministrateUsers = $currentUser->can('administrateUsers'); + $canModerateUsers = $currentUser->can('moderateUsers'); + + $isNewUser = !$this->id; + $isCurrentUser = $this->getIsCurrent(); + + $statusItems = []; + $sessionItems = []; + $miscItems = []; + + if ($edition === Craft::Pro && !$isNewUser) { + switch ($this->getStatus()) { + case Element::STATUS_ARCHIVED: + case Element::STATUS_DISABLED: + if (Craft::$app->getElements()->canSave($this)) { + $statusItems[] = [ + 'label' => Craft::t('app', 'Enable'), + 'action' => 'users/enable-user', + 'params' => [ + 'userId' => $this->id, + ], + ]; + } + break; + case self::STATUS_INACTIVE: + case self::STATUS_PENDING: + // Only provide activation actions if they have an email address + if ($this->email) { + if ($this->pending || $canAdministrateUsers) { + $statusItems[] = [ + 'icon' => 'paperplane', + 'label' => Craft::t('app', 'Send activation email'), + 'action' => 'users/send-activation-email', + 'params' => [ + 'userId' => $this->id, + ], + ]; + } + if ($canAdministrateUsers) { + // Only need to show the "Copy activation URL" option if they don't have a password + if (!$this->password) { + $statusItems[] = $this->_copyPasswordResetUrlActionItem(Craft::t('app', 'Copy activation URL…'), $view); + } + $statusItems[] = [ + 'icon' => 'enabled', + 'label' => Craft::t('app', 'Activate account'), + 'action' => 'users/activate-user', + 'params' => [ + 'userId' => $this->id, + ], + ]; + } + } + break; + case self::STATUS_SUSPENDED: + if ($usersService->canSuspend($currentUser, $this)) { + $statusItems[] = [ + 'icon' => 'enabled', + 'label' => Craft::t('app', 'Unsuspend'), + 'action' => 'users/unsuspend-user', + 'params' => [ + 'userId' => $this->id, + ], + ]; + } + break; + case self::STATUS_ACTIVE: + if ($this->locked) { + if ( + !$isCurrentUser && + ($currentUser->admin || !$this->admin) && + $canModerateUsers && + ( + ($previousUserId = Session::get(self::IMPERSONATE_KEY)) === null || + $this->id != $previousUserId + ) + ) { + $statusItems[] = [ + 'label' => Craft::t('app', 'Unlock'), + 'action' => 'users/unlock-user', + 'params' => [ + 'userId' => $this->id, + ], + ]; + } + } + + if (!$isCurrentUser) { + $statusItems[] = [ + 'icon' => 'paperplane', + 'label' => Craft::t('app', 'Send password reset email'), + 'action' => 'users/send-password-reset-email', + 'params' => [ + 'userId' => $this->id, + ], + ]; + if ($canAdministrateUsers) { + $statusItems[] = $this->_copyPasswordResetUrlActionItem(Craft::t('app', 'Copy password reset URL…'), $view); + } + } + break; + } + + if (!$isCurrentUser) { + if ($usersService->canImpersonate($currentUser, $this)) { + $sessionItems[] = [ + 'icon' => 'key', + 'label' => trim($this->getName()) + ? Craft::t('app', 'Sign in as {user}', ['user' => $this->getName()]) + : Craft::t('app', 'Sign in as user'), + 'action' => 'users/impersonate', + 'params' => [ + 'userId' => $this->id, + ], + 'redirect' => Craft::$app->getConfig()->getGeneral()->getPostCpLoginRedirect(), + ]; + + $copyImpersonationUrlId = 'action-copy-impersonation-url'; + $sessionItems[] = [ + 'type' => MenuItemType::Button, + 'id' => $copyImpersonationUrlId, + 'icon' => 'clipboard', + 'label' => Craft::t('app', 'Copy impersonation URL…'), + ]; + + $view->registerJsWithVars(fn($id, $userId, $message) => << { + Craft.sendActionRequest('POST', 'users/get-impersonation-url', { + data: {userId: $userId}, + }).then((response) => { + Craft.ui.createCopyTextPrompt({ + label: $message, + value: response.data.url, + }); + }); +}); +JS, [ + $view->namespaceInputId($copyImpersonationUrlId), + $this->id, + Craft::t('app', 'Copy the impersonation URL, and open it in a new private window.'), + ]); + } + } + } + + return [ + ...parent::safeActionMenuItems(), + ['type' => MenuItemType::HR], + ...$statusItems, + ['type' => MenuItemType::HR], + ...$miscItems, + ['type' => MenuItemType::HR], + ...$sessionItems, + ]; + } + + /** + * @inheritdoc + */ + protected function destructiveActionMenuItems(): array + { + // Intentionally not calling parent::destructiveActionMenuItems() here, + // because we want to override the user deletion UX. + + $edition = Craft::$app->getEdition(); + $currentUser = Craft::$app->getUser()->getIdentity(); + $usersService = Craft::$app->getUsers(); + + $canAdministrateUsers = $currentUser->can('administrateUsers'); + + $isNewUser = !$this->id; + $isCurrentUser = $this->getIsCurrent(); + + $items = []; + + if ($edition === Craft::Pro && !$isNewUser) { + if (!$isCurrentUser) { + if ($usersService->canSuspend($currentUser, $this) && $this->active && !$this->suspended) { + $items[] = [ + 'icon' => 'ban', + 'label' => Craft::t('app', 'Suspend'), + 'action' => 'users/suspend-user', + 'params' => [ + 'userId' => $this->id, + ], + ]; + } + } + + // Destructive actions that should only be performed on non-admins, unless the current user is also an admin + if (!$this->admin || $currentUser->admin) { + if (($isCurrentUser || $canAdministrateUsers) && ($this->active || $this->pending)) { + $items[] = [ + 'icon' => 'disabled', + 'label' => Craft::t('app', 'Deactivate…'), + 'action' => 'users/deactivate-user', + 'params' => [ + 'userId' => $this->id, + ], + '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 = 'action-delete'; + $items[] = [ + 'type' => MenuItemType::Button, + 'id' => $deleteId, + 'icon' => 'trash', + 'label' => 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, + Craft::$app->getSecurity()->hashData(Craft::$app->getEdition() === Craft::Pro ? 'users' : 'dashboard'), + ]); + } + } + } + + return $items; + } + + private function _copyPasswordResetUrlActionItem(string $label, View $view): array + { + $id = 'action-copy-password-reset-url'; + + $view->registerJsWithVars(fn($id, $userId, $message) => << { + Craft.elevatedSessionManager.requireElevatedSession(() => { + Craft.sendActionRequest('POST', 'users/get-password-reset-url', { + data: {userId: $userId} + }).then((response) => { + Craft.ui.createCopyTextPrompt({ + label: $message, + value: response.data.url, + }); + }).catch(({response}) => { + Craft.cp.displayError(response.data.message); + }); + }); +}); +JS, [ + $view->namespaceInputId($id), + $this->id, + Craft::t('app', 'Copy the activation URL'), + ]); + + return [ + 'type' => MenuItemType::Button, + 'id' => $id, + 'icon' => 'clipboard', + 'label' => $label, + ]; + } + /** * Returns the user’s preferences. * diff --git a/src/enums/MenuItemType.php b/src/enums/MenuItemType.php new file mode 100644 index 00000000000..da6cf1133d9 --- /dev/null +++ b/src/enums/MenuItemType.php @@ -0,0 +1,25 @@ + + * @since 5.0.0 + */ +enum MenuItemType: string +{ + case Link = 'link'; + case Button = 'button'; + case HR = 'hr'; + case Group = 'group'; +} diff --git a/src/events/DefineMenuItemsEvent.php b/src/events/DefineMenuItemsEvent.php new file mode 100644 index 00000000000..357cbac6d5e --- /dev/null +++ b/src/events/DefineMenuItemsEvent.php @@ -0,0 +1,24 @@ + + * @since 5.0.0 + */ +class DefineMenuItemsEvent extends Event +{ + /** + * @var array The menu items. + */ + public array $items = []; +} diff --git a/src/events/RegisterUserActionsEvent.php b/src/events/RegisterUserActionsEvent.php deleted file mode 100644 index 2b4d8447740..00000000000 --- a/src/events/RegisterUserActionsEvent.php +++ /dev/null @@ -1,45 +0,0 @@ - - * @since 3.0.0 - */ -class RegisterUserActionsEvent extends Event -{ - /** - * @var User|null The user associated with the event - */ - public ?User $user = null; - - /** - * @var array Actions related to the user’s status - */ - public array $statusActions = []; - - /** - * @var array Actions related to the user’s authenticated session - */ - public array $sessionActions = []; - - /** - * @var array Destructive actions - */ - public array $destructiveActions = []; - - /** - * @var array Miscellaneous actions - */ - public array $miscActions = []; -} diff --git a/src/helpers/Cp.php b/src/helpers/Cp.php index 472cea1e4d1..af22d3326bd 100644 --- a/src/helpers/Cp.php +++ b/src/helpers/Cp.php @@ -14,6 +14,7 @@ use craft\behaviors\DraftBehavior; use craft\elements\Address; use craft\enums\LicenseKeyStatus; +use craft\enums\MenuItemType; use craft\errors\InvalidHtmlTagException; use craft\errors\InvalidPluginException; use craft\events\DefineElementHtmlEvent; @@ -425,6 +426,7 @@ public static function elementChipHtml(ElementInterface $element, array $config 'showLabel' => true, 'showStatus' => true, 'showThumb' => true, + 'showActionMenu' => false, 'size' => self::ELEMENT_SIZE_SMALL, ]; @@ -484,7 +486,10 @@ public static function elementChipHtml(ElementInterface $element, array $config $html .= self::elementLabelHtml($element, $config, $attributes, fn() => $element->getChipLabelHtml()); } + $actionMenuItems = $config['showActionMenu'] ? $element->getActionMenuItems() : null; + $html .= Html::beginTag('div', ['class' => 'chip-actions']) . + ($config['showActionMenu'] ? self::elementActionMenu($element) : '') . ($config['sortable'] ? Html::button('', [ 'class' => ['move', 'icon'], 'title' => Craft::t('app', 'Reorder'), @@ -538,6 +543,7 @@ public static function elementCardHtml(ElementInterface $element, array $config 'context' => 'index', 'id' => sprintf('card-%s', mt_rand()), 'inputName' => null, + 'showActionMenu' => false, ]; $attributes = ArrayHelper::merge( @@ -572,6 +578,7 @@ public static function elementCardHtml(ElementInterface $element, array $config 'title' => Craft::t('app', 'Select'), 'aria' => ['label' => Craft::t('app', 'Select')], ]) : '') . + ($config['showActionMenu'] ? self::elementActionMenu($element) : '') . ($config['sortable'] ? Html::button('', [ 'class' => ['move', 'icon'], 'title' => Craft::t('app', 'Reorder'), @@ -602,6 +609,44 @@ public static function elementCardHtml(ElementInterface $element, array $config return $html; } + /** + * Renders accessible HTML for status indicators. + * + * When the `status` is equal to "draft" the draft icon will be displayed. The attributes passed as the + * second argument should be a status definition from [[\craft\base\ElementInterface::statuses]] + * + * @param string $status Status string + * @param array|null $attributes Attributes to be passed along. + * @return string|null + * @since 5.0.0 + */ + public static function statusIndicatorHtml(string $status, array $attributes = null): ?string + { + if ($status === 'draft') { + return Html::tag('span', '', [ + 'data' => ['icon' => 'draft'], + 'class' => 'icon', + 'role' => 'img', + 'aria' => [ + 'label' => sprintf('%s %s', Craft::t('app', 'Status:'), Craft::t('app', 'Draft')), + ], + ]); + } + + return Html::tag('span', '', [ + 'class' => array_filter([ + 'status', + $status, + $attributes['color'] ?? null, + ]), + 'role' => 'img', + 'aria' => [ + 'label' => sprintf('%s %s', Craft::t('app', 'Status:'), $attributes['label'] ?? ucfirst($status)), + ], + ]); + } + + private static function baseElementAttributes(ElementInterface $element, array $config): array { $elementsService = Craft::$app->getElements(); @@ -641,14 +686,7 @@ private static function baseElementAttributes(ElementInterface $element, array $ private static function elementStatusHtml(ElementInterface $element): ?string { if ($element->getIsDraft()) { - return Html::tag('span', '', [ - 'data' => ['icon' => 'draft'], - 'class' => 'icon', - 'role' => 'img', - 'aria' => [ - 'label' => sprintf('%s %s', Craft::t('app', 'Status:'), Craft::t('app', 'Draft')), - ], - ]); + return self::statusIndicatorHtml('draft'); } if (!$element::hasStatuses()) { @@ -657,37 +695,34 @@ private static function elementStatusHtml(ElementInterface $element): ?string $status = $element->getStatus(); $statusDef = $element::statuses()[$status] ?? null; - return Html::tag('span', '', [ - 'class' => array_filter([ - 'status', - $status, - $statusDef['color'] ?? null, - ]), - 'role' => 'img', - 'aria' => [ - 'label' => sprintf('%s %s', Craft::t('app', 'Status:'), $statusDef['label'] ?? $statusDef ?? ucfirst($status)), - ], - ]); + + // Just to give the `statusIndicatorHtml` clean types + if (is_string($statusDef)) { + $statusDef = ['label' => $statusDef]; + } + + return self::statusIndicatorHtml($status, $statusDef); } private static function elementLabelHtml(ElementInterface $element, array $config, array $attributes, callable $uiLabel): string { $content = implode('', array_map( - fn(string $segment) => Html::tag('span', Html::encode($segment), ['class' => 'segment']), - $element->getUiLabelPath() - )) . + fn(string $segment) => Html::tag('span', Html::encode($segment), ['class' => 'segment']), + $element->getUiLabelPath() + )) . $uiLabel(); // show the draft name? - if (($config['showDraftName'] ?? true) && $element->getIsDraft() && !$element->getIsUnpublishedDraft()) { + if (($config['showDraftName'] ?? true) && $element->getIsDraft() && !$element->isProvisionalDraft && !$element->getIsUnpublishedDraft()) { /** @var DraftBehavior|ElementInterface $element */ $content .= Html::tag('span', $element->draftName ?: Craft::t('app', 'Draft'), [ - 'class' => 'draft-label', + 'class' => 'context-label', ]); } - $content = ($content !== '' ? Html::tag('a', $content, [ - 'class' => 'label-link', + // the inner span is needed for `text-overflow: ellipsis` (e.g. within breadcrumbs) + $content = ($content !== '' ? Html::tag('a', Html::tag('span', $content), [ + 'class' => ['label-link'], 'href' => !$element->trashed && $config['context'] !== 'modal' ? ($attributes['data']['cp-url'] ?? null) : null, ]) : '') . @@ -707,6 +742,32 @@ private static function elementLabelHtml(ElementInterface $element, array $confi ]); } + private static function elementActionMenu(ElementInterface $element): string + { + return Craft::$app->getView()->namespaceInputs( + function() use ($element): string { + $actionMenuItems = array_filter( + $element->getActionMenuItems(), + fn(array $item) => !($item['destructive'] ?? false), + ); + + if (empty($actionMenuItems)) { + return ''; + } + + return static::disclosureMenu($actionMenuItems, [ + 'hiddenLabel' => Craft::t('app', 'Actions'), + 'buttonAttributes' => [ + 'class' => ['action-btn'], + 'removeClass' => 'menubtn', + 'data' => ['icon' => 'ellipsis'], + ], + ]); + }, + sprintf('element-actions-%s', mt_rand()), + ); + } + /** * Renders an element’s chip HTML. * @@ -1861,9 +1922,9 @@ public static function fieldLayoutDesignerHtml(FieldLayout $fieldLayout, array $ if (!$config['customizableTabs']) { $tab = array_shift($tabs) ?? new FieldLayoutTab([ - 'uid' => StringHelper::UUID(), - 'layout' => $fieldLayout, - ]); + 'uid' => StringHelper::UUID(), + 'layout' => $fieldLayout, + ]); $tab->name = $config['pretendTabName'] ?? Craft::t('app', 'Content'); // Any extra tabs? @@ -2206,6 +2267,209 @@ public static function metadataHtml(array $data): string ]); } + /** + * Returns a disclosure menu’s HTML. + * + * Each item can contain a `type` key set to a [[MenuItemType]] case. By default, it will be set to: + * + * - [[MenuItemType::Button]] if an `action` key is set + * - [[MenuItemType::Group]] if `heading` or `items` keys are set + * - [[MenuItemType::Link]] in all other cases + * + * Link and button items can contain the following keys: + * + * - `id` – The item’s ID + * - `label` – The item label, to be HTML-encoded + * - `html` - The item label, which will be output verbatim, without being HTML-encoded + * - `description` – The item description + * - `status` – The status indicator that should be shown beside the item label + * - `url` – The URL that the item should link to + * - `action` – The controller action that the item should trigger + * - `params` – Request parameters that should be sent to the `action` + * - `confirm` – A confirmation message that should be presented to the user before triggering the `action` + * - `redirect` – The redirect path that the `action` should use + * - `selected` – Whether the item should be marked as selected + * - `hidden` – Whether the item should be hidden + * - `attributes` – Any HTML attributes that should be set on the item’s `` or ` - {% if crumbs %} - - {% endif %} + + {% if crumbs %} + + {% endif %} diff --git a/src/templates/_layouts/cp.twig b/src/templates/_layouts/cp.twig index 7eabeb2868b..790272b236d 100644 --- a/src/templates/_layouts/cp.twig +++ b/src/templates/_layouts/cp.twig @@ -67,13 +67,14 @@ {% set contentNotice = (contentNotice ?? block('contentNotice') ?? '')|trim %} {% set sidebar = (sidebar ?? block('sidebar') ?? '')|trim %} -{% set contextMenu = (contextMenu ?? block('contextMenu') ?? '')|trim %} {% set toolbar = (toolbar ?? block('toolbar') ?? '')|trim %} {% set actionButton = (block('actionButton') ?? '')|trim %} {% set additionalButtons = additionalButtons ?? null %} {% set details = (details ?? block('details') ?? '')|trim %} {% set footer = (footer ?? block('footer') ?? '')|trim %} {% set crumbs = crumbs ?? null %} +{% set contextMenu = (contextMenu ?? block('contextMenu') ?? '')|trim %} +{% set actionMenu = actionMenu ?? '' %} {% set tabs = (tabs ?? [])|length > 1 ? tabs : null %} {% set errorSummary = errorSummary ?? null %} @@ -143,8 +144,14 @@ {% include '_layouts/components/alerts' %} {% endif %} - {% endblock %} {% block content %} @@ -349,47 +363,6 @@
{{ "Status"|t('app') }}
{{ statusLabel }}
- {% if actions|length %} -
- {{ forms.button({ - class: 'menubtn', - attributes: { - id: 'action-menubtn', - title: 'Actions'|t('app'), - aria: { - label: 'Actions'|t('app'), - }, - data: { - icon: 'settings', - }, - }, - spinner: true, - }) }} - -
- {% endif %}
diff --git a/src/translations/en/app.php b/src/translations/en/app.php index 2c19fbd81f5..b5ba1b231af 100644 --- a/src/translations/en/app.php +++ b/src/translations/en/app.php @@ -230,6 +230,7 @@ 'Change icon' => 'Change icon', 'Change logo' => 'Change logo', 'Change photo' => 'Change photo', + 'Change site' => 'Change site', 'Changelog' => 'Changelog', 'Changes discarded.' => 'Changes discarded.', 'Changes saved.' => 'Changes saved.', @@ -883,6 +884,7 @@ 'Media' => 'Media', 'Medium' => 'Medium', 'Meet the Craft community' => 'Meet the Craft community', + 'Menu' => 'Menu', 'Merge the folder (any conflicting files will be replaced)' => 'Merge the folder (any conflicting files will be replaced)', 'Merged PRs' => 'Merged PRs', 'Message saved.' => 'Message saved.', @@ -1044,6 +1046,7 @@ 'Only up to {version} is compatible with your version of Craft.' => 'Only up to {version} is compatible with your version of Craft.', 'Oops!' => 'Oops!', 'Open PRs' => 'Open PRs', + 'Open in Image Editor' => 'Open in Image Editor', 'Open the full edit page in a new tab' => 'Open the full edit page in a new tab', 'Opens in a new window' => 'Opens in a new window', 'Operator' => 'Operator', @@ -1081,8 +1084,6 @@ 'Placeholder Text' => 'Placeholder Text', 'Plain Text' => 'Plain Text', 'Plane' => 'Plane', - 'Please enter your current password.' => 'Please enter your current password.', - 'Please enter your password.' => 'Please enter your password.', 'Please fix on an environment where administrative changes are allowed.' => 'Please fix on an environment where administrative changes are allowed.', 'Please fix the following in your {file} file before proceeding:' => 'Please fix the following in your {file} file before proceeding:', 'Please notify one of your site’s admins.' => 'Please notify one of your site’s admins.', @@ -1289,6 +1290,7 @@ 'Select All' => 'Select All', 'Select a volume' => 'Select a volume', 'Select all' => 'Select all', + 'Select context' => 'Select context', 'Select element' => 'Select element', 'Select transform' => 'Select transform', 'Select {element}' => 'Select {element}', diff --git a/src/web/CpScreenResponseBehavior.php b/src/web/CpScreenResponseBehavior.php index c7cfd3f33fc..611ed36661f 100644 --- a/src/web/CpScreenResponseBehavior.php +++ b/src/web/CpScreenResponseBehavior.php @@ -10,6 +10,7 @@ use Craft; use craft\helpers\Html; use craft\helpers\UrlHelper; +use craft\models\Site; use yii\base\Behavior; /** @@ -62,6 +63,20 @@ class CpScreenResponseBehavior extends Behavior */ public ?string $selectedSubnavItem = null; + /** + * @var Site|null The site that should be displayed within the breadcrumbs. + * @see site() + * @since 5.0.0 + */ + public ?Site $site = null; + + /** + * @var array|null The sites that should be selectable by the site breadcrumb menu. + * @see selectableSites() + * @since 5.0.0 + */ + public ?array $selectableSites = null; + /** * @var array|callable|null Breadcrumbs. * @@ -142,15 +157,18 @@ class CpScreenResponseBehavior extends Behavior public ?string $saveShortcutRedirectUrl = null; /** - * @var string|callable|null The context menu HTML. - * - * This will only be used by full-page screens. - * - * @see contextMenuHtml() - * @see contextMenuTemplate() + * @var callable|null Context menu items factory. + * @see contextMenuItems() * @since 5.0.0 */ - public $contextMenuHtml = null; + public $contextMenuItems = null; + + /** + * @var callable|null Action menu items factory. + * @see actionMenuItems() + * @since 5.0.0 + */ + public $actionMenuItems = null; /** * @var string|null The submit button label. @@ -278,7 +296,14 @@ public function selectedSubnavItem(?string $value): Response /** * Sets the breadcrumbs. * - * Each breadcrumb should be represented by a nested array with `label` and `url` keys. + * Breadcrumbs should be defined by arrays with the following keys: + * + * - `label` – The breadcrumb label, to be HTML-encoded + * - `url` – The URL that the breadcrumb should link to + * - `icon` – The icon which should be displayed beside the label + * - `menu` – The menu items which should be displayed alongside the breadcrumb + * (see [[\craft\helpers\Cp::disclosureMenu()]] for documentation on supported item properties) + * - `current` – Whether the breadcrumb represents the current page * * This will only be used by full-page screens. * @@ -312,6 +337,32 @@ public function addCrumb(string $label, string $url): Response return $this->owner; } + /** + * Sets the site that should be displayed within the breadcrumbs. + * + * @param Site|null $value + * @return Response + * @since 5.0.0 + */ + public function site(?Site $value): Response + { + $this->site = $value; + return $this->owner; + } + + /** + * Sets the sites that should be selectable by the site breadcrumb menu. + * + * @param array|null $value + * @return Response + * @since 5.0.0 + */ + public function selectableSites(?array $value): Response + { + $this->selectableSites = $value; + return $this->owner; + } + /** * Sets the tabs. * @@ -473,34 +524,33 @@ public function saveShortcutRedirectUrl(?string $value): Response } /** - * Sets the context menu HTML. + * Sets the context menu items. * - * This will only be used by full-page screens. + * See [[\craft\helpers\Cp::disclosureMenu()]] for documentation on supported item properties. * - * @param callable|string|null $value + * @param callable|null $value A callback function which returns the menu items * @return Response * @since 5.0.0 */ - public function contextMenuHtml(callable|string|null $value): Response + public function contextMenuItems(?callable $value): Response { - $this->contextMenuHtml = $value; + $this->contextMenuItems = $value; return $this->owner; } /** - * Sets a template that should be used to render the context menu HTML. + * Sets the action menu items. * - * This will only be used by full-page screens. + * See [[\craft\helpers\Cp::disclosureMenu()]] for documentation on supported item properties. * - * @param string $template - * @param array $variables + * @param callable|null $value A callback function which returns the menu items * @return Response + * @since 5.0.0 */ - public function contextMenuTemplate(string $template, array $variables = []): Response + public function actionMenuItems(?callable $value): Response { - return $this->contextMenuHtml( - fn() => Craft::$app->getView()->renderTemplate($template, $variables, View::TEMPLATE_MODE_CP) - ); + $this->actionMenuItems = $value; + return $this->owner; } /** diff --git a/src/web/CpScreenResponseFormatter.php b/src/web/CpScreenResponseFormatter.php index 9ff79f42790..e5b13a5f492 100644 --- a/src/web/CpScreenResponseFormatter.php +++ b/src/web/CpScreenResponseFormatter.php @@ -8,6 +8,7 @@ namespace craft\web; use Craft; +use craft\helpers\Cp; use craft\helpers\Html; use craft\helpers\StringHelper; use craft\helpers\UrlHelper; @@ -87,7 +88,6 @@ private function _formatJson(\yii\web\Request $request, YiiResponse $response, C }, $namespace); $sidebar = $behavior->metaSidebarHtml ? $view->namespaceInputs($behavior->metaSidebarHtml, $namespace) : null; - $errorSummary = $behavior->errorSummary ? $view->namespaceInputs($behavior->errorSummary, $namespace) : null; $response->data = [ @@ -100,6 +100,9 @@ private function _formatJson(\yii\web\Request $request, YiiResponse $response, C 'formAttributes' => $behavior->formAttributes, 'action' => $behavior->action, 'submitButtonLabel' => $behavior->submitButtonLabel, + 'actionMenu' => $this->_actionMenu($behavior, false, [ + 'withButton' => false, + ], $namespace), 'content' => $content, 'sidebar' => $sidebar, 'errorSummary' => $errorSummary, @@ -121,8 +124,8 @@ private function _formatTemplate(YiiResponse $response, CpScreenResponseBehavior call_user_func($behavior->prepareScreen, $response, 'main-form'); } - $crumbs = is_callable($behavior->crumbs) ? call_user_func($behavior->crumbs) : $behavior->crumbs; - $contextMenu = is_callable($behavior->contextMenuHtml) ? call_user_func($behavior->contextMenuHtml) : $behavior->contextMenuHtml; + $docTitle = $behavior->docTitle ?? strip_tags($behavior->title ?? ''); + $crumbs = (is_callable($behavior->crumbs) ? call_user_func($behavior->crumbs) : $behavior->crumbs) ?? []; $addlButtons = is_callable($behavior->additionalButtonsHtml) ? call_user_func($behavior->additionalButtonsHtml) : $behavior->additionalButtonsHtml; $altActions = is_callable($behavior->altActions) ? call_user_func($behavior->altActions) : $behavior->altActions; $notice = is_callable($behavior->noticeHtml) ? call_user_func($behavior->noticeHtml) : $behavior->noticeHtml; @@ -131,6 +134,22 @@ private function _formatTemplate(YiiResponse $response, CpScreenResponseBehavior $pageSidebar = is_callable($behavior->pageSidebarHtml) ? call_user_func($behavior->pageSidebarHtml) : $behavior->pageSidebarHtml; $errorSummary = is_callable($behavior->errorSummary) ? call_user_func($behavior->errorSummary) : $behavior->errorSummary; + if (Craft::$app->getIsMultiSite() && isset($behavior->site)) { + array_unshift($crumbs, [ + 'id' => 'site-crumb', + 'icon' => 'world', + 'label' => Craft::t('site', $behavior->site->name), + 'menu' => [ + 'label' => Craft::t('site', 'Select site'), + 'items' => !empty($behavior->selectableSites) + ? Cp::siteMenuItems($behavior->selectableSites, $behavior->site, [ + 'includeOmittedSites' => true, + ]) + : null, + ], + ]); + } + if ($behavior->action) { $content .= Html::actionInput($behavior->action, [ 'class' => 'action-input', @@ -145,14 +164,26 @@ private function _formatTemplate(YiiResponse $response, CpScreenResponseBehavior 'class' => TemplateResponseBehavior::class, 'template' => '_layouts/cp', 'variables' => [ - 'docTitle' => $behavior->docTitle ?? strip_tags($behavior->title ?? ''), + 'docTitle' => $docTitle, 'title' => $behavior->title, 'selectedSubnavItem' => $behavior->selectedSubnavItem, 'crumbs' => array_map(function(array $crumb): array { - $crumb['url'] = UrlHelper::cpUrl($crumb['url'] ?? ''); + if (isset($crumb['url'])) { + $crumb['url'] = UrlHelper::cpUrl($crumb['url']); + } return $crumb; }, $crumbs ?? []), - 'contextMenu' => $contextMenu, + 'contextMenu' => $this->_contextMenu($behavior), + 'actionMenu' => $this->_actionMenu($behavior, config: [ + 'hiddenLabel' => Craft::t('app', 'Actions'), + 'buttonAttributes' => [ + 'id' => 'action-btn', + 'class' => ['action-btn'], + 'removeClass' => 'menubtn', + 'title' => Craft::t('app', 'Actions'), + 'data' => ['icon' => 'ellipsis'], + ], + ]), 'submitButtonLabel' => $behavior->submitButtonLabel, 'additionalButtons' => $addlButtons, 'tabs' => $behavior->tabs, @@ -177,4 +208,66 @@ private function _formatTemplate(YiiResponse $response, CpScreenResponseBehavior (new TemplateResponseFormatter())->format($response); } + + private function _contextMenu( + CpScreenResponseBehavior $behavior, + ?string $namespace = null, + ): ?string { + return $this->_menu($behavior->contextMenuItems, [ + 'id' => 'context-menu', + 'class' => 'padded', + 'autoLabel' => true, + 'buttonAttributes' => [ + 'id' => 'context-btn', + ], + 'hiddenLabel' => Craft::t('app', 'Select context'), + ], $namespace); + } + + private function _actionMenu( + CpScreenResponseBehavior $behavior, + bool $withDestructive = true, + array $config = [], + ?string $namespace = null, + ): ?string { + if ($behavior->actionMenuItems === null) { + return null; + } + + if ($withDestructive) { + $itemsFactory = $behavior->actionMenuItems; + } else { + $itemsFactory = fn() => array_filter( + call_user_func($behavior->actionMenuItems), + fn(array $item) => !($item['destructive'] ?? false), + ); + } + + return $this->_menu($itemsFactory, $config + [ + 'id' => 'action-menu', + ], $namespace); + } + + private function _menu(?callable $itemsFactory, array $config, ?string $namespace): ?string + { + if ($itemsFactory === null) { + return null; + } + + $render = function() use ($itemsFactory, $config): ?string { + $items = Cp::normalizeMenuItems($itemsFactory() ?? []); + + if (empty($items)) { + return null; + } + + return Cp::disclosureMenu($items, $config); + }; + + if ($namespace) { + return Craft::$app->getView()->namespaceInputs($render, $namespace); + } + + return $render(); + } } diff --git a/src/web/Request.php b/src/web/Request.php index 78fa16e3f42..2f6ecc5b879 100644 --- a/src/web/Request.php +++ b/src/web/Request.php @@ -977,6 +977,23 @@ public function getQueryParams(): array return parent::getQueryParams(); } + /** + * Returns the named GET parameters, without the path parameter. + * + * @return array + * @since 5.0.0 + */ + public function getQueryParamsWithoutPath(): array + { + $params = $this->getQueryParams(); + + if ($this->generalConfig->pathParam) { + unset($params[$this->generalConfig->pathParam]); + } + + return $params; + } + /** * Returns the named GET parameter value. * diff --git a/src/web/assets/cp/dist/cp.js b/src/web/assets/cp/dist/cp.js index 42d0d234bcc..09a7d461e5f 100644 --- a/src/web/assets/cp/dist/cp.js +++ b/src/web/assets/cp/dist/cp.js @@ -1,3 +1,3 @@ /*! For license information please see cp.js.LICENSE.txt */ -(function(){var __webpack_modules__={463:function(){Craft.Accordion=Garnish.Base.extend({$trigger:null,targetSelector:null,_$target:null,init:function(t){var e=this;this.$trigger=$(t),this.$trigger.data("accordion")&&(console.warn("Double-instantiating an accordion trigger on an element"),this.$trigger.data("accordion").destroy()),this.$trigger.data("accordion",this),this.targetSelector=this.$trigger.attr("aria-controls")?"#".concat(this.$trigger.attr("aria-controls")):null,this.targetSelector&&(this._$target=$(this.targetSelector)),this.addListener(this.$trigger,"click","onTriggerClick"),this.addListener(this.$trigger,"keypress",(function(t){var i=t.keyCode;i!==Garnish.SPACE_KEY&&i!==Garnish.RETURN_KEY||(t.preventDefault(),e.onTriggerClick())}))},onTriggerClick:function(){"true"===this.$trigger.attr("aria-expanded")?this.hideTarget(this._$target):this.showTarget(this._$target)},showTarget:function(t){var e=this;if(t&&t.length){this.showTarget._currentHeight=t.height(),t.removeClass("hidden"),this.$trigger.removeClass("collapsed").addClass("expanded").attr("aria-expanded","true");for(var i=0;i=this.settings.maxItems)){var e=$(t).appendTo(this.$tbody),i=e.find(".delete");this.settings.sortable&&this.sorter.addItems(e),this.$deleteBtns=this.$deleteBtns.add(i),this.addListener(i,"click","handleDeleteBtnClick"),this.totalItems++,this.updateUI()}},reorderItems:function(){var t=this;if(this.settings.sortable){for(var e=[],i=0;i=this.settings.maxItems?$(this.settings.newItemBtnSelector).addClass("hidden"):$(this.settings.newItemBtnSelector).removeClass("hidden"))}},{defaults:{tableSelector:null,noItemsSelector:null,newItemBtnSelector:null,idAttribute:"data-id",nameAttribute:"data-name",sortable:!1,allowDeleteAll:!0,minItems:0,maxItems:null,reorderAction:null,deleteAction:null,reorderSuccessMessage:Craft.t("app","New order saved."),reorderFailMessage:Craft.t("app","Couldn’t save new order."),confirmDeleteMessage:Craft.t("app","Are you sure you want to delete “{name}”?"),deleteSuccessMessage:Craft.t("app","“{name}” deleted."),deleteFailMessage:Craft.t("app","Couldn’t delete “{name}”."),onReorderItems:$.noop,onDeleteItem:$.noop}})},6872:function(){Craft.AssetImageEditor=Garnish.Modal.extend({$body:null,$footer:null,$imageTools:null,$buttons:null,$cancelBtn:null,$replaceBtn:null,$saveBtn:null,$focalPointBtn:null,$editorContainer:null,$straighten:null,$croppingCanvas:null,$spinner:null,$constraintContainer:null,$constraintRadioInputs:null,$customConstraints:null,canvas:null,image:null,viewport:null,focalPoint:null,grid:null,croppingCanvas:null,clipper:null,croppingRectangle:null,cropperHandles:null,cropperGrid:null,croppingShade:null,imageStraightenAngle:0,viewportRotation:0,originalWidth:0,originalHeight:0,imageVerticeCoords:null,zoomRatio:1,animationInProgress:!1,currentView:"",assetId:null,cacheBust:null,draggingCropper:!1,scalingCropper:!1,draggingFocal:!1,previousMouseX:0,previousMouseY:0,shiftKeyHeld:!1,editorHeight:0,editorWidth:0,cropperState:!1,scaleFactor:1,flipData:{},focalPointState:!1,maxImageSize:null,lastLoadedDimensions:null,imageIsLoading:!1,mouseMoveEvent:null,croppingConstraint:!1,constraintOrientation:"landscape",showingCustomConstraint:!1,saving:!1,renderImage:null,renderCropper:null,_queue:null,init:function(t,e){var i=this;this._queue=new Craft.Queue,this.cacheBust=Date.now(),this.setSettings(e,Craft.AssetImageEditor.defaults),null===this.settings.allowDegreeFractions&&(this.settings.allowDegreeFractions=Craft.isImagick),Garnish.prefersReducedMotion()&&(this.settings.animationDuration=1),this.assetId=t,this.flipData={x:0,y:0},this.$container=$('').appendTo(Garnish.$bod),this.$body=$('
').appendTo(this.$container),this.$footer=$('