diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c6070c5896..9e7467704ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,12 +1,167 @@ # Release Notes for Craft CMS 4 -## Unreleased +## 4.5.0 - 2023-08-22 +### Content Management +- Entry and category edit pages now show other authors who are currently editing the same element. ([#13420](https://github.com/craftcms/cms/pull/13420)) +- Entry and category edit pages now display a notification when the element has been saved by another author. ([#13420](https://github.com/craftcms/cms/pull/13420)) +- Entry and category edit pages now display a validation error summary at the top of the page, including a mention of errors from other sites. ([#11569](https://github.com/craftcms/cms/issues/11569), [#12125](https://github.com/craftcms/cms/pull/12125)) +- Table fields can now have a “Row heading” column. ([#13231](https://github.com/craftcms/cms/pull/13231)) +- Table fields now have a “Static Rows” setting. ([#13231](https://github.com/craftcms/cms/pull/13231)) +- Table fields no longer show a heading row, if all heading values are blank. ([#13231](https://github.com/craftcms/cms/pull/13231)) +- Element slideouts now show their sidebar content full-screen for elements without a field layout, rather than having an empty body. ([#13056](https://github.com/craftcms/cms/pull/13056), [#13053](https://github.com/craftcms/cms/issues/13053)) +- Relational fields no longer track the previously-selected element(s) when something outside the field is clicked on. ([#13123](https://github.com/craftcms/cms/issues/13123)) +- Element indexes now use field layouts’ overridden field labels, if all field layouts associated with an element source use the same label. ([#8903](https://github.com/craftcms/cms/discussions/8903)) +- Element indexes now track souces’ filters in the URL, so they can be sharable and persisted when navigating back to the index page via the browser history. ([#13499](https://github.com/craftcms/cms/pull/13499)) +- Users’ default thumbnails are now the user initials over a unique color gradient. ([#13511](https://github.com/craftcms/cms/pull/13511)) +- Improved the styling and max height of Selectize inputs. ([#13065](https://github.com/craftcms/cms/discussions/13065), [#13176](https://github.com/craftcms/cms/pull/13176)) +- Selectize inputs now support click-and-drag selection. ([#13273](https://github.com/craftcms/cms/discussions/13273)) +- Selectize single-select inputs now automatically select the current value on focus. ([#13273](https://github.com/craftcms/cms/discussions/13273)) +- It’s now possible to create new entries from entry select modals when a custom source is selected, if the source is configured to only show entries from one section. ([#11499](https://github.com/craftcms/cms/discussions/11499)) +- The Entries index page now shows a primary “New entry” button when a custom source is selected, if the source is configured to only show entries from one section. ([#13390](https://github.com/craftcms/cms/discussions/13390)) +- Invalid Dropdown fields now automatically select their default option and get marked as changed (if they have a default option). ([#13540](https://github.com/craftcms/cms/pull/13540)) + +### Accessibility +- Image assets’ thumbnails and `` tags generated via `craft\element\Asset::getImg()` no longer use the assets’ titles as `alt` fallback values. ([#12854](https://github.com/craftcms/cms/pull/12854)) +- Element index pages now have visually-hidden “Sources” headings for screen readers. ([#12961](https://github.com/craftcms/cms/pull/12961)) +- Element metadata fields now have visually-hidden “Metadata” headings for screen readers. ([#12961](https://github.com/craftcms/cms/pull/12961)) +- Structure elements within element indexes now convey their levels to screen readers. ([#13020](https://github.com/craftcms/cms/pull/13020)) +- Non-image asset thumbnails in the control panel now have `alt` attributes set to the file extension. ([#12724](https://github.com/craftcms/cms/pull/12724)) +- Improved copy-text buttons for screen readers. ([#13073](https://github.com/craftcms/cms/pull/13073)) +- Improved the contrast of asset file type icons. ([#13262](https://github.com/craftcms/cms/pull/13262)) + +### Administration +- Added the “Slug Translation Method” setting to entry types. ([#8962](https://github.com/craftcms/cms/discussions/8962), [#13291](https://github.com/craftcms/cms/pull/13291)) +- Added the “Show the Status field” setting to entry types. ([#12837](https://github.com/craftcms/cms/discussions/12837), [#13265](https://github.com/craftcms/cms/pull/13265)) +- Added the `setup/cloud` command, which prepares a Craft install to be deployed to Craft Cloud. +- Added the `setup/message-tables` command, which can be run to set the project up for database-stored static translations via [DbMessageSource](https://www.yiiframework.com/doc/api/2.0/yii-i18n-dbmessagesource). ([#13542](https://github.com/craftcms/cms/pull/13542)) +- Entry types created via the `entrify/global-set` command now have “Show the Status field” disabled by default. ([#12837](https://github.com/craftcms/cms/discussions/12837)) +- Added the `defaultCountryCode` config setting. ([#13478](https://github.com/craftcms/cms/discussions/13478)) +- Custom element sources can now be configured to only appear for certain sites. ([#13344](https://github.com/craftcms/cms/discussions/13344)) +- The “My Account” page no longer shows a “Require a password reset on next login” checkbox. +- The Asset Indexes utility no longer shows the “Cache remote images” option on ephemeral environments. ([#13202](https://github.com/craftcms/cms/issues/13202)) +- It’s now possible to configure UK addresses to show a “County” field. ([#13361](https://github.com/craftcms/cms/pull/13361)) +- The “Login Page Logo” and “Site Icon” general settings’ image previews now have checkered backgrounds. ([#13210](https://github.com/craftcms/cms/discussions/13210), [#13229](https://github.com/craftcms/cms/pull/13229)) +- Empty field layout tabs are no longer pruned out. ([#13132](https://github.com/craftcms/cms/issues/13132)) +- `active`, `addresses`, `admin`, `email`, `friendlyName`, `locked`, `name`, `password`, `pending`, `suspended`, and `username` are now reserved user field handles. ([#13579](https://github.com/craftcms/cms/issues/13579)) + +### Development +- Added a new `_globals` global Twig variable for front-end templates, which can be used to store custom values in a global scope. ([#13050](https://github.com/craftcms/cms/pull/13050), [#12951](https://github.com/craftcms/cms/discussions/12951)) +- The `|replace` Twig filter now supports passing in a hash with regular expression keys. ([#12956](https://github.com/craftcms/cms/issues/12956)) +- `{% exit %}` tags now support passing a message after the status code. ([#13166](https://github.com/craftcms/cms/discussions/13166)) +- Built-in element types’ GraphQL queries now support passing `null` to `relatedToAssets`, `relatedToEntries`, `relatedToUsers`, `relatedToCategories`, `relatedToTags`, and `relatedToAll` arguments. ([#7954](https://github.com/craftcms/cms/issues/7954)) +- Elements now include custom field values when being iterated over, and when being merged. ([#13009](https://github.com/craftcms/cms/issues/13009)) +- Dropdown and Radio Buttons fields now have a “Column Type” setting, which will be set to `varchar` for existing fields, and defaults to “Automatic” for new fields. ([#13025](https://github.com/craftcms/cms/pull/13025), [#12954](https://github.com/craftcms/cms/issues/12954)) +- Successful `users/login` JSON responses now include information about the logged-in user. ([#13374](https://github.com/craftcms/cms/discussions/13374)) + +### Extensibility +- Filesystem types can now register custom file uploaders. ([#13313](https://github.com/craftcms/cms/pull/13313)) +- When applying a draft, the canonical elements’ `getDirtyAttributes()` and `getDirtyFields()` methods now return the attribute names and field handles that were modified on the draft for save events. ([#12967](https://github.com/craftcms/cms/issues/12967)) +- Admin tables can be configured to pass custom query params to the data endpoint. ([#13416](https://github.com/craftcms/cms/pull/13416)) +- Admin tables can now be programatically reloaded. ([#13416](https://github.com/craftcms/cms/pull/13416)) +- Admin table properties are now reactive. ([#13558](https://github.com/craftcms/cms/pull/13558), [#13520](https://github.com/craftcms/cms/discussions/13520)) +- Native element sources can now define a `defaultFilter` key, which defines the default filter condition that should be applied when the source is selected. ([#13499](https://github.com/craftcms/cms/pull/13499)) +- Added `craft\addresses\SubdivisionRepository`. ([#13361](https://github.com/craftcms/cms/pull/13361)) +- Added `craft\base\Element::showStatusField()`. ([#13265](https://github.com/craftcms/cms/pull/13265)) +- Added `craft\base\Element::thumbSvg()`. ([#13262](https://github.com/craftcms/cms/pull/13262)) +- Added `craft\base\ElementInterface::getIsSlugTranslatable()`. +- Added `craft\base\ElementInterface::getSlugTranslationDescription()`. +- Added `craft\base\ElementInterface::getSlugTranslationKey()`. +- Added `craft\base\ElementInterface::getThumbHtml()`. +- Added `craft\base\ElementInterface::modifyCustomSource()`. +- Added `craft\base\ElementInterface::setDirtyFields()`. +- Added `craft\base\ElementInterface::setFieldValueFromRequest()`. ([#12935](https://github.com/craftcms/cms/issues/12935)) +- Added `craft\base\FieldInterface::normalizeValueFromRequest()`. ([#12935](https://github.com/craftcms/cms/issues/12935)) +- Added `craft\base\FieldLayoutProviderInterface`. ([#13250](https://github.com/craftcms/cms/pull/13250)) +- Added `craft\base\FsInterface::getShowHasUrlSetting()`. ([#13224](https://github.com/craftcms/cms/pull/13224)) +- Added `craft\base\FsInterface::getShowUrlSetting()`. ([#13224](https://github.com/craftcms/cms/pull/13224)) +- Added `craft\base\FsTrait::$showHasUrlSetting`. ([#13224](https://github.com/craftcms/cms/pull/13224)) +- Added `craft\base\FsTrait::$showUrlSetting`. ([#13224](https://github.com/craftcms/cms/pull/13224)) +- Added `craft\behaviors\EventBehavior`. ([#13502](https://github.com/craftcms/cms/discussions/13502)) +- Added `craft\controllers\AssetsControllerTrait`. +- Added `craft\elements\db\ElementQuery::EVENT_BEFORE_POPULATE_ELEMENT`. +- Added `craft\events\AssetBundleEvent`. +- Added `craft\events\DefineAddressSubdivisionsEvent`. ([#13361](https://github.com/craftcms/cms/pull/13361)) +- Added `craft\events\MoveElementEvent::$action`. ([#13429](https://github.com/craftcms/cms/pull/13429)) +- Added `craft\events\MoveElementEvent::$targetElementId`. ([#13429](https://github.com/craftcms/cms/pull/13429)) +- Added `craft\events\MoveElementEvent::getTargetElement()`. ([#13429](https://github.com/craftcms/cms/pull/13429)) +- Added `craft\gql\GqlEntityRegistry::getOrCreate()`. ([#13354](https://github.com/craftcms/cms/pull/13354)) +- Added `craft\helpers\Assets::iconSvg()`. +- Added `craft\helpers\StringHelper::escapeShortcodes()`. ([#12935](https://github.com/craftcms/cms/issues/12935)) +- Added `craft\helpers\StringHelper::unescapeShortcodes()`. ([#12935](https://github.com/craftcms/cms/issues/12935)) +- Added `craft\models\FieldLayout::$provider`. ([#13250](https://github.com/craftcms/cms/pull/13250)) +- Added `craft\services\Addresses::$formatter`, which can be used to override the default address formatter. ([#13242](https://github.com/craftcms/cms/pull/13242), [#12615](https://github.com/craftcms/cms/discussions/12615)) +- Added `craft\services\Addresses::EVENT_DEFINE_ADDRESS_SUBDIVISIONS`. ([#13361](https://github.com/craftcms/cms/pull/13361)) +- Added `craft\services\Addresses::defineAddressSubdivisions()`. ([#13361](https://github.com/craftcms/cms/pull/13361)) +- Added `craft\services\Elements::collectCacheInfoForElement()`. +- Added `craft\services\Elements::getRecentActivity()`. ([#13420](https://github.com/craftcms/cms/pull/13420)) +- Added `craft\services\Elements::trackActivity()`. ([#13420](https://github.com/craftcms/cms/pull/13420)) +- Added `craft\services\ProjectConfig::$cacheDuration`. ([#13164](https://github.com/craftcms/cms/issues/13164)) +- Added `craft\services\Structures::ACTION_APPEND`. ([#13429](https://github.com/craftcms/cms/pull/13429)) +- Added `craft\services\Structures::ACTION_PLACE_AFTER`. ([#13429](https://github.com/craftcms/cms/pull/13429)) +- Added `craft\services\Structures::ACTION_PLACE_BEFORE`. ([#13429](https://github.com/craftcms/cms/pull/13429)) +- Added `craft\services\Structures::ACTION_PREPEND`. ([#13429](https://github.com/craftcms/cms/pull/13429)) +- Added `craft\services\Structures::EVENT_AFTER_INSERT_ELEMENT`. ([#13429](https://github.com/craftcms/cms/pull/13429)) +- Added `craft\services\Structures::EVENT_BEFORE_INSERT_ELEMENT`. ([#13429](https://github.com/craftcms/cms/pull/13429)) +- Added `craft\web\Controller::EVENT_DEFINE_BEHAVIORS`. ([#13477](https://github.com/craftcms/cms/pull/13477)) +- Added `craft\web\Controller::defineBehaviors()`. ([#13477](https://github.com/craftcms/cms/pull/13477)) +- Added `craft\web\CpScreenResponseBehavior::$errorSummary`, `errorSummary()`, and `errorSummaryTemplate()`. ([#12125](https://github.com/craftcms/cms/pull/12125)) +- Added `craft\web\CpScreenResponseBehavior::$pageSidebar`, `pageSidebar()`, and `pageSidebarTemplate()`. ([#13019](https://github.com/craftcms/cms/pull/13019), [#12795](https://github.com/craftcms/cms/issues/12795)) +- Added `craft\web\CpScreenResponseBehavior::$slideoutBodyClass`. +- Added `craft\web\Response::$defaultFormatters`. ([#13541](https://github.com/craftcms/cms/pull/13541)) +- Added `craft\web\View::EVENT_AFTER_REGISTER_ASSET_BUNDLE`. +- `craft\elements\actions\NewChild` is no longer triggerable on elements that have a `data-disallow-new-children` attribute. ([#13539](https://github.com/craftcms/cms/discussions/13539)) +- `craft\elements\actions\SetStatus` is no longer triggerable on elements that have a `data-disallow-status` attribute. +- `craft\helpers\Cp::selectizeFieldHtml()`, `selectizeHtml()`, and `_includes/forms/selectize.twig` now support a `multi` param. ([#13176](https://github.com/craftcms/cms/pull/13176)) +- `craft\helpers\Typecast::properties()` now supports backed enum values. ([#13371](https://github.com/craftcms/cms/pull/13371)) +- `craft\services\Assets::getRootFolderByVolumeId()` now ensures the root folder actually exists, and caches its results internally, improving performance. ([#13297](https://github.com/craftcms/cms/issues/13297)) +- `craft\services\Assets::getThumbUrl()` now has an `$iconFallback` argument, which can be set to `false` to prevent a file icon URL from being returned as a fallback for assets that don’t have image thumbnails. +- `craft\services\Assets::getAllDescendantFolders()` now has an `$asTree` argument. ([#13535](https://github.com/craftcms/cms/discussions/13535)) +- `craft\services\Structures::EVENT_BEFORE_MOVE_ELEMENT` is now cancellable. ([#13429](https://github.com/craftcms/cms/pull/13429)) +- `craft\validators\UniqueValidator` now supports setting an additional filter via the `filter` property. ([#12941](https://github.com/craftcms/cms/pull/12941)) +- `craft\web\Response::setCacheHeaders()` now has `$duration` and `$overwrite` arguments. +- `craft\web\Response::setNoCacheHeaders()` now has an `$overwrite` argument. +- `craft\web\UrlManager` no longer triggers its `EVENT_REGISTER_CP_URL_RULES` and `EVENT_REGISTER_SITE_URL_RULES` events until the request is ready to be routed, making it safe to call `UrlManager::addRules()` from plugin/module constructors. ([#13109](https://github.com/craftcms/cms/issues/13109)) +- Deprecated `craft\base\Element::EVENT_AFTER_MOVE_IN_STRUCTURE`. ([#13429](https://github.com/craftcms/cms/pull/13429)) +- Deprecated `craft\base\Element::EVENT_BEFORE_MOVE_IN_STRUCTURE`. ([#13429](https://github.com/craftcms/cms/pull/13429)) +- Deprecated `craft\base\Element::afterMoveInStructure()`. ([#13429](https://github.com/craftcms/cms/pull/13429)) +- Deprecated `craft\base\Element::beforeMoveInStructure()`. ([#13429](https://github.com/craftcms/cms/pull/13429)) +- Deprecated `craft\events\ElementStructureEvent`. ([#13429](https://github.com/craftcms/cms/pull/13429)) +- Deprecated `craft\helpers\ArrayHelper::firstKey()`. `array_key_first()` should be used instead. +- Deprecated `craft\helpers\Assets::iconPath()`. `craft\helpers\Assets::iconSvg()` or `craft\elements\Asset::getThumbHtml()` should be used instead. +- Deprecated `craft\helpers\Assets::iconUrl()`. +- Deprecated `craft\helpers\UrlHelper::buildQuery()`. `http_build_query()` should be used instead. +- Deprecated `craft\services\Volumes::ensureTopFolder()`. `craft\services\Assets::getRootFolderByVolumeId()` should be used instead. +- Added `Craft.BaseUploader`. ([#13313](https://github.com/craftcms/cms/pull/13313)) +- Added `Craft.createUploader()`. ([#13313](https://github.com/craftcms/cms/pull/13313)) +- Added `Craft.registerUploaderClass()`. ([#13313](https://github.com/craftcms/cms/pull/13313)) +- Added `Craft.Tooltip`. + +### System +- Added support for setting environmental values in a “secrets” PHP file, identified by a `CRAFT_SECRETS_PATH` environment variable. ([#13283](https://github.com/craftcms/cms/pull/13283)) +- Added support for the `CRAFT_LOG_ALLOW_LINE_BREAKS` environment variable. ([#13544](https://github.com/craftcms/cms/pull/13544)) +- All generated URL param characters are now properly encoded. ([#12796](https://github.com/craftcms/cms/issues/12796)) +- `migrate` commands besides `migrate/create` no longer create the migration directory if it doesn’t exist yet. ([#12732](https://github.com/craftcms/cms/pull/12732)) +- When `content` table columns are resized, if any existing values are too long, all column data is now backed up into a new table, and the overflowing values are set to `null`. ([#13025](https://github.com/craftcms/cms/pull/13025)) +- When `content` table columns are renamed, if an existing column with the same name already exists, the original column data is now backed up into a new table and then deleted from the `content` table. ([#13025](https://github.com/craftcms/cms/pull/13025)) +- Plain Text and Table fields no longer convert emoji to shortcodes on PostgreSQL. +- Reduced the size of control panel Ajax request headers. +- Improved GraphQL performance. ([#13354](https://github.com/craftcms/cms/pull/13354)) - Fixed an error that occurred when exporting assets, if a subfolder was selected. ([#13570](https://github.com/craftcms/cms/issues/13570)) - Fixed a bug where icons within secondary buttons were illegible when active. - Fixed a bug where the `|replace` filter was treating search strings as regular expressions even if they were invalid. ([#12956](https://github.com/craftcms/cms/issues/12956)) - Fixed a bug where user groups without “Edit users” permission were being granted “Assign users to [group name]” when upgrading to Craft 4. - Fixed a bug where keyboard shortcuts weren’t getting reactivated when control panel notifications were dismissed. ([#13574](https://github.com/craftcms/cms/issues/13574)) +- Fixed a bug where Plain Text and Table fields were converting posted shortcode-looking strings to emoji. ([#12935](https://github.com/craftcms/cms/issues/12935)) +- Fixed a bug where `craft\elements\Asset::getUrl()` was returning invalid URLs for GIF and SVG assets within filesystems without base URLs, if the `transformGifs` or `transformSvgs` config settings were disabled. ([#13306](https://github.com/craftcms/cms/issues/13306)) +- Fixed a bug where the GraphQL API wasn’t enforcing schema site selections for the requested site. ([#13346](https://github.com/craftcms/cms/pull/13346)) +- Fixed a bug where PM times were getting converted to AM for Greek locales. ([#9942](https://github.com/craftcms/cms/issues/9942)) +- Fixed a bug where Command/Ctrl + clicks on “New entry” button menu options would open the Entries index page in a new tab, and redirect to the Edit Entry page in the current tab. ([#13550](https://github.com/craftcms/cms/issues/13550)) +- Fixed DB cache support for PostgreSQL. +- Updated Yii to 2.0.48.1. ([#13445](https://github.com/craftcms/cms/pull/13445)) +- Loosened the Composer constraint to `^2.2.19`. ([#13396](https://github.com/craftcms/cms/discussions/13396)) +- Internal Composer operations now use a bundled `composer.phar` file, rather than Composer’s PHP API. ([#13519](https://github.com/craftcms/cms/pull/13519)) +- Updated Selectize to 0.15.2. ([#13273](https://github.com/craftcms/cms/discussions/13273)) ## 4.4.17 - 2023-08-08 diff --git a/codeception.yml b/codeception.yml index 715e776222e..050d93f3501 100644 --- a/codeception.yml +++ b/codeception.yml @@ -48,7 +48,6 @@ modules: groups: base: [tests/unit/base] behaviors: [tests/unit/behaviors] - composer: [tests/unit/composer] db: [tests/unit/db] elements: [tests/unit/elements] helpers: [tests/unit/helpers] diff --git a/composer.json b/composer.json index 8e03008e23f..966041e1dd5 100644 --- a/composer.json +++ b/composer.json @@ -35,11 +35,11 @@ "ext-pdo": "*", "ext-zip": "*", "commerceguys/addressing": "^1.2", - "composer/composer": "2.2.19", + "composer/composer": "^2.2.19", "craftcms/plugin-installer": "~1.6.0", "craftcms/server-check": "~2.1.2", "creocoder/yii2-nested-sets": "~0.9.0", - "elvanto/litemoji": "^4.3.0", + "elvanto/litemoji": "~4.3.0", "enshrined/svg-sanitize": "~0.16.0", "guzzlehttp/guzzle": "^7.2.0", "illuminate/collections": "^9.1.0", @@ -56,7 +56,7 @@ "twig/twig": "~3.4.3", "voku/stringy": "^6.4.0", "webonyx/graphql-php": "~14.11.5", - "yiisoft/yii2": "~2.0.47.0", + "yiisoft/yii2": "~2.0.48.1", "yiisoft/yii2-debug": "~2.1.22.0", "yiisoft/yii2-queue": "~2.3.2", "yiisoft/yii2-symfonymailer": "^2.0.0" diff --git a/composer.lock b/composer.lock index 077e7f706db..00859b54713 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "db570d1d17f4ec1b4c17cba6640cf8c5", + "content-hash": "e3c936e6a758c3a7a678193a09004f74", "packages": [ { "name": "cebe/markdown", @@ -136,16 +136,16 @@ }, { "name": "composer/ca-bundle", - "version": "1.3.5", + "version": "1.3.6", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "74780ccf8c19d6acb8d65c5f39cd72110e132bbd" + "reference": "90d087e988ff194065333d16bc5cf649872d9cdb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/74780ccf8c19d6acb8d65c5f39cd72110e132bbd", - "reference": "74780ccf8c19d6acb8d65c5f39cd72110e132bbd", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/90d087e988ff194065333d16bc5cf649872d9cdb", + "reference": "90d087e988ff194065333d16bc5cf649872d9cdb", "shasum": "" }, "require": { @@ -192,7 +192,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.3.5" + "source": "https://github.com/composer/ca-bundle/tree/1.3.6" }, "funding": [ { @@ -208,43 +208,125 @@ "type": "tidelift" } ], - "time": "2023-01-11T08:27:00+00:00" + "time": "2023-06-06T12:02:59+00:00" + }, + { + "name": "composer/class-map-generator", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/composer/class-map-generator.git", + "reference": "953cc4ea32e0c31f2185549c7d216d7921f03da9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/class-map-generator/zipball/953cc4ea32e0c31f2185549c7d216d7921f03da9", + "reference": "953cc4ea32e0c31f2185549c7d216d7921f03da9", + "shasum": "" + }, + "require": { + "composer/pcre": "^2.1 || ^3.1", + "php": "^7.2 || ^8.0", + "symfony/finder": "^4.4 || ^5.3 || ^6 || ^7" + }, + "require-dev": { + "phpstan/phpstan": "^1.6", + "phpstan/phpstan-deprecation-rules": "^1", + "phpstan/phpstan-phpunit": "^1", + "phpstan/phpstan-strict-rules": "^1.1", + "symfony/filesystem": "^5.4 || ^6", + "symfony/phpunit-bridge": "^5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\ClassMapGenerator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Utilities to scan PHP code and generate class maps.", + "keywords": [ + "classmap" + ], + "support": { + "issues": "https://github.com/composer/class-map-generator/issues", + "source": "https://github.com/composer/class-map-generator/tree/1.1.0" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2023-06-30T13:58:57+00:00" }, { "name": "composer/composer", - "version": "2.2.19", + "version": "2.5.8", "source": { "type": "git", "url": "https://github.com/composer/composer.git", - "reference": "30ff21a9af9a10845436abaeeb0bb7276e996d24" + "reference": "4c516146167d1392c8b9b269bb7c24115d262164" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/composer/zipball/30ff21a9af9a10845436abaeeb0bb7276e996d24", - "reference": "30ff21a9af9a10845436abaeeb0bb7276e996d24", + "url": "https://api.github.com/repos/composer/composer/zipball/4c516146167d1392c8b9b269bb7c24115d262164", + "reference": "4c516146167d1392c8b9b269bb7c24115d262164", "shasum": "" }, "require": { "composer/ca-bundle": "^1.0", + "composer/class-map-generator": "^1.0", "composer/metadata-minifier": "^1.0", - "composer/pcre": "^1.0", + "composer/pcre": "^2.1 || ^3.1", "composer/semver": "^3.0", - "composer/spdx-licenses": "^1.2", - "composer/xdebug-handler": "^2.0 || ^3.0", + "composer/spdx-licenses": "^1.5.7", + "composer/xdebug-handler": "^2.0.2 || ^3.0.3", "justinrainbow/json-schema": "^5.2.11", - "php": "^5.3.2 || ^7.0 || ^8.0", - "psr/log": "^1.0 || ^2.0", - "react/promise": "^1.2 || ^2.7", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "react/promise": "^2.8", "seld/jsonlint": "^1.4", - "seld/phar-utils": "^1.0", - "symfony/console": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0", - "symfony/filesystem": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0", - "symfony/finder": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0", - "symfony/process": "^2.8.52 || ^3.4.35 || ^4.4 || ^5.0 || ^6.0" + "seld/phar-utils": "^1.2", + "seld/signal-handler": "^2.0", + "symfony/console": "^5.4.11 || ^6.0.11", + "symfony/filesystem": "^5.4 || ^6.0", + "symfony/finder": "^5.4 || ^6.0", + "symfony/polyfill-php73": "^1.24", + "symfony/polyfill-php80": "^1.24", + "symfony/polyfill-php81": "^1.24", + "symfony/process": "^5.4 || ^6.0" }, "require-dev": { - "phpspec/prophecy": "^1.10", - "symfony/phpunit-bridge": "^4.2 || ^5.0 || ^6.0" + "phpstan/phpstan": "^1.9.3", + "phpstan/phpstan-deprecation-rules": "^1", + "phpstan/phpstan-phpunit": "^1.0", + "phpstan/phpstan-strict-rules": "^1", + "phpstan/phpstan-symfony": "^1.2.10", + "symfony/phpunit-bridge": "^6.0" }, "suggest": { "ext-openssl": "Enabling the openssl extension allows you to access https URLs for repositories and packages", @@ -257,7 +339,12 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "2.2-dev" + "dev-main": "2.5-dev" + }, + "phpstan": { + "includes": [ + "phpstan/rules.neon" + ] } }, "autoload": { @@ -291,7 +378,7 @@ "support": { "irc": "ircs://irc.libera.chat:6697/composer", "issues": "https://github.com/composer/composer/issues", - "source": "https://github.com/composer/composer/tree/2.2.19" + "source": "https://github.com/composer/composer/tree/2.5.8" }, "funding": [ { @@ -307,7 +394,7 @@ "type": "tidelift" } ], - "time": "2023-02-04T13:54:48+00:00" + "time": "2023-06-09T15:13:21+00:00" }, { "name": "composer/metadata-minifier", @@ -380,30 +467,30 @@ }, { "name": "composer/pcre", - "version": "1.0.1", + "version": "3.1.0", "source": { "type": "git", "url": "https://github.com/composer/pcre.git", - "reference": "67a32d7d6f9f560b726ab25a061b38ff3a80c560" + "reference": "4bff79ddd77851fe3cdd11616ed3f92841ba5bd2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/pcre/zipball/67a32d7d6f9f560b726ab25a061b38ff3a80c560", - "reference": "67a32d7d6f9f560b726ab25a061b38ff3a80c560", + "url": "https://api.github.com/repos/composer/pcre/zipball/4bff79ddd77851fe3cdd11616ed3f92841ba5bd2", + "reference": "4bff79ddd77851fe3cdd11616ed3f92841ba5bd2", "shasum": "" }, "require": { - "php": "^5.3.2 || ^7.0 || ^8.0" + "php": "^7.4 || ^8.0" }, "require-dev": { "phpstan/phpstan": "^1.3", "phpstan/phpstan-strict-rules": "^1.1", - "symfony/phpunit-bridge": "^4.2 || ^5" + "symfony/phpunit-bridge": "^5" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "1.x-dev" + "dev-main": "3.x-dev" } }, "autoload": { @@ -431,7 +518,7 @@ ], "support": { "issues": "https://github.com/composer/pcre/issues", - "source": "https://github.com/composer/pcre/tree/1.0.1" + "source": "https://github.com/composer/pcre/tree/3.1.0" }, "funding": [ { @@ -447,7 +534,7 @@ "type": "tidelift" } ], - "time": "2022-01-21T20:24:37+00:00" + "time": "2022-11-17T09:50:14+00:00" }, { "name": "composer/semver", @@ -817,16 +904,16 @@ }, { "name": "defuse/php-encryption", - "version": "v2.3.1", + "version": "v2.4.0", "source": { "type": "git", "url": "https://github.com/defuse/php-encryption.git", - "reference": "77880488b9954b7884c25555c2a0ea9e7053f9d2" + "reference": "f53396c2d34225064647a05ca76c1da9d99e5828" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/defuse/php-encryption/zipball/77880488b9954b7884c25555c2a0ea9e7053f9d2", - "reference": "77880488b9954b7884c25555c2a0ea9e7053f9d2", + "url": "https://api.github.com/repos/defuse/php-encryption/zipball/f53396c2d34225064647a05ca76c1da9d99e5828", + "reference": "f53396c2d34225064647a05ca76c1da9d99e5828", "shasum": "" }, "require": { @@ -835,7 +922,8 @@ "php": ">=5.6.0" }, "require-dev": { - "phpunit/phpunit": "^4|^5|^6|^7|^8|^9" + "phpunit/phpunit": "^5|^6|^7|^8|^9|^10", + "yoast/phpunit-polyfills": "^2.0.0" }, "bin": [ "bin/generate-defuse-key" @@ -877,9 +965,9 @@ ], "support": { "issues": "https://github.com/defuse/php-encryption/issues", - "source": "https://github.com/defuse/php-encryption/tree/v2.3.1" + "source": "https://github.com/defuse/php-encryption/tree/v2.4.0" }, - "time": "2021-04-09T23:57:26+00:00" + "time": "2023-06-19T06:10:36+00:00" }, { "name": "doctrine/collections", @@ -953,25 +1041,29 @@ }, { "name": "doctrine/deprecations", - "version": "v1.1.0", + "version": "v1.1.1", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "8cffffb2218e01f3b370bf763e00e81697725259" + "reference": "612a3ee5ab0d5dd97b7cf3874a6efe24325efac3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/8cffffb2218e01f3b370bf763e00e81697725259", - "reference": "8cffffb2218e01f3b370bf763e00e81697725259", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/612a3ee5ab0d5dd97b7cf3874a6efe24325efac3", + "reference": "612a3ee5ab0d5dd97b7cf3874a6efe24325efac3", "shasum": "" }, "require": { - "php": "^7.1|^8.0" + "php": "^7.1 || ^8.0" }, "require-dev": { "doctrine/coding-standard": "^9", - "phpunit/phpunit": "^7.5|^8.5|^9.5", - "psr/log": "^1|^2|^3" + "phpstan/phpstan": "1.4.10 || 1.10.15", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", + "psalm/plugin-phpunit": "0.18.4", + "psr/log": "^1 || ^2 || ^3", + "vimeo/psalm": "4.30.0 || 5.12.0" }, "suggest": { "psr/log": "Allows logging deprecations via PSR-3 logger implementation" @@ -990,9 +1082,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/v1.1.0" + "source": "https://github.com/doctrine/deprecations/tree/v1.1.1" }, - "time": "2023-05-29T18:55:17+00:00" + "time": "2023-06-03T09:27:29+00:00" }, { "name": "doctrine/lexer", @@ -1417,16 +1509,16 @@ }, { "name": "guzzlehttp/promises", - "version": "2.0.0", + "version": "2.0.1", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "3a494dc7dc1d7d12e511890177ae2d0e6c107da6" + "reference": "111166291a0f8130081195ac4556a5587d7f1b5d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/3a494dc7dc1d7d12e511890177ae2d0e6c107da6", - "reference": "3a494dc7dc1d7d12e511890177ae2d0e6c107da6", + "url": "https://api.github.com/repos/guzzle/promises/zipball/111166291a0f8130081195ac4556a5587d7f1b5d", + "reference": "111166291a0f8130081195ac4556a5587d7f1b5d", "shasum": "" }, "require": { @@ -1480,7 +1572,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.0.0" + "source": "https://github.com/guzzle/promises/tree/2.0.1" }, "funding": [ { @@ -1496,20 +1588,20 @@ "type": "tidelift" } ], - "time": "2023-05-21T13:50:22+00:00" + "time": "2023-08-03T15:11:55+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.5.0", + "version": "2.6.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "b635f279edd83fc275f822a1188157ffea568ff6" + "reference": "8bd7c33a0734ae1c5d074360512beb716bef3f77" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/b635f279edd83fc275f822a1188157ffea568ff6", - "reference": "b635f279edd83fc275f822a1188157ffea568ff6", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/8bd7c33a0734ae1c5d074360512beb716bef3f77", + "reference": "8bd7c33a0734ae1c5d074360512beb716bef3f77", "shasum": "" }, "require": { @@ -1596,7 +1688,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.5.0" + "source": "https://github.com/guzzle/psr7/tree/2.6.0" }, "funding": [ { @@ -1612,20 +1704,20 @@ "type": "tidelift" } ], - "time": "2023-04-17T16:11:26+00:00" + "time": "2023-08-03T15:06:02+00:00" }, { "name": "illuminate/collections", - "version": "v9.52.8", + "version": "v9.52.14", "source": { "type": "git", "url": "https://github.com/illuminate/collections.git", - "reference": "0168d0e44ea0c4fe5451fe08cde7049b9e9f9741" + "reference": "d3710b0b244bfc62c288c1a87eaa62dd28352d1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/illuminate/collections/zipball/0168d0e44ea0c4fe5451fe08cde7049b9e9f9741", - "reference": "0168d0e44ea0c4fe5451fe08cde7049b9e9f9741", + "url": "https://api.github.com/repos/illuminate/collections/zipball/d3710b0b244bfc62c288c1a87eaa62dd28352d1f", + "reference": "d3710b0b244bfc62c288c1a87eaa62dd28352d1f", "shasum": "" }, "require": { @@ -1667,11 +1759,11 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2023-02-22T11:32:27+00:00" + "time": "2023-06-11T21:17:10+00:00" }, { "name": "illuminate/conditionable", - "version": "v9.52.8", + "version": "v9.52.14", "source": { "type": "git", "url": "https://github.com/illuminate/conditionable.git", @@ -1717,7 +1809,7 @@ }, { "name": "illuminate/contracts", - "version": "v9.52.8", + "version": "v9.52.14", "source": { "type": "git", "url": "https://github.com/illuminate/contracts.git", @@ -1765,7 +1857,7 @@ }, { "name": "illuminate/macroable", - "version": "v9.52.8", + "version": "v9.52.14", "source": { "type": "git", "url": "https://github.com/illuminate/macroable.git", @@ -2336,16 +2428,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "1.22.0", + "version": "1.23.1", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "ec58baf7b3c7f1c81b3b00617c953249fb8cf30c" + "reference": "846ae76eef31c6d7790fac9bc399ecee45160b26" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/ec58baf7b3c7f1c81b3b00617c953249fb8cf30c", - "reference": "ec58baf7b3c7f1c81b3b00617c953249fb8cf30c", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/846ae76eef31c6d7790fac9bc399ecee45160b26", + "reference": "846ae76eef31c6d7790fac9bc399ecee45160b26", "shasum": "" }, "require": { @@ -2377,9 +2469,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.22.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/1.23.1" }, - "time": "2023-06-01T12:35:21+00:00" + "time": "2023-08-03T16:32:59+00:00" }, { "name": "pixelandtonic/imagine", @@ -3151,18 +3243,79 @@ }, "time": "2022-08-31T10:31:18+00:00" }, + { + "name": "seld/signal-handler", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/signal-handler.git", + "reference": "f69d119511dc0360440cdbdaa71829c149b7be75" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/signal-handler/zipball/f69d119511dc0360440cdbdaa71829c149b7be75", + "reference": "f69d119511dc0360440cdbdaa71829c149b7be75", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "require-dev": { + "phpstan/phpstan": "^1", + "phpstan/phpstan-deprecation-rules": "^1.0", + "phpstan/phpstan-phpunit": "^1", + "phpstan/phpstan-strict-rules": "^1.3", + "phpunit/phpunit": "^7.5.20 || ^8.5.23", + "psr/log": "^1 || ^2 || ^3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Seld\\Signal\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "Simple unix signal handler that silently fails where signals are not supported for easy cross-platform development", + "keywords": [ + "posix", + "sigint", + "signal", + "sigterm", + "unix" + ], + "support": { + "issues": "https://github.com/Seldaek/signal-handler/issues", + "source": "https://github.com/Seldaek/signal-handler/tree/2.0.1" + }, + "time": "2022-07-20T18:31:45+00:00" + }, { "name": "symfony/console", - "version": "v5.4.24", + "version": "v5.4.26", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "560fc3ed7a43e6d30ea94a07d77f9a60b8ed0fb8" + "reference": "b504a3d266ad2bb632f196c0936ef2af5ff6e273" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/560fc3ed7a43e6d30ea94a07d77f9a60b8ed0fb8", - "reference": "560fc3ed7a43e6d30ea94a07d77f9a60b8ed0fb8", + "url": "https://api.github.com/repos/symfony/console/zipball/b504a3d266ad2bb632f196c0936ef2af5ff6e273", + "reference": "b504a3d266ad2bb632f196c0936ef2af5ff6e273", "shasum": "" }, "require": { @@ -3232,7 +3385,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v5.4.24" + "source": "https://github.com/symfony/console/tree/v5.4.26" }, "funding": [ { @@ -3248,7 +3401,7 @@ "type": "tidelift" } ], - "time": "2023-05-26T05:13:16+00:00" + "time": "2023-07-19T20:11:33+00:00" }, { "name": "symfony/deprecation-contracts", @@ -3319,16 +3472,16 @@ }, { "name": "symfony/event-dispatcher", - "version": "v5.4.22", + "version": "v5.4.26", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "1df20e45d56da29a4b1d8259dd6e950acbf1b13f" + "reference": "5dcc00e03413f05c1e7900090927bb7247cb0aac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/1df20e45d56da29a4b1d8259dd6e950acbf1b13f", - "reference": "1df20e45d56da29a4b1d8259dd6e950acbf1b13f", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/5dcc00e03413f05c1e7900090927bb7247cb0aac", + "reference": "5dcc00e03413f05c1e7900090927bb7247cb0aac", "shasum": "" }, "require": { @@ -3384,7 +3537,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v5.4.22" + "source": "https://github.com/symfony/event-dispatcher/tree/v5.4.26" }, "funding": [ { @@ -3400,7 +3553,7 @@ "type": "tidelift" } ], - "time": "2023-03-17T11:31:58+00:00" + "time": "2023-07-06T06:34:20+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -3546,16 +3699,16 @@ }, { "name": "symfony/finder", - "version": "v5.4.21", + "version": "v5.4.27", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "078e9a5e1871fcfe6a5ce421b539344c21afef19" + "reference": "ff4bce3c33451e7ec778070e45bd23f74214cd5d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/078e9a5e1871fcfe6a5ce421b539344c21afef19", - "reference": "078e9a5e1871fcfe6a5ce421b539344c21afef19", + "url": "https://api.github.com/repos/symfony/finder/zipball/ff4bce3c33451e7ec778070e45bd23f74214cd5d", + "reference": "ff4bce3c33451e7ec778070e45bd23f74214cd5d", "shasum": "" }, "require": { @@ -3589,7 +3742,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v5.4.21" + "source": "https://github.com/symfony/finder/tree/v5.4.27" }, "funding": [ { @@ -3605,7 +3758,7 @@ "type": "tidelift" } ], - "time": "2023-02-16T09:33:00+00:00" + "time": "2023-07-31T08:02:31+00:00" }, { "name": "symfony/http-client", @@ -4663,6 +4816,85 @@ ], "time": "2022-11-03T14:55:06+00:00" }, + { + "name": "symfony/polyfill-php81", + "version": "v1.27.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "707403074c8ea6e2edaf8794b0157a0bfa52157a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/707403074c8ea6e2edaf8794b0157a0bfa52157a", + "reference": "707403074c8ea6e2edaf8794b0157a0bfa52157a", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.27-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.27.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-11-03T14:55:06+00:00" + }, { "name": "symfony/process", "version": "v6.0.19", @@ -5180,16 +5412,16 @@ }, { "name": "voku/anti-xss", - "version": "4.1.41", + "version": "4.1.42", "source": { "type": "git", "url": "https://github.com/voku/anti-xss.git", - "reference": "55a403436494e44a2547a8d42de68e6cad4bca1d" + "reference": "bca1f8607e55a3c5077483615cd93bd8f11bd675" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/voku/anti-xss/zipball/55a403436494e44a2547a8d42de68e6cad4bca1d", - "reference": "55a403436494e44a2547a8d42de68e6cad4bca1d", + "url": "https://api.github.com/repos/voku/anti-xss/zipball/bca1f8607e55a3c5077483615cd93bd8f11bd675", + "reference": "bca1f8607e55a3c5077483615cd93bd8f11bd675", "shasum": "" }, "require": { @@ -5235,7 +5467,7 @@ ], "support": { "issues": "https://github.com/voku/anti-xss/issues", - "source": "https://github.com/voku/anti-xss/tree/4.1.41" + "source": "https://github.com/voku/anti-xss/tree/4.1.42" }, "funding": [ { @@ -5259,7 +5491,7 @@ "type": "tidelift" } ], - "time": "2023-02-12T15:56:55+00:00" + "time": "2023-07-03T14:40:46+00:00" }, { "name": "voku/arrayy", @@ -5881,16 +6113,16 @@ }, { "name": "webonyx/graphql-php", - "version": "v14.11.9", + "version": "v14.11.10", "source": { "type": "git", "url": "https://github.com/webonyx/graphql-php.git", - "reference": "ff91c9f3cf241db702e30b2c42bcc0920e70ac70" + "reference": "d9c2fdebc6aa01d831bc2969da00e8588cffef19" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/ff91c9f3cf241db702e30b2c42bcc0920e70ac70", - "reference": "ff91c9f3cf241db702e30b2c42bcc0920e70ac70", + "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/d9c2fdebc6aa01d831bc2969da00e8588cffef19", + "reference": "d9c2fdebc6aa01d831bc2969da00e8588cffef19", "shasum": "" }, "require": { @@ -5910,8 +6142,7 @@ "phpunit/phpunit": "^7.2 || ^8.5", "psr/http-message": "^1.0", "react/promise": "2.*", - "simpod/php-coveralls-mirror": "^3.0", - "squizlabs/php_codesniffer": "3.5.4" + "simpod/php-coveralls-mirror": "^3.0" }, "suggest": { "psr/http-message": "To use standard GraphQL server", @@ -5935,7 +6166,7 @@ ], "support": { "issues": "https://github.com/webonyx/graphql-php/issues", - "source": "https://github.com/webonyx/graphql-php/tree/v14.11.9" + "source": "https://github.com/webonyx/graphql-php/tree/v14.11.10" }, "funding": [ { @@ -5943,20 +6174,20 @@ "type": "open_collective" } ], - "time": "2023-01-06T12:12:50+00:00" + "time": "2023-07-05T14:23:37+00:00" }, { "name": "yiisoft/yii2", - "version": "2.0.47", + "version": "2.0.48.1", "source": { "type": "git", "url": "https://github.com/yiisoft/yii2-framework.git", - "reference": "8ecf57895d9c4b29cf9658ffe57af5f3d0e25254" + "reference": "de92f154eefe322fc1b1b2a52cce46677441ced4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/yiisoft/yii2-framework/zipball/8ecf57895d9c4b29cf9658ffe57af5f3d0e25254", - "reference": "8ecf57895d9c4b29cf9658ffe57af5f3d0e25254", + "url": "https://api.github.com/repos/yiisoft/yii2-framework/zipball/de92f154eefe322fc1b1b2a52cce46677441ced4", + "reference": "de92f154eefe322fc1b1b2a52cce46677441ced4", "shasum": "" }, "require": { @@ -5967,7 +6198,7 @@ "cebe/markdown": "~1.0.0 | ~1.1.0 | ~1.2.0", "ext-ctype": "*", "ext-mbstring": "*", - "ezyang/htmlpurifier": "~4.6", + "ezyang/htmlpurifier": "^4.6", "lib-pcre": "*", "paragonie/random_compat": ">=1", "php": ">=5.4.0", @@ -6065,7 +6296,7 @@ "type": "tidelift" } ], - "time": "2022-11-18T16:21:58+00:00" + "time": "2023-05-24T19:04:02+00:00" }, { "name": "yiisoft/yii2-composer", @@ -7160,16 +7391,16 @@ }, { "name": "fakerphp/faker", - "version": "v1.22.0", + "version": "v1.23.0", "source": { "type": "git", "url": "https://github.com/FakerPHP/Faker.git", - "reference": "f85772abd508bd04e20bb4b1bbe260a68d0066d2" + "reference": "e3daa170d00fde61ea7719ef47bb09bb8f1d9b01" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/f85772abd508bd04e20bb4b1bbe260a68d0066d2", - "reference": "f85772abd508bd04e20bb4b1bbe260a68d0066d2", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e3daa170d00fde61ea7719ef47bb09bb8f1d9b01", + "reference": "e3daa170d00fde61ea7719ef47bb09bb8f1d9b01", "shasum": "" }, "require": { @@ -7222,9 +7453,9 @@ ], "support": { "issues": "https://github.com/FakerPHP/Faker/issues", - "source": "https://github.com/FakerPHP/Faker/tree/v1.22.0" + "source": "https://github.com/FakerPHP/Faker/tree/v1.23.0" }, - "time": "2023-05-14T12:31:37+00:00" + "time": "2023-06-12T08:44:38+00:00" }, { "name": "graham-campbell/result-type", @@ -7492,16 +7723,16 @@ }, { "name": "nikic/php-parser", - "version": "v4.15.5", + "version": "v4.16.0", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "11e2663a5bc9db5d714eedb4277ee300403b4a9e" + "reference": "19526a33fb561ef417e822e85f08a00db4059c17" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/11e2663a5bc9db5d714eedb4277ee300403b4a9e", - "reference": "11e2663a5bc9db5d714eedb4277ee300403b4a9e", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/19526a33fb561ef417e822e85f08a00db4059c17", + "reference": "19526a33fb561ef417e822e85f08a00db4059c17", "shasum": "" }, "require": { @@ -7542,9 +7773,9 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.15.5" + "source": "https://github.com/nikic/PHP-Parser/tree/v4.16.0" }, - "time": "2023-05-19T20:20:00+00:00" + "time": "2023-06-25T14:52:30+00:00" }, { "name": "phar-io/manifest", @@ -7734,16 +7965,16 @@ }, { "name": "phpstan/phpstan", - "version": "1.10.15", + "version": "1.10.27", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "762c4dac4da6f8756eebb80e528c3a47855da9bd" + "reference": "a9f44dcea06f59d1363b100bb29f297b311fa640" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/762c4dac4da6f8756eebb80e528c3a47855da9bd", - "reference": "762c4dac4da6f8756eebb80e528c3a47855da9bd", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a9f44dcea06f59d1363b100bb29f297b311fa640", + "reference": "a9f44dcea06f59d1363b100bb29f297b311fa640", "shasum": "" }, "require": { @@ -7792,20 +8023,20 @@ "type": "tidelift" } ], - "time": "2023-05-09T15:28:01+00:00" + "time": "2023-08-05T09:57:55+00:00" }, { "name": "phpunit/php-code-coverage", - "version": "9.2.26", + "version": "9.2.27", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "443bc6912c9bd5b409254a40f4b0f4ced7c80ea1" + "reference": "b0a88255cb70d52653d80c890bd7f38740ea50d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/443bc6912c9bd5b409254a40f4b0f4ced7c80ea1", - "reference": "443bc6912c9bd5b409254a40f4b0f4ced7c80ea1", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/b0a88255cb70d52653d80c890bd7f38740ea50d1", + "reference": "b0a88255cb70d52653d80c890bd7f38740ea50d1", "shasum": "" }, "require": { @@ -7861,7 +8092,8 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.26" + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/9.2.27" }, "funding": [ { @@ -7869,7 +8101,7 @@ "type": "github" } ], - "time": "2023-03-06T12:58:08+00:00" + "time": "2023-07-26T13:44:30+00:00" }, { "name": "phpunit/php-file-iterator", @@ -8114,16 +8346,16 @@ }, { "name": "phpunit/phpunit", - "version": "9.6.8", + "version": "9.6.10", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "17d621b3aff84d0c8b62539e269e87d8d5baa76e" + "reference": "a6d351645c3fe5a30f5e86be6577d946af65a328" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/17d621b3aff84d0c8b62539e269e87d8d5baa76e", - "reference": "17d621b3aff84d0c8b62539e269e87d8d5baa76e", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a6d351645c3fe5a30f5e86be6577d946af65a328", + "reference": "a6d351645c3fe5a30f5e86be6577d946af65a328", "shasum": "" }, "require": { @@ -8197,7 +8429,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.8" + "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.10" }, "funding": [ { @@ -8213,7 +8445,7 @@ "type": "tidelift" } ], - "time": "2023-05-11T05:14:45+00:00" + "time": "2023-07-10T04:04:23+00:00" }, { "name": "sebastian/cli-parser", @@ -8721,16 +8953,16 @@ }, { "name": "sebastian/global-state", - "version": "5.0.5", + "version": "5.0.6", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "0ca8db5a5fc9c8646244e629625ac486fa286bf2" + "reference": "bde739e7565280bda77be70044ac1047bc007e34" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/0ca8db5a5fc9c8646244e629625ac486fa286bf2", - "reference": "0ca8db5a5fc9c8646244e629625ac486fa286bf2", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bde739e7565280bda77be70044ac1047bc007e34", + "reference": "bde739e7565280bda77be70044ac1047bc007e34", "shasum": "" }, "require": { @@ -8773,7 +9005,7 @@ ], "support": { "issues": "https://github.com/sebastianbergmann/global-state/issues", - "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.5" + "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.6" }, "funding": [ { @@ -8781,7 +9013,7 @@ "type": "github" } ], - "time": "2022-02-14T08:28:10+00:00" + "time": "2023-08-02T09:26:13+00:00" }, { "name": "sebastian/lines-of-code", @@ -9318,16 +9550,16 @@ }, { "name": "symfony/css-selector", - "version": "v5.4.21", + "version": "v5.4.26", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "95f3c7468db1da8cc360b24fa2a26e7cefcb355d" + "reference": "0ad3f7e9a1ab492c5b4214cf22a9dc55dcf8600a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/95f3c7468db1da8cc360b24fa2a26e7cefcb355d", - "reference": "95f3c7468db1da8cc360b24fa2a26e7cefcb355d", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/0ad3f7e9a1ab492c5b4214cf22a9dc55dcf8600a", + "reference": "0ad3f7e9a1ab492c5b4214cf22a9dc55dcf8600a", "shasum": "" }, "require": { @@ -9364,7 +9596,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v5.4.21" + "source": "https://github.com/symfony/css-selector/tree/v5.4.26" }, "funding": [ { @@ -9380,20 +9612,20 @@ "type": "tidelift" } ], - "time": "2023-02-14T08:03:56+00:00" + "time": "2023-07-07T06:10:25+00:00" }, { "name": "symfony/dom-crawler", - "version": "v5.4.23", + "version": "v5.4.25", "source": { "type": "git", "url": "https://github.com/symfony/dom-crawler.git", - "reference": "4a286c916b74ecfb6e2caf1aa31d3fe2a34b7e08" + "reference": "d2aefa5a7acc5511422792931d14d1be96fe9fea" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/4a286c916b74ecfb6e2caf1aa31d3fe2a34b7e08", - "reference": "4a286c916b74ecfb6e2caf1aa31d3fe2a34b7e08", + "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/d2aefa5a7acc5511422792931d14d1be96fe9fea", + "reference": "d2aefa5a7acc5511422792931d14d1be96fe9fea", "shasum": "" }, "require": { @@ -9439,7 +9671,7 @@ "description": "Eases DOM navigation for HTML and XML documents", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v5.4.23" + "source": "https://github.com/symfony/dom-crawler/tree/v5.4.25" }, "funding": [ { @@ -9455,7 +9687,7 @@ "type": "tidelift" } ], - "time": "2023-04-08T21:20:19+00:00" + "time": "2023-06-05T08:05:41+00:00" }, { "name": "symplify/easy-coding-standard", diff --git a/ecs.php b/ecs.php index d5f142aba4d..8f3fcd8d075 100644 --- a/ecs.php +++ b/ecs.php @@ -10,6 +10,9 @@ __DIR__ . '/tests', __FILE__, ]); + $ecsConfig->skip([ + __DIR__ . '/tests/unit/helpers/typecast', + ]); $ecsConfig->parallel(); $ecsConfig->sets([SetList::CRAFT_CMS_4]); diff --git a/lib/composer-LICENSE.txt b/lib/composer-LICENSE.txt new file mode 100644 index 00000000000..62ecfd8d004 --- /dev/null +++ b/lib/composer-LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (c) Nils Adermann, Jordi Boggiano + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/lib/composer.phar b/lib/composer.phar new file mode 100755 index 00000000000..d39c3e6df0d Binary files /dev/null and b/lib/composer.phar differ diff --git a/package-lock.json b/package-lock.json index a532daa4de1..201d282a8b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@benmajor/jquery-touch-events": "^2.0.3", "@craftcms/sass": "file:packages/craftcms-sass", "@craftcms/vue": "file:packages/craftcms-vue", + "@selectize/selectize": "selectize/selectize.js#master", "@types/jquery": "^3.5.7", "accounting": "^0.4.1", "axios": "^0.27.2", @@ -35,7 +36,6 @@ "punycode": "1.4.1", "react": "^16.14.0", "react-dom": "^16.14.0", - "selectize": "^0.12.6", "timepicker": "^1.13.18", "typescript": "^4.7.4", "v-tooltip": "^2.0.3", @@ -2056,6 +2056,23 @@ "node": ">=14" } }, + "node_modules/@selectize/selectize": { + "version": "0.15.2", + "resolved": "git+ssh://git@github.com/selectize/selectize.js.git#e3f2e0b4aa251375bc21b5fcd8ca7d374a921f08", + "license": "Apache-2.0", + "engines": { + "node": "*" + }, + "peerDependencies": { + "jquery": "^1.7.0 || ^2 || ^3", + "jquery-ui": "^1.13.2" + }, + "peerDependenciesMeta": { + "jquery-ui": { + "optional": true + } + } + }, "node_modules/@sinclair/typebox": { "version": "0.24.51", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", @@ -2918,11 +2935,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/ansicolors": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/ansicolors/-/ansicolors-0.2.1.tgz", - "integrity": "sha512-tOIuy1/SK/dr94ZA0ckDohKXNeBNqZ4us6PjMVLs5h1w2GBB6uPtOknp2+VF4F/zcy9LI70W+Z+pE2Soajky1w==" - }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -3009,14 +3021,6 @@ "node": ">=8" } }, - "node_modules/async": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", - "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", - "dependencies": { - "lodash": "^4.17.14" - } - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -3450,18 +3454,6 @@ "node": ">=0.8.0" } }, - "node_modules/cardinal": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/cardinal/-/cardinal-1.0.0.tgz", - "integrity": "sha512-INsuF4GyiFLk8C91FPokbKTc/rwHqV4JnfatVZ6GPhguP1qmkRWX2dp5tepYboYdPpGWisLVLI+KsXoXFPRSMg==", - "dependencies": { - "ansicolors": "~0.2.1", - "redeyed": "~1.0.0" - }, - "bin": { - "cdl": "bin/cdl.js" - } - }, "node_modules/caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", @@ -4355,11 +4347,6 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==" }, - "node_modules/csv-parse": { - "version": "4.16.3", - "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-4.16.3.tgz", - "integrity": "sha512-cO1I/zmz4w2dcKHVvpCr7JVRu8/FymG5OEpmvsZYlccYolPBLoVGKUHgNoc4ZGkFeFlWGEDmMyBM+TTqRdW/wg==" - }, "node_modules/d3": { "version": "4.13.0", "resolved": "https://registry.npmjs.org/d3/-/d3-4.13.0.tgz", @@ -6464,14 +6451,6 @@ "node": ">=10.17.0" } }, - "node_modules/humanize": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/humanize/-/humanize-0.0.9.tgz", - "integrity": "sha512-bvZZ7vXpr1RKoImjuQ45hJb5OvE2oJafHysiD/AL3nkqTZH2hFCjQ3YZfCd63FefDitbJze/ispUPP0gfDsT2Q==", - "engines": { - "node": "*" - } - }, "node_modules/husky": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/husky/-/husky-7.0.4.tgz", @@ -7643,14 +7622,6 @@ "node": ">=8.6" } }, - "node_modules/microplugin": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/microplugin/-/microplugin-0.0.3.tgz", - "integrity": "sha512-3wKXex4/iyALV0GX2juow66J9dabkEMgHeZAihdLTaRTzm0N+RubXioNPpfIQDPuBRxr3JbjNt7B0Lr/3yE9yQ==", - "engines": { - "node": "*" - } - }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -7772,11 +7743,6 @@ "node": "*" } }, - "node_modules/minimist": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", - "integrity": "sha512-iotkTvxc+TwOm5Ieim8VnSNvCDjCK9S8G3scJ50ZthspSxa7jx50jkhYduuAtAjvfDUwSgOwf8+If99AlOEhyw==" - }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -8023,15 +7989,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/optimist": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", - "integrity": "sha512-snN4O4TkigujZphWLN0E//nQmm7790RYaE53DdL7ZYwee2D8DDo9/EyYiKUfN3rneWUjhJnueija3G9I2i0h3g==", - "dependencies": { - "minimist": "~0.0.1", - "wordwrap": "~0.0.2" - } - }, "node_modules/optionator": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", @@ -9198,26 +9155,6 @@ "node": ">= 0.10" } }, - "node_modules/redeyed": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/redeyed/-/redeyed-1.0.1.tgz", - "integrity": "sha512-8eEWsNCkV2rvwKLS1Cvp5agNjMhwRe2um+y32B2+3LqOzg4C9BBPs6vzAfV16Ivb8B9HPNKIqd8OrdBws8kNlQ==", - "dependencies": { - "esprima": "~3.0.0" - } - }, - "node_modules/redeyed/node_modules/esprima": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-3.0.0.tgz", - "integrity": "sha512-xoBq/MIShSydNZOkjkoCEjqod963yHNXTLC40ypBhop6yPqflPz/vTinmCfSrGcywVLnSftRf6a0kJLdFdzemw==", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -9607,21 +9544,6 @@ "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==" }, - "node_modules/selectize": { - "version": "0.12.6", - "resolved": "https://registry.npmjs.org/selectize/-/selectize-0.12.6.tgz", - "integrity": "sha512-bWO5A7G+I8+QXyjLfQUgh31VI4WKYagUZQxAXlDyUmDDNrFxrASV0W9hxCOl0XJ/XQ1dZEu3G9HjXV4Wj0yb6w==", - "dependencies": { - "microplugin": "0.0.3", - "sifter": "^0.5.1" - }, - "engines": { - "node": "*" - }, - "peerDependencies": { - "jquery": "^1.7.0, ^2, ^3" - } - }, "node_modules/selfsigned": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.1.1.tgz", @@ -9822,21 +9744,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/sifter": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/sifter/-/sifter-0.5.4.tgz", - "integrity": "sha512-t2yxTi/MM/ESup7XH5oMu8PUcttlekt269RqxARgnvS+7D/oP6RyA1x3M/5w8dG9OgkOyQ8hNRWelQ8Rj4TAQQ==", - "dependencies": { - "async": "^2.6.0", - "cardinal": "^1.0.0", - "csv-parse": "^4.6.5", - "humanize": "^0.0.9", - "optimist": "^0.6.1" - }, - "bin": { - "sifter": "bin/sifter.js" - } - }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -11632,14 +11539,6 @@ "node": ">=0.10.0" } }, - "node_modules/wordwrap": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", - "integrity": "sha512-1tMA907+V4QmxV7dbRvb4/8MaRALK6q9Abid3ndMYnbyo8piisCmeONVqVSXqQA3KaP4SLt5b7ud6E2sqP8TFw==", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -13371,6 +13270,11 @@ "playwright-core": "1.28.1" } }, + "@selectize/selectize": { + "version": "git+ssh://git@github.com/selectize/selectize.js.git#e3f2e0b4aa251375bc21b5fcd8ca7d374a921f08", + "from": "@selectize/selectize@selectize/selectize.js#master", + "requires": {} + }, "@sinclair/typebox": { "version": "0.24.51", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", @@ -14098,11 +14002,6 @@ "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", "dev": true }, - "ansicolors": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/ansicolors/-/ansicolors-0.2.1.tgz", - "integrity": "sha512-tOIuy1/SK/dr94ZA0ckDohKXNeBNqZ4us6PjMVLs5h1w2GBB6uPtOknp2+VF4F/zcy9LI70W+Z+pE2Soajky1w==" - }, "anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -14174,14 +14073,6 @@ "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==", "dev": true }, - "async": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", - "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", - "requires": { - "lodash": "^4.17.14" - } - }, "asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -14500,15 +14391,6 @@ "nan": "^2.10.0" } }, - "cardinal": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/cardinal/-/cardinal-1.0.0.tgz", - "integrity": "sha512-INsuF4GyiFLk8C91FPokbKTc/rwHqV4JnfatVZ6GPhguP1qmkRWX2dp5tepYboYdPpGWisLVLI+KsXoXFPRSMg==", - "requires": { - "ansicolors": "~0.2.1", - "redeyed": "~1.0.0" - } - }, "caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", @@ -15154,11 +15036,6 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz", "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw==" }, - "csv-parse": { - "version": "4.16.3", - "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-4.16.3.tgz", - "integrity": "sha512-cO1I/zmz4w2dcKHVvpCr7JVRu8/FymG5OEpmvsZYlccYolPBLoVGKUHgNoc4ZGkFeFlWGEDmMyBM+TTqRdW/wg==" - }, "d3": { "version": "4.13.0", "resolved": "https://registry.npmjs.org/d3/-/d3-4.13.0.tgz", @@ -16790,11 +16667,6 @@ "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==" }, - "humanize": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/humanize/-/humanize-0.0.9.tgz", - "integrity": "sha512-bvZZ7vXpr1RKoImjuQ45hJb5OvE2oJafHysiD/AL3nkqTZH2hFCjQ3YZfCd63FefDitbJze/ispUPP0gfDsT2Q==" - }, "husky": { "version": "7.0.4", "resolved": "https://registry.npmjs.org/husky/-/husky-7.0.4.tgz", @@ -17645,11 +17517,6 @@ "picomatch": "^2.3.1" } }, - "microplugin": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/microplugin/-/microplugin-0.0.3.tgz", - "integrity": "sha512-3wKXex4/iyALV0GX2juow66J9dabkEMgHeZAihdLTaRTzm0N+RubXioNPpfIQDPuBRxr3JbjNt7B0Lr/3yE9yQ==" - }, "mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -17731,11 +17598,6 @@ "brace-expansion": "^1.1.7" } }, - "minimist": { - "version": "0.0.10", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.10.tgz", - "integrity": "sha512-iotkTvxc+TwOm5Ieim8VnSNvCDjCK9S8G3scJ50ZthspSxa7jx50jkhYduuAtAjvfDUwSgOwf8+If99AlOEhyw==" - }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -17910,15 +17772,6 @@ "is-wsl": "^2.2.0" } }, - "optimist": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz", - "integrity": "sha512-snN4O4TkigujZphWLN0E//nQmm7790RYaE53DdL7ZYwee2D8DDo9/EyYiKUfN3rneWUjhJnueija3G9I2i0h3g==", - "requires": { - "minimist": "~0.0.1", - "wordwrap": "~0.0.2" - } - }, "optionator": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", @@ -18665,21 +18518,6 @@ "resolve": "^1.9.0" } }, - "redeyed": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/redeyed/-/redeyed-1.0.1.tgz", - "integrity": "sha512-8eEWsNCkV2rvwKLS1Cvp5agNjMhwRe2um+y32B2+3LqOzg4C9BBPs6vzAfV16Ivb8B9HPNKIqd8OrdBws8kNlQ==", - "requires": { - "esprima": "~3.0.0" - }, - "dependencies": { - "esprima": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-3.0.0.tgz", - "integrity": "sha512-xoBq/MIShSydNZOkjkoCEjqod963yHNXTLC40ypBhop6yPqflPz/vTinmCfSrGcywVLnSftRf6a0kJLdFdzemw==" - } - } - }, "regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -18944,15 +18782,6 @@ "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==" }, - "selectize": { - "version": "0.12.6", - "resolved": "https://registry.npmjs.org/selectize/-/selectize-0.12.6.tgz", - "integrity": "sha512-bWO5A7G+I8+QXyjLfQUgh31VI4WKYagUZQxAXlDyUmDDNrFxrASV0W9hxCOl0XJ/XQ1dZEu3G9HjXV4Wj0yb6w==", - "requires": { - "microplugin": "0.0.3", - "sifter": "^0.5.1" - } - }, "selfsigned": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.1.1.tgz", @@ -19123,18 +18952,6 @@ "object-inspect": "^1.9.0" } }, - "sifter": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/sifter/-/sifter-0.5.4.tgz", - "integrity": "sha512-t2yxTi/MM/ESup7XH5oMu8PUcttlekt269RqxARgnvS+7D/oP6RyA1x3M/5w8dG9OgkOyQ8hNRWelQ8Rj4TAQQ==", - "requires": { - "async": "^2.6.0", - "cardinal": "^1.0.0", - "csv-parse": "^4.6.5", - "humanize": "^0.0.9", - "optimist": "^0.6.1" - } - }, "signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -20417,11 +20234,6 @@ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==" }, - "wordwrap": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", - "integrity": "sha512-1tMA907+V4QmxV7dbRvb4/8MaRALK6q9Abid3ndMYnbyo8piisCmeONVqVSXqQA3KaP4SLt5b7ud6E2sqP8TFw==" - }, "wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 91825fd4efd..2a4c6e37373 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@benmajor/jquery-touch-events": "^2.0.3", "@craftcms/sass": "file:packages/craftcms-sass", "@craftcms/vue": "file:packages/craftcms-vue", + "@selectize/selectize": "selectize/selectize.js#master", "@types/jquery": "^3.5.7", "accounting": "^0.4.1", "axios": "^0.27.2", @@ -53,7 +54,6 @@ "punycode": "1.4.1", "react": "^16.14.0", "react-dom": "^16.14.0", - "selectize": "^0.12.6", "timepicker": "^1.13.18", "typescript": "^4.7.4", "v-tooltip": "^2.0.3", diff --git a/packages/craftcms-sass/_mixins.scss b/packages/craftcms-sass/_mixins.scss index 6552aa07092..cb58546d6b0 100644 --- a/packages/craftcms-sass/_mixins.scss +++ b/packages/craftcms-sass/_mixins.scss @@ -859,3 +859,30 @@ $radioSize: 16px; font-size: 14px; } } + +@mixin checkered-bg($size) { + // h/t https://gist.github.com/dfrankland/f6fed3e3ccc42e3de482b324126f9542 + $halfSize: $size * 0.5; + background-image: linear-gradient( + 45deg, + #{transparentize($grey300, 0.75)} 25%, + transparent 25% + ), + linear-gradient( + 135deg, + #{transparentize($grey300, 0.75)} 25%, + transparent 25% + ), + linear-gradient( + 45deg, + transparent 75%, + #{transparentize($grey300, 0.75)} 75% + ), + linear-gradient( + 135deg, + transparent 75%, + #{transparentize($grey300, 0.75)} 75% + ); + background-size: $size $size; + background-position: 0 0, $halfSize 0, $halfSize -#{$halfSize}, 0 $halfSize; +} diff --git a/packages/craftcms-vue/admintable/App.vue b/packages/craftcms-vue/admintable/App.vue index dd89f60a43b..1bae16f1876 100644 --- a/packages/craftcms-vue/admintable/App.vue +++ b/packages/craftcms-vue/admintable/App.vue @@ -80,6 +80,7 @@ :fields="fields" :per-page="perPage" :no-data-template="noDataTemplate" + :query-params="queryParams" pagination-path="pagination" @vuetable:loaded="init" @vuetable:loading="loading" @@ -364,6 +365,9 @@ onSelect: { default: function () {}, }, + onQueryParams: { + default: function () {}, + }, }, data() { @@ -502,10 +506,6 @@ }, handleSearch: debounce(function () { - if (this.$refs.vuetable) { - this.$refs.vuetable.gotoPage(1); - } - this.reload(); }, 350), @@ -539,8 +539,13 @@ }, reload() { + if (this.$refs.vuetable) { + this.$refs.vuetable.gotoPage(1); + } + this.isLoading = true; this.deselectAll(); + this.$refs.vuetable.normalizeFields(); this.$refs.vuetable.reload(); }, @@ -596,6 +601,20 @@ this.onSelect(checks); } }, + + queryParams(sortOrder, currentPage, perPage) { + let params = { + sort: sortOrder, + page: currentPage, + per_page: perPage, + }; + + if (this.onQueryParams instanceof Function) { + params = this.onQueryParams(params); + } + + return params; + }, }, computed: { diff --git a/phpstan.neon b/phpstan.neon index ca45ef8c3ee..c8a97a5b99d 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -15,6 +15,7 @@ parameters: - tests/_data/* - tests/_output/* - tests/_support/_generated/ + - tests/unit/helpers/typecast/ scanFiles: - lib/craft/behaviors/CustomFieldBehavior.php - tests/_support/_generated/AcceptanceTesterActions.php diff --git a/src/addresses/SubdivisionRepository.php b/src/addresses/SubdivisionRepository.php new file mode 100644 index 00000000000..ddb4d00de42 --- /dev/null +++ b/src/addresses/SubdivisionRepository.php @@ -0,0 +1,68 @@ + + * @since 4.5.0 + */ +class SubdivisionRepository extends BaseSubdivisionRepository +{ + /** + * @inheritdoc + */ + public function getList(array $parents, $locale = null): array + { + // get the list of subdivisions from commerceguys/addressing + $options = parent::getList($parents, Craft::$app->language); + + // if the list is empty (like in case of GB), get the extra options from our files + if (empty($options)) { + $options = $this->_getExtraOptions($parents, Craft::$app->language); + } + + // trigger the event to give devs a chance to modify further, and return the list + return Craft::$app->getAddresses()->defineAddressSubdivisions($parents, $options); + } + + /** + * Get a list of extra subdivision options + * + * @param array $parents + * @param string|null $lang + * @return array + */ + private function _getExtraOptions(array $parents, string $lang = null): array + { + $list = []; + $fileName = implode('-', $parents); + $filePath = __DIR__ . '/data/' . $fileName . '.json'; + + if (@file_exists($filePath) && $data = @file_get_contents($filePath)) { + $data = json_decode($data, true); + + if ($data['subdivisions']) { + $useLocalName = Locale::matchCandidates($lang, $data['extraLocale'] ?? null); + foreach ($data['subdivisions'] as $key => $value) { + $list[$key] = $useLocalName ? $value['local_name'] : $value['name']; + } + } + } + ksort($list); + + return $list; + } +} diff --git a/src/addresses/data/GB.json b/src/addresses/data/GB.json new file mode 100644 index 00000000000..ca99eb2a330 --- /dev/null +++ b/src/addresses/data/GB.json @@ -0,0 +1,857 @@ +{ + "extraLocale": "cy", + "subdivisions": { + "Cambridgeshire": { + "name": "Cambridgeshire", + "local_name": "Cambridgeshire" + }, + "Cumbria": { + "name": "Cumbria", + "local_name": "Cumbria" + }, + "Derbyshire": { + "name": "Derbyshire", + "local_name": "Derbyshire" + }, + "Devon": { + "name": "Devon", + "local_name": "Devon" + }, + "Dorset": { + "name": "Dorset", + "local_name": "Dorset" + }, + "East Sussex": { + "name": "East Sussex", + "local_name": "East Sussex" + }, + "Essex": { + "name": "Essex", + "local_name": "Essex" + }, + "Gloucestershire": { + "name": "Gloucestershire", + "local_name": "Gloucestershire" + }, + "Hampshire": { + "name": "Hampshire", + "local_name": "Hampshire" + }, + "Hertfordshire": { + "name": "Hertfordshire", + "local_name": "Hertfordshire" + }, + "Kent": { + "name": "Kent", + "local_name": "Kent" + }, + "Lancashire": { + "name": "Lancashire", + "local_name": "Lancashire" + }, + "Leicestershire": { + "name": "Leicestershire", + "local_name": "Leicestershire" + }, + "Lincolnshire": { + "name": "Lincolnshire", + "local_name": "Lincolnshire" + }, + "Norfolk": { + "name": "Norfolk", + "local_name": "Norfolk" + }, + "North Yorkshire": { + "name": "North Yorkshire", + "local_name": "North Yorkshire" + }, + "Nottinghamshire": { + "name": "Nottinghamshire", + "local_name": "Nottinghamshire" + }, + "Oxfordshire": { + "name": "Oxfordshire", + "local_name": "Oxfordshire" + }, + "Somerset": { + "name": "Somerset", + "local_name": "Somerset" + }, + "Staffordshire": { + "name": "Staffordshire", + "local_name": "Staffordshire" + }, + "Suffolk": { + "name": "Suffolk", + "local_name": "Suffolk" + }, + "Surrey": { + "name": "Surrey", + "local_name": "Surrey" + }, + "Warwickshire": { + "name": "Warwickshire", + "local_name": "Warwickshire" + }, + "West Sussex": { + "name": "West Sussex", + "local_name": "West Sussex" + }, + "Worcestershire": { + "name": "Worcestershire", + "local_name": "Worcestershire" + }, + "London, City of": { + "name": "London, City of", + "local_name": "London, City of" + }, + "Barking and Dagenham": { + "name": "Barking and Dagenham", + "local_name": "Barking and Dagenham" + }, + "Barnet": { + "name": "Barnet", + "local_name": "Barnet" + }, + "Bexley": { + "name": "Bexley", + "local_name": "Bexley" + }, + "Brent": { + "name": "Brent", + "local_name": "Brent" + }, + "Bromley": { + "name": "Bromley", + "local_name": "Bromley" + }, + "Camden": { + "name": "Camden", + "local_name": "Camden" + }, + "Croydon": { + "name": "Croydon", + "local_name": "Croydon" + }, + "Ealing": { + "name": "Ealing", + "local_name": "Ealing" + }, + "Enfield": { + "name": "Enfield", + "local_name": "Enfield" + }, + "Greenwich": { + "name": "Greenwich", + "local_name": "Greenwich" + }, + "Hackney": { + "name": "Hackney", + "local_name": "Hackney" + }, + "Hammersmith and Fulham": { + "name": "Hammersmith and Fulham", + "local_name": "Hammersmith and Fulham" + }, + "Haringey": { + "name": "Haringey", + "local_name": "Haringey" + }, + "Harrow": { + "name": "Harrow", + "local_name": "Harrow" + }, + "Havering": { + "name": "Havering", + "local_name": "Havering" + }, + "Hillingdon": { + "name": "Hillingdon", + "local_name": "Hillingdon" + }, + "Hounslow": { + "name": "Hounslow", + "local_name": "Hounslow" + }, + "Islington": { + "name": "Islington", + "local_name": "Islington" + }, + "Kensington and Chelsea": { + "name": "Kensington and Chelsea", + "local_name": "Kensington and Chelsea" + }, + "Kingston upon Thames": { + "name": "Kingston upon Thames", + "local_name": "Kingston upon Thames" + }, + "Lambeth": { + "name": "Lambeth", + "local_name": "Lambeth" + }, + "Lewisham": { + "name": "Lewisham", + "local_name": "Lewisham" + }, + "Merton": { + "name": "Merton", + "local_name": "Merton" + }, + "Newham": { + "name": "Newham", + "local_name": "Newham" + }, + "Redbridge": { + "name": "Redbridge", + "local_name": "Redbridge" + }, + "Richmond upon Thames": { + "name": "Richmond upon Thames", + "local_name": "Richmond upon Thames" + }, + "Southwark": { + "name": "Southwark", + "local_name": "Southwark" + }, + "Sutton": { + "name": "Sutton", + "local_name": "Sutton" + }, + "Tower Hamlets": { + "name": "Tower Hamlets", + "local_name": "Tower Hamlets" + }, + "Waltham Forest": { + "name": "Waltham Forest", + "local_name": "Waltham Forest" + }, + "Wandsworth": { + "name": "Wandsworth", + "local_name": "Wandsworth" + }, + "Westminster": { + "name": "Westminster", + "local_name": "Westminster" + }, + "Barnsley": { + "name": "Barnsley", + "local_name": "Barnsley" + }, + "Birmingham": { + "name": "Birmingham", + "local_name": "Birmingham" + }, + "Bolton": { + "name": "Bolton", + "local_name": "Bolton" + }, + "Bradford": { + "name": "Bradford", + "local_name": "Bradford" + }, + "Bury": { + "name": "Bury", + "local_name": "Bury" + }, + "Calderdale": { + "name": "Calderdale", + "local_name": "Calderdale" + }, + "Coventry": { + "name": "Coventry", + "local_name": "Coventry" + }, + "Doncaster": { + "name": "Doncaster", + "local_name": "Doncaster" + }, + "Dudley": { + "name": "Dudley", + "local_name": "Dudley" + }, + "Gateshead": { + "name": "Gateshead", + "local_name": "Gateshead" + }, + "Kirklees": { + "name": "Kirklees", + "local_name": "Kirklees" + }, + "Knowsley": { + "name": "Knowsley", + "local_name": "Knowsley" + }, + "Leeds": { + "name": "Leeds", + "local_name": "Leeds" + }, + "Liverpool": { + "name": "Liverpool", + "local_name": "Liverpool" + }, + "Manchester": { + "name": "Manchester", + "local_name": "Manchester" + }, + "Newcastle upon Tyne": { + "name": "Newcastle upon Tyne", + "local_name": "Newcastle upon Tyne" + }, + "North Tyneside": { + "name": "North Tyneside", + "local_name": "North Tyneside" + }, + "Oldham": { + "name": "Oldham", + "local_name": "Oldham" + }, + "Rochdale": { + "name": "Rochdale", + "local_name": "Rochdale" + }, + "Rotherham": { + "name": "Rotherham", + "local_name": "Rotherham" + }, + "St. Helens": { + "name": "St. Helens", + "local_name": "St. Helens" + }, + "Salford": { + "name": "Salford", + "local_name": "Salford" + }, + "Sandwell": { + "name": "Sandwell", + "local_name": "Sandwell" + }, + "Sefton": { + "name": "Sefton", + "local_name": "Sefton" + }, + "Sheffield": { + "name": "Sheffield", + "local_name": "Sheffield" + }, + "Solihull": { + "name": "Solihull", + "local_name": "Solihull" + }, + "South Tyneside": { + "name": "South Tyneside", + "local_name": "South Tyneside" + }, + "Stockport": { + "name": "Stockport", + "local_name": "Stockport" + }, + "Sunderland": { + "name": "Sunderland", + "local_name": "Sunderland" + }, + "Tameside": { + "name": "Tameside", + "local_name": "Tameside" + }, + "Trafford": { + "name": "Trafford", + "local_name": "Trafford" + }, + "Wakefield": { + "name": "Wakefield", + "local_name": "Wakefield" + }, + "Walsall": { + "name": "Walsall", + "local_name": "Walsall" + }, + "Wigan": { + "name": "Wigan", + "local_name": "Wigan" + }, + "Wirral": { + "name": "Wirral", + "local_name": "Wirral" + }, + "Wolverhampton": { + "name": "Wolverhampton", + "local_name": "Wolverhampton" + }, + "Bath and North East Somerset": { + "name": "Bath and North East Somerset", + "local_name": "Bath and North East Somerset" + }, + "Bedford": { + "name": "Bedford", + "local_name": "Bedford" + }, + "Blackburn with Darwen": { + "name": "Blackburn with Darwen", + "local_name": "Blackburn with Darwen" + }, + "Blackpool": { + "name": "Blackpool", + "local_name": "Blackpool" + }, + "Bournemouth, Christchurch and Poole": { + "name": "Bournemouth, Christchurch and Poole", + "local_name": "Bournemouth, Christchurch and Poole" + }, + "Bracknell Forest": { + "name": "Bracknell Forest", + "local_name": "Bracknell Forest" + }, + "Brighton and Hove": { + "name": "Brighton and Hove", + "local_name": "Brighton and Hove" + }, + "Bristol, City of": { + "name": "Bristol, City of", + "local_name": "Bristol, City of" + }, + "Buckinghamshire": { + "name": "Buckinghamshire", + "local_name": "Buckinghamshire" + }, + "Central Bedfordshire": { + "name": "Central Bedfordshire", + "local_name": "Central Bedfordshire" + }, + "Cheshire East": { + "name": "Cheshire East", + "local_name": "Cheshire East" + }, + "Cheshire West and Chester": { + "name": "Cheshire West and Chester", + "local_name": "Cheshire West and Chester" + }, + "Cornwall": { + "name": "Cornwall", + "local_name": "Cornwall" + }, + "Darlington": { + "name": "Darlington", + "local_name": "Darlington" + }, + "Derby": { + "name": "Derby", + "local_name": "Derby" + }, + "Durham, County": { + "name": "Durham, County", + "local_name": "Durham, County" + }, + "East Riding of Yorkshire": { + "name": "East Riding of Yorkshire", + "local_name": "East Riding of Yorkshire" + }, + "Halton": { + "name": "Halton", + "local_name": "Halton" + }, + "Hartlepool": { + "name": "Hartlepool", + "local_name": "Hartlepool" + }, + "Herefordshire": { + "name": "Herefordshire", + "local_name": "Herefordshire" + }, + "Isle of Wight": { + "name": "Isle of Wight", + "local_name": "Isle of Wight" + }, + "Isles of Scilly": { + "name": "Isles of Scilly", + "local_name": "Isles of Scilly" + }, + "Kingston upon Hull": { + "name": "Kingston upon Hull", + "local_name": "Kingston upon Hull" + }, + "Leicester": { + "name": "Leicester", + "local_name": "Leicester" + }, + "Luton": { + "name": "Luton", + "local_name": "Luton" + }, + "Medway": { + "name": "Medway", + "local_name": "Medway" + }, + "Middlesbrough": { + "name": "Middlesbrough", + "local_name": "Middlesbrough" + }, + "Milton Keynes": { + "name": "Milton Keynes", + "local_name": "Milton Keynes" + }, + "North East Lincolnshire": { + "name": "North East Lincolnshire", + "local_name": "North East Lincolnshire" + }, + "North Lincolnshire": { + "name": "North Lincolnshire", + "local_name": "North Lincolnshire" + }, + "North Northamptonshire": { + "name": "North Northamptonshire", + "local_name": "North Northamptonshire" + }, + "North Somerset": { + "name": "North Somerset", + "local_name": "North Somerset" + }, + "Northumberland": { + "name": "Northumberland", + "local_name": "Northumberland" + }, + "Nottingham": { + "name": "Nottingham", + "local_name": "Nottingham" + }, + "Peterborough": { + "name": "Peterborough", + "local_name": "Peterborough" + }, + "Plymouth": { + "name": "Plymouth", + "local_name": "Plymouth" + }, + "Portsmouth": { + "name": "Portsmouth", + "local_name": "Portsmouth" + }, + "Reading": { + "name": "Reading", + "local_name": "Reading" + }, + "Redcar and Cleveland": { + "name": "Redcar and Cleveland", + "local_name": "Redcar and Cleveland" + }, + "Rutland": { + "name": "Rutland", + "local_name": "Rutland" + }, + "Shropshire": { + "name": "Shropshire", + "local_name": "Shropshire" + }, + "Slough": { + "name": "Slough", + "local_name": "Slough" + }, + "South Gloucestershire": { + "name": "South Gloucestershire", + "local_name": "South Gloucestershire" + }, + "Southampton": { + "name": "Southampton", + "local_name": "Southampton" + }, + "Southend-on-Sea": { + "name": "Southend-on-Sea", + "local_name": "Southend-on-Sea" + }, + "Stockton-on-Tees": { + "name": "Stockton-on-Tees", + "local_name": "Stockton-on-Tees" + }, + "Stoke-on-Trent": { + "name": "Stoke-on-Trent", + "local_name": "Stoke-on-Trent" + }, + "Swindon": { + "name": "Swindon", + "local_name": "Swindon" + }, + "Telford and Wrekin": { + "name": "Telford and Wrekin", + "local_name": "Telford and Wrekin" + }, + "Thurrock": { + "name": "Thurrock", + "local_name": "Thurrock" + }, + "Torbay": { + "name": "Torbay", + "local_name": "Torbay" + }, + "Warrington": { + "name": "Warrington", + "local_name": "Warrington" + }, + "West Berkshire": { + "name": "West Berkshire", + "local_name": "West Berkshire" + }, + "West Northamptonshire": { + "name": "West Northamptonshire", + "local_name": "West Northamptonshire" + }, + "Wiltshire": { + "name": "Wiltshire", + "local_name": "Wiltshire" + }, + "Windsor and Maidenhead": { + "name": "Windsor and Maidenhead", + "local_name": "Windsor and Maidenhead" + }, + "Wokingham": { + "name": "Wokingham", + "local_name": "Wokingham" + }, + "York": { + "name": "York", + "local_name": "York" + }, + "Antrim and Newtownabbey": { + "name": "Antrim and Newtownabbey", + "local_name": "Antrim and Newtownabbey" + }, + "Ards and North Down": { + "name": "Ards and North Down", + "local_name": "Ards and North Down" + }, + "Armagh City, Banbridge and Craigavon": { + "name": "Armagh City, Banbridge and Craigavon", + "local_name": "Armagh City, Banbridge and Craigavon" + }, + "Belfast City": { + "name": "Belfast City", + "local_name": "Belfast City" + }, + "Causeway Coast and Glens": { + "name": "Causeway Coast and Glens", + "local_name": "Causeway Coast and Glens" + }, + "Derry and Strabane": { + "name": "Derry and Strabane", + "local_name": "Derry and Strabane" + }, + "Fermanagh and Omagh": { + "name": "Fermanagh and Omagh", + "local_name": "Fermanagh and Omagh" + }, + "Lisburn and Castlereagh": { + "name": "Lisburn and Castlereagh", + "local_name": "Lisburn and Castlereagh" + }, + "Mid and East Antrim": { + "name": "Mid and East Antrim", + "local_name": "Mid and East Antrim" + }, + "Mid-Ulster": { + "name": "Mid-Ulster", + "local_name": "Mid-Ulster" + }, + "Newry, Mourne and Down": { + "name": "Newry, Mourne and Down", + "local_name": "Newry, Mourne and Down" + }, + "Clackmannanshire": { + "name": "Clackmannanshire", + "local_name": "Clackmannanshire" + }, + "Dumfries and Galloway": { + "name": "Dumfries and Galloway", + "local_name": "Dumfries and Galloway" + }, + "Dundee City": { + "name": "Dundee City", + "local_name": "Dundee City" + }, + "East Ayrshire": { + "name": "East Ayrshire", + "local_name": "East Ayrshire" + }, + "East Dunbartonshire": { + "name": "East Dunbartonshire", + "local_name": "East Dunbartonshire" + }, + "East Lothian": { + "name": "East Lothian", + "local_name": "East Lothian" + }, + "East Renfrewshire": { + "name": "East Renfrewshire", + "local_name": "East Renfrewshire" + }, + "Edinburgh, City of": { + "name": "Edinburgh, City of", + "local_name": "Edinburgh, City of" + }, + "Eilean Siar": { + "name": "Eilean Siar", + "local_name": "Eilean Siar" + }, + "Falkirk": { + "name": "Falkirk", + "local_name": "Falkirk" + }, + "Fife": { + "name": "Fife", + "local_name": "Fife" + }, + "Glasgow City": { + "name": "Glasgow City", + "local_name": "Glasgow City" + }, + "Highland": { + "name": "Highland", + "local_name": "Highland" + }, + "Inverclyde": { + "name": "Inverclyde", + "local_name": "Inverclyde" + }, + "Midlothian": { + "name": "Midlothian", + "local_name": "Midlothian" + }, + "Moray": { + "name": "Moray", + "local_name": "Moray" + }, + "North Ayrshire": { + "name": "North Ayrshire", + "local_name": "North Ayrshire" + }, + "North Lanarkshire": { + "name": "North Lanarkshire", + "local_name": "North Lanarkshire" + }, + "Orkney Islands": { + "name": "Orkney Islands", + "local_name": "Orkney Islands" + }, + "Perth and Kinross": { + "name": "Perth and Kinross", + "local_name": "Perth and Kinross" + }, + "Renfrewshire": { + "name": "Renfrewshire", + "local_name": "Renfrewshire" + }, + "Scottish Borders": { + "name": "Scottish Borders", + "local_name": "Scottish Borders" + }, + "Shetland Islands": { + "name": "Shetland Islands", + "local_name": "Shetland Islands" + }, + "South Ayrshire": { + "name": "South Ayrshire", + "local_name": "South Ayrshire" + }, + "South Lanarkshire": { + "name": "South Lanarkshire", + "local_name": "South Lanarkshire" + }, + "Stirling": { + "name": "Stirling", + "local_name": "Stirling" + }, + "West Dunbartonshire": { + "name": "West Dunbartonshire", + "local_name": "West Dunbartonshire" + }, + "West Lothian": { + "name": "West Lothian", + "local_name": "West Lothian" + }, + "Blaenau Gwent": { + "name": "Blaenau Gwent", + "local_name": "Blaenau Gwent" + }, + "Bridgend": { + "name": "Bridgend", + "local_name": "Pen-y-bont ar Ogwr" + }, + "Caerphilly": { + "name": "Caerphilly", + "local_name": "Caerffili" + }, + "Cardiff": { + "name": "Cardiff", + "local_name": "Caerdydd" + }, + "Carmarthenshire": { + "name": "Carmarthenshire", + "local_name": "Sir Gaerfyrddin" + }, + "Ceredigion": { + "name": "Ceredigion", + "local_name": "Sir Ceredigion" + }, + "Conwy": { + "name": "Conwy", + "local_name": "Conwy" + }, + "Denbighshire": { + "name": "Denbighshire", + "local_name": "Sir Ddinbych" + }, + "Flintshire": { + "name": "Flintshire", + "local_name": "Sir y Fflint" + }, + "Gwynedd": { + "name": "Gwynedd", + "local_name": "Gwynedd" + }, + "Isle of Anglesey": { + "name": "Isle of Anglesey", + "local_name": "Sir Ynys Môn" + }, + "Merthyr Tydfil": { + "name": "Merthyr Tydfil", + "local_name": "Merthyr Tudful" + }, + "Monmouthshire": { + "name": "Monmouthshire", + "local_name": "Sir Fynwy" + }, + "Neath Port Talbot": { + "name": "Neath Port Talbot", + "local_name": "Castell-nedd Port Talbot" + }, + "Newport": { + "name": "Newport", + "local_name": "Casnewydd" + }, + "Pembrokeshire": { + "name": "Pembrokeshire", + "local_name": "Sir Benfro" + }, + "Powys": { + "name": "Powys", + "local_name": "Powys" + }, + "Rhondda Cynon Taff": { + "name": "Rhondda Cynon Taff", + "local_name": "Rhondda CynonTaf" + }, + "Swansea": { + "name": "Swansea", + "local_name": "Abertawe" + }, + "Torfaen": { + "name": "Torfaen", + "local_name": "Tor-faen" + }, + "Vale of Glamorgan, The": { + "name": "Vale of Glamorgan, The", + "local_name": "Bro Morgannwg" + }, + "Wrexham": { + "name": "Wrexham", + "local_name": "Wrecsam" + } + } +} diff --git a/src/base/ApplicationTrait.php b/src/base/ApplicationTrait.php index 08c6ea28dbb..51d3dc56633 100644 --- a/src/base/ApplicationTrait.php +++ b/src/base/ApplicationTrait.php @@ -1523,6 +1523,19 @@ private function _preInit(): void ColumnSchemaBuilder::$typeCategoryMap[Schema::TYPE_LONGTEXT] = ColumnSchemaBuilder::CATEGORY_STRING; ColumnSchemaBuilder::$typeCategoryMap[Schema::TYPE_ENUM] = ColumnSchemaBuilder::CATEGORY_STRING; + // Register Collection::set() as an alias of put() - with support for bulk-setting values + Collection::macro('set', function(mixed $values) { + /** @var Collection $this */ + if (is_array($values)) { + foreach ($values as $key => $value) { + $this->put($key, $value); + } + } else { + $this->put(...func_get_args()); + } + return $this; + }); + // Register Collection::one() as an alias of first(), for consistency with yii\db\Query. Collection::macro('one', function() { /** @var Collection $this */ diff --git a/src/base/Element.php b/src/base/Element.php index 93d8e6d94f4..4e0d113d1fa 100644 --- a/src/base/Element.php +++ b/src/base/Element.php @@ -7,6 +7,7 @@ namespace craft\base; +use ArrayIterator; use Craft; use craft\behaviors\CustomFieldBehavior; use craft\behaviors\DraftBehavior; @@ -75,6 +76,7 @@ use DateTime; use Illuminate\Support\Collection; use Throwable; +use Traversable; use Twig\Markup; use UnitEnum; use yii\base\ErrorHandler; @@ -727,11 +729,18 @@ abstract class Element extends Component implements ElementInterface * @event ElementStructureEvent The event that is triggered before the element is moved in a structure. * * You may set [[\yii\base\ModelEvent::$isValid]] to `false` to prevent the element from getting moved. + * + * @deprecated in 4.5.0. [[\craft\services\Structures::EVENT_BEFORE_INSERT_ELEMENT]] or + * [[\craft\services\Structures::EVENT_BEFORE_MOVE_ELEMENT|EVENT_BEFORE_MOVE_ELEMENT]] + * should be used instead. */ public const EVENT_BEFORE_MOVE_IN_STRUCTURE = 'beforeMoveInStructure'; /** * @event ElementStructureEvent The event that is triggered after the element is moved in a structure. + * @deprecated in 4.5.0. [[\craft\services\Structures::EVENT_AFTER_INSERT_ELEMENT]] or + * [[\craft\services\Structures::EVENT_AFTER_MOVE_ELEMENT|EVENT_AFTER_MOVE_ELEMENT]] + * should be used instead. */ public const EVENT_AFTER_MOVE_IN_STRUCTURE = 'afterMoveInStructure'; @@ -912,6 +921,14 @@ public static function sourcePath(string $sourceKey, string $stepKey, ?string $c return null; } + /** + * @inheritdoc + */ + public static function modifyCustomSource(array $config): array + { + return $config; + } + /** * @inheritdoc * @since 3.5.0 @@ -2324,6 +2341,26 @@ public function extraFields(): array ]; } + /** + * @inheritdoc + */ + public function getIterator(): Traversable + { + $attributes = $this->getAttributes(); + + // Include custom fields + if (static::hasContent() && ($fieldLayout = $this->getFieldLayout()) !== null) { + foreach ($fieldLayout->getCustomFieldElements() as $layoutElement) { + $field = $layoutElement->getField(); + if (!isset($attributes[$field->handle])) { + $attributes[$field->handle] = $this->getFieldValue($field->handle); + } + } + } + + return new ArrayIterator($attributes); + } + /** * @inheritdoc */ @@ -3297,6 +3334,44 @@ protected function previewTargets(): array return []; } + /** + * @inheritdoc + */ + public function getThumbHtml(int $size): ?string + { + $thumbUrl = $this->getThumbUrl($size); + + if ($thumbUrl !== null) { + return Html::tag('div', '', [ + 'class' => array_filter([ + 'elementthumb', + $this->getHasCheckeredThumb() ? 'checkered' : null, + $this->getHasRoundedThumb() ? 'rounded' : null, + ]), + 'data' => [ + 'sizes' => sprintf('%spx', $size), + 'srcset' => sprintf('%s %sw, %s %sw', $thumbUrl, $size, $this->getThumbUrl($size * 2), $size * 2), + 'alt' => $this->getThumbAlt(), + ], + ]); + } + + $thumbSvg = $this->thumbSvg(); + if ($thumbSvg !== null) { + $thumbSvg = Html::svg($thumbSvg, false, true); + $alt = $this->getThumbAlt(); + if ($alt !== null) { + $thumbSvg = Html::prependToTag($thumbSvg, Html::tag('title', Html::encode($alt))); + } + $thumbSvg = Html::modifyTagAttributes($thumbSvg, ['role' => 'img']); + return Html::tag('div', $thumbSvg, [ + 'class' => 'elementthumb', + ]); + } + + return null; + } + /** * @inheritdoc */ @@ -3305,6 +3380,18 @@ public function getThumbUrl(int $size): ?string return null; } + /** + * Returns the element’s thumbnail SVG contents, which should be used as a fallback when [[getThumbUrl()]] + * returns `null`. + * + * @return string|null + * @since 4.5.0 + */ + protected function thumbSvg(): ?string + { + return null; + } + /** * @inheritdoc */ @@ -3947,7 +4034,7 @@ public function getDirtyAttributes(): array */ public function setDirtyAttributes(array $names, bool $merge = true): void { - if ($merge) { + if ($merge && !empty($this->_dirtyAttributes)) { $this->_dirtyAttributes = array_merge($this->_dirtyAttributes, array_flip($names)); } else { $this->_dirtyAttributes = array_flip($names); @@ -3978,6 +4065,30 @@ public function getTitleTranslationKey(): string return ElementHelper::translationKey($this, Field::TRANSLATION_METHOD_SITE); } + /** + * @inheritdoc + */ + public function getIsSlugTranslatable(): bool + { + return true; + } + + /** + * @inheritdoc + */ + public function getSlugTranslationDescription(): ?string + { + return ElementHelper::translationDescription(Field::TRANSLATION_METHOD_SITE); + } + + /** + * @inheritdoc + */ + public function getSlugTranslationKey(): string + { + return ElementHelper::translationKey($this, Field::TRANSLATION_METHOD_SITE); + } + /** * @inheritdoc */ @@ -4067,6 +4178,27 @@ public function setFieldValue(string $fieldHandle, mixed $value): void unset($this->_eagerLoadedElementCounts[$fieldHandle]); } + /** + * @inheritdoc + */ + public function setFieldValueFromRequest(string $fieldHandle, mixed $value): void + { + $field = $this->fieldByHandle($fieldHandle); + + if (!$field) { + throw new InvalidFieldException($fieldHandle); + } + + // Normalize it now in case the system language changes later + // (we'll do this with the value directly rather than using setFieldValue() + normalizeFieldValue(), + // because it's slightly more efficient, and to workaround an infinite loop bug caused by Matrix + // needing to render an object template on the owner element during normalization, which would in turn + // cause the Matrix field value to be (re-)normalized based on the POST data, and on and on...) + $value = $field->normalizeValueFromRequest($value, $this); + $this->setFieldValue($field->handle, $value); + $this->_normalizedFieldValues[$field->handle] = true; + } + /** * @inheritdoc */ @@ -4179,6 +4311,20 @@ public function getDirtyFields(): array return array_keys($this->_dirtyFields); } + /** + * @inheritdoc + */ + public function setDirtyFields(array $fieldHandles, bool $merge = true): void + { + if ($merge && !empty($this->_dirtyFields)) { + $this->_dirtyFields = array_merge($this->_dirtyFields, array_flip($fieldHandles)); + } else { + $this->_dirtyFields = array_flip($fieldHandles); + } + + $this->_allDirty = false; + } + /** * Returns whether all fields and attributes should be considered dirty. * @@ -4251,14 +4397,7 @@ public function setFieldValuesFromRequest(string $paramNamespace = ''): void continue; } - // Normalize it now in case the system language changes later - // (we'll do this with the value directly rather than using setFieldValue() + normalizeFieldValue(), - // because it's slightly more efficient and to workaround an infinite loop bug caused by Matrix - // needing to render an object template on the owner element during normalization, which would in turn - // cause the Matrix field value to be (re-)normalized based on the POST data, and on and on...) - $value = $field->normalizeValue($value, $this); - $this->setFieldValue($field->handle, $value); - $this->_normalizedFieldValues[$field->handle] = true; + $this->setFieldValueFromRequest($field->handle, $value); } } while ($processedAnyFields); } @@ -4456,7 +4595,11 @@ public function getCurrentRevision(): ?ElementInterface */ public function getHtmlAttributes(string $context): array { - $htmlAttributes = $this->htmlAttributes($context); + $htmlAttributes = ArrayHelper::merge($this->htmlAttributes($context), [ + 'data' => [ + 'disallow-status' => !$this->showStatusField(), + ], + ]); // Give plugins a chance to modify them $event = new RegisterElementHtmlAttributesEvent([ @@ -4693,10 +4836,11 @@ public function getSidebarHtml(bool $static): string $metaFieldsHtml = $this->metaFieldsHtml($static); if ($metaFieldsHtml !== '') { - $components[] = Html::tag('div', $metaFieldsHtml, ['class' => 'meta']); + $components[] = Html::tag('div', $metaFieldsHtml, ['class' => 'meta']) . + Html::tag('h2', Craft::t('app', 'Metadata'), ['class' => 'visually-hidden']); } - if (!$static && static::hasStatuses()) { + if (!$static && static::hasStatuses() && $this->showStatusField()) { // Is this a multi-site element? $components[] = $this->statusFieldHtml(); } @@ -4765,7 +4909,8 @@ protected function slugFieldHtml(bool $static): string return Cp::textFieldHtml([ 'label' => Craft::t('app', 'Slug'), 'siteId' => $this->siteId, - 'translationDescription' => Craft::t('app', 'This field is translated for each site.'), + 'translatable' => $this->getIsSlugTranslatable(), + 'translationDescription' => $this->getSlugTranslationDescription(), 'id' => 'slug', 'name' => 'slug', 'autocorrect' => false, @@ -4776,6 +4921,19 @@ protected function slugFieldHtml(bool $static): string ]); } + /** + * Whether status field should be shown for this element. + * If set to `false`, status can't be updated via editing entry, action or resave command. + * `true` for all elements by default for backwards compatibility. + * + * @return bool + * @since 4.5.0 + */ + protected function showStatusField(): bool + { + return true; + } + /** * Returns the status field HTML for the sidebar. * diff --git a/src/base/ElementInterface.php b/src/base/ElementInterface.php index 66d113dfefc..57bb80d974c 100644 --- a/src/base/ElementInterface.php +++ b/src/base/ElementInterface.php @@ -270,8 +270,8 @@ public static function statuses(): array; * - **`status`** – The status color that should be shown beside the source label. Possible values include `green`, * `orange`, `red`, `yellow`, `pink`, `purple`, `blue`, `turquoise`, `light`, `grey`, `black`, and `white`. (Optional) * - **`badgeCount`** – The badge count that should be displayed alongside the label. (Optional) - * - **`sites`** – An array of site IDs that the source should be shown for, on multi-site element indexes. (Optional; - * by default the source will be shown for all sites.) + * - **`sites`** – An array of site IDs or UUIDs that the source should be shown for, on multi-site element indexes. + * (Optional; by default the source will be shown for all sites.) * - **`criteria`** – An array of element criteria parameters that the source should use when the source is selected. * (Optional) * - **`data`** – An array of `data-X` attributes that should be set on the source’s `` tag in the source list’s, @@ -279,6 +279,8 @@ public static function statuses(): array; * the attribute. (Optional) * - **`defaultSort`** – A string identifying the sort attribute that should be selected by default, or an array where * the first value identifies the sort attribute, and the second determines which direction to sort by. (Optional) + * - **`defaultFilter`** – An element condition instance or config, which should be used by default when the source + * is first selected. * - **`hasThumbs`** – A bool that defines whether this source supports Thumbs View. (Use your element’s * [[getThumbUrl()]] method to define your elements’ thumb URL.) (Optional) * - **`structureId`** – The ID of the Structure that contains the elements in this source. If set, Structure View @@ -319,6 +321,15 @@ public static function findSource(string $sourceKey, ?string $context = null): ? */ public static function sourcePath(string $sourceKey, string $stepKey, ?string $context): ?array; + /** + * Modifies a custom source’s config, before it’s returned by [[craft\services\ElementSources::getSources()]] + * + * @param array $config + * @return array + * @since 4.5.0 + */ + public static function modifyCustomSource(array $config): array; + /** * Returns all of the field layouts associated with elements from the given source. * @@ -946,7 +957,16 @@ public function getAdditionalButtons(): string; public function getPreviewTargets(): array; /** - * Returns the URL to the element’s thumbnail, if there is one. + * Returns the HTML for the element’s thumbnail, if it has one. + * + * @param int $size The width and height the thumbnail should have. + * @return string|null + * @since 4.5.0 + */ + public function getThumbHtml(int $size): ?string; + + /** + * Returns the URL to the element’s thumbnail, if it has one. * * @param int $size The maximum width and height the thumbnail should have. * @return string|null @@ -1291,6 +1311,38 @@ public function getTitleTranslationDescription(): ?string; */ public function getTitleTranslationKey(): string; + /** + * Returns whether the Slug field should be shown as translatable in the UI. + * + * Note this method has no effect on whether slugs will get copied over to other + * sites when the element is actually getting saved. That is determined by [[getSlugTranslationKey()]]. + * + * @return bool + * @since 4.5.0 + */ + public function getIsSlugTranslatable(): bool; + + /** + * Returns the description of the Slug field’s translation support. + * + * @return string|null + * @since 4.5.0 + */ + public function getSlugTranslationDescription(): ?string; + + /** + * Returns the Slug’s translation key. + * + * When saving an element on a multi-site Craft install, if `$propagate` is `true` for [[\craft\services\Elements::saveElement()]], + * then `getSlugTranslationKey()` will be called for each site the element should be propagated to. + * If the method returns the same value as it did for the initial site, then the initial site’s slug will be copied over + * to the target site. + * + * @return string The translation key + * @since 4.5.0 + */ + public function getSlugTranslationKey(): string; + /** * Returns whether a field is empty. * @@ -1343,6 +1395,16 @@ public function getFieldValue(string $fieldHandle): mixed; */ public function setFieldValue(string $fieldHandle, mixed $value): void; + /** + * Sets the value for a given field. The value should have originated from post data. + * + * @param string $fieldHandle The field handle whose value needs to be set + * @param mixed $value The value to set on the field + * @throws InvalidFieldException if `$fieldHandle` is an invalid field handle + * @since 4.5.0 + */ + public function setFieldValueFromRequest(string $fieldHandle, mixed $value): void; + /** * Returns the field handles that have been updated on the canonical element since the last time it was * merged into this element. @@ -1397,6 +1459,16 @@ public function isFieldDirty(string $fieldHandle): bool; */ public function getDirtyFields(): array; + /** + * Sets the list of dirty field handles. + * + * @param string[] $fieldHandles + * @param bool $merge Whether these fields should be merged with existing dirty fields + * @see getDirtyFields() + * @since 4.5.0 + */ + public function setDirtyFields(array $fieldHandles, bool $merge = true): void; + /** * Marks all fields and attributes as dirty. * @@ -1663,6 +1735,9 @@ public function afterRestore(): void; * * @param int $structureId The structure ID * @return bool Whether the element should be moved within the structure + * @deprecated in 4.5.0. [[\craft\services\Structures::EVENT_BEFORE_INSERT_ELEMENT]] or + * [[\craft\services\Structures::EVENT_BEFORE_MOVE_ELEMENT|EVENT_BEFORE_MOVE_ELEMENT]] + * should be used instead. */ public function beforeMoveInStructure(int $structureId): bool; @@ -1670,6 +1745,9 @@ public function beforeMoveInStructure(int $structureId): bool; * Performs actions after an element is moved within a structure. * * @param int $structureId The structure ID + * @deprecated in 4.5.0. [[\craft\services\Structures::EVENT_AFTER_INSERT_ELEMENT]] or + * [[\craft\services\Structures::EVENT_AFTER_MOVE_ELEMENT|EVENT_AFTER_MOVE_ELEMENT]] + * should be used instead. */ public function afterMoveInStructure(int $structureId): void; diff --git a/src/base/Field.php b/src/base/Field.php index e20cb28a1a0..ec08654694d 100644 --- a/src/base/Field.php +++ b/src/base/Field.php @@ -487,6 +487,14 @@ public function normalizeValue(mixed $value, ?ElementInterface $element = null): return $value; } + /** + * @inheritdoc + */ + public function normalizeValueFromRequest(mixed $value, ?ElementInterface $element = null): mixed + { + return $this->normalizeValue($value, $element); + } + /** * @inheritdoc */ diff --git a/src/base/FieldInterface.php b/src/base/FieldInterface.php index 505b2c125da..cba00db4f03 100644 --- a/src/base/FieldInterface.php +++ b/src/base/FieldInterface.php @@ -372,6 +372,19 @@ public function getSearchKeywords(mixed $value, ElementInterface $element): stri */ public function normalizeValue(mixed $value, ?ElementInterface $element = null): mixed; + /** + * Normalizes a posted field value for use. + * + * This should call [[normalizeValue()]] by default, unless there are any special considerations that + * need to be made for posted values. + * + * @param mixed $value The serialized value + * @param ElementInterface|null $element The element the field is associated with, if there is one + * @return mixed The prepared field value + * @since 4.5.0 + */ + public function normalizeValueFromRequest(mixed $value, ?ElementInterface $element = null): mixed; + /** * Prepares the field’s value to be stored somewhere, like the content table. * diff --git a/src/base/FieldLayoutProviderInterface.php b/src/base/FieldLayoutProviderInterface.php new file mode 100644 index 00000000000..1114797efc4 --- /dev/null +++ b/src/base/FieldLayoutProviderInterface.php @@ -0,0 +1,27 @@ + + * @since 4.5.0 + */ +interface FieldLayoutProviderInterface +{ + /** + * Returns the field layout defined by this component. + * + * @return FieldLayout + */ + public function getFieldLayout(): FieldLayout; +} diff --git a/src/base/Fs.php b/src/base/Fs.php index ec851b3b0c2..5d147b2cba0 100644 --- a/src/base/Fs.php +++ b/src/base/Fs.php @@ -57,6 +57,22 @@ public function attributeLabels(): array ]; } + /** + * @inheritdoc + */ + public function getShowHasUrlSetting(): bool + { + return static::$showHasUrlSetting; + } + + /** + * @inheritdoc + */ + public function getShowUrlSetting(): bool + { + return static::$showUrlSetting; + } + /** * @inheritdoc */ diff --git a/src/base/FsInterface.php b/src/base/FsInterface.php index 89d7f522d0c..abc413575d3 100644 --- a/src/base/FsInterface.php +++ b/src/base/FsInterface.php @@ -17,4 +17,22 @@ */ interface FsInterface extends BaseFsInterface, SavableComponentInterface { + /** + * Returns whether the “Files in this filesystem have public URLs” setting should be shown. + * + * @return bool + * @since 4.5.0 + */ + public function getShowHasUrlSetting(): bool; + + /** + * Returns whether the “Base URL” setting should be shown. + * + * If this returns `false`, and the filesystem has a base URL, [[getRootUrl()]] should be implemented directly, + * rather than storing the base URL on the [[\craft\base\Fs::$url]] property. + * + * @return bool + * @since 4.5.0 + */ + public function getShowUrlSetting(): bool; } diff --git a/src/base/FsTrait.php b/src/base/FsTrait.php index 7946f4099d4..90cf476dce4 100644 --- a/src/base/FsTrait.php +++ b/src/base/FsTrait.php @@ -15,6 +15,22 @@ */ trait FsTrait { + /** + * @var bool Whether the “Files in this filesystem have public URLs” setting should be shown. + * @since 4.5.0 + */ + protected static bool $showHasUrlSetting = true; + + /** + * @var bool Whether the “Base URL” setting should be shown. + * + * If this is `false`, and the filesystem has a base URL, [[getRootUrl()]] should be implemented directly, + * rather than storing the base URL on the [[\craft\base\Fs::$url]] property. + * + * @since 4.5.0 + */ + protected static bool $showUrlSetting = true; + /** * @var string|null Name */ diff --git a/src/base/conditions/BaseMultiSelectConditionRule.php b/src/base/conditions/BaseMultiSelectConditionRule.php index f2077df2f41..af666769cf9 100644 --- a/src/base/conditions/BaseMultiSelectConditionRule.php +++ b/src/base/conditions/BaseMultiSelectConditionRule.php @@ -2,7 +2,6 @@ namespace craft\base\conditions; -use Craft; use craft\helpers\ArrayHelper; use craft\helpers\Cp; use craft\helpers\Db; @@ -86,32 +85,16 @@ abstract protected function options(): array; protected function inputHtml(): string { $multiSelectId = 'multiselect'; - $namespacedId = Craft::$app->getView()->namespaceInputId($multiSelectId); - - $js = << { - htmx.trigger(htmx.find('#$namespacedId'), 'change'); - }, -}); -JS; - Craft::$app->getView()->registerJs($js); return Html::hiddenLabel(Html::encode($this->getLabel()), $multiSelectId) . - Cp::multiSelectHtml([ + Cp::selectizeHtml([ 'id' => $multiSelectId, - 'class' => 'selectize flex-grow', + 'class' => 'flex-grow', 'name' => 'values', 'values' => $this->_values, 'options' => $this->options(), - 'inputAttributes' => [ - 'style' => [ - 'display' => 'none', // Hide it before selectize does its thing - ], - ], + 'multi' => true, ]); } diff --git a/src/behaviors/EventBehavior.php b/src/behaviors/EventBehavior.php new file mode 100644 index 00000000000..84b999193e6 --- /dev/null +++ b/src/behaviors/EventBehavior.php @@ -0,0 +1,60 @@ + + * @since 4.5.0 + */ +class EventBehavior extends Behavior +{ + private array $handledEvents; + + /** + * @param array $events Event name/handler pairs + * @param bool $once Whether the events should only be handled once for the owner object + * @param array $config + */ + public function __construct( + private array $events, + private bool $once = false, + array $config = [], + ) { + if ($this->once) { + $this->handledEvents = []; + } + + parent::__construct($config); + } + + public function events(): array + { + return array_map( + fn(callable $handler) => fn(Event $event) => $this->handleEvent($event, $handler), + $this->events, + ); + } + + private function handleEvent(Event $event, callable $handler): void + { + if ($this->once) { + if (isset($this->handledEvents[$event->name])) { + return; + } + $this->handledEvents[$event->name] = true; + } + + // Send the owner along with the event + $handler($event, $this->owner); + } +} diff --git a/src/behaviors/FieldLayoutBehavior.php b/src/behaviors/FieldLayoutBehavior.php index fe149de6b23..19616074235 100644 --- a/src/behaviors/FieldLayoutBehavior.php +++ b/src/behaviors/FieldLayoutBehavior.php @@ -10,6 +10,7 @@ use Craft; use craft\base\ElementInterface; use craft\base\FieldInterface; +use craft\base\FieldLayoutProviderInterface; use craft\models\FieldLayout; use yii\base\Behavior; use yii\base\InvalidConfigException; @@ -115,13 +116,22 @@ public function getFieldLayout(): FieldLayout try { $id = $this->getFieldLayoutId(); } catch (InvalidConfigException) { - return $this->_fieldLayout = new FieldLayout([ + $id = null; + } + + if ($id) { + $fieldLayout = Craft::$app->getFields()->getLayoutById($id); + if (!$fieldLayout) { + throw new InvalidConfigException('Invalid field layout ID: ' . $id); + } + } else { + $fieldLayout = new FieldLayout([ 'type' => $this->elementType, ]); } - if (($fieldLayout = Craft::$app->getFields()->getLayoutById($id)) === null) { - throw new InvalidConfigException('Invalid field layout ID: ' . $id); + if ($this->owner instanceof FieldLayoutProviderInterface) { + $fieldLayout->provider = $this->owner; } return $this->_fieldLayout = $fieldLayout; diff --git a/src/composer/Factory.php b/src/composer/Factory.php deleted file mode 100644 index c99761e37d8..00000000000 --- a/src/composer/Factory.php +++ /dev/null @@ -1,40 +0,0 @@ - - * @since 3.0.0 - */ -class Factory extends \Composer\Factory -{ - /** - * Copied from \Composer\Factory::createArchiveManager(), but without adding the zip/phar archivers - * to avoid unnecessary server requirements. - * - * Full class names used when the parent implementation referenced classes relative to its own namespace. - * - * @param Config $config The configuration - * @param DownloadManager $dm Manager use to download sources - * @param Loop $loop - * @return ArchiveManager - */ - public function createArchiveManager(Config $config, DownloadManager $dm, Loop $loop): ArchiveManager - { - // $am->addArchiver(new Archiver\ZipArchiver); - // $am->addArchiver(new Archiver\PharArchiver); - return new ArchiveManager($dm, $loop); - } -} diff --git a/src/config/GeneralConfig.php b/src/config/GeneralConfig.php index cf532d322c1..77757c78c73 100644 --- a/src/config/GeneralConfig.php +++ b/src/config/GeneralConfig.php @@ -693,6 +693,25 @@ class GeneralConfig extends BaseConfig */ public string $defaultCookieDomain = ''; + /** + * @var string The two-letter country code that addresses will be set to by default. + * + * See for a list of acceptable country codes. + * + * ::: code + * ```php Static Config + * ->defaultCountryCode('GB') + * ``` + * ```shell Environment Override + * CRAFT_DEFAULT_COUNTRY_CODE=GB + * ``` + * ::: + * + * @group System + * @since 4.5.0 + */ + public string $defaultCountryCode = 'US'; + /** * @var string|null The default language the control panel should use for users who haven’t set a preferred language yet. * @@ -3748,6 +3767,27 @@ public function defaultCookieDomain(string $value): self return $this; } + /** + * The two-letter country code that addresses will be set to by default. + * + * See for a list of acceptable country codes. + * + * ```php + * ->defaultCountryCode('GB') + * ``` + * + * @group System + * @param string $value + * @return self + * @see $defaultCountryCode + * @since 4.5.0 + */ + public function defaultCountryCode(string $value): self + { + $this->defaultCountryCode = $value; + return $this; + } + /** * The default language the control panel should use for users who haven’t set a preferred language yet. * diff --git a/src/config/app.php b/src/config/app.php index 4babd14aa39..21e7ee6cad1 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -3,8 +3,8 @@ return [ 'id' => 'CraftCMS', 'name' => 'Craft CMS', - 'version' => '4.4.17', - 'schemaVersion' => '4.4.0.4', + 'version' => '4.5.0', + 'schemaVersion' => '4.5.0.3', 'minVersionRequired' => '3.7.11', 'basePath' => dirname(__DIR__), // Defines the @app alias 'runtimePath' => '@storage/runtime', // Defines the @runtime alias diff --git a/src/config/composer-classes.php b/src/config/composer-classes.php deleted file mode 100644 index 319ea8e7875..00000000000 --- a/src/config/composer-classes.php +++ /dev/null @@ -1,342 +0,0 @@ -getConfig()->getGeneral()->getTestToEmailAddress(); $to = $this->prompt('Which email address should the test email be sent to?', [ - 'default' => ArrayHelper::firstKey($testToEmailAddress), + 'default' => array_key_first($testToEmailAddress), ]); } diff --git a/src/console/controllers/MigrateController.php b/src/console/controllers/MigrateController.php index 0be23de58bf..69ecbc0d2a6 100644 --- a/src/console/controllers/MigrateController.php +++ b/src/console/controllers/MigrateController.php @@ -202,7 +202,6 @@ public function beforeAction($action): bool } $this->migrationPath = $this->getMigrator()->migrationPath; - FileHelper::createDirectory($this->migrationPath); } // Make sure that the project config YAML exists in case any migrations need to check incoming YAML values @@ -211,8 +210,12 @@ public function beforeAction($action): bool $projectConfig->regenerateExternalConfig(); } - if (!$this->traitBeforeAction($action)) { - return false; + try { + if (!$this->traitBeforeAction($action)) { + return false; + } + } catch (InvalidConfigException $e) { + // migrations folder not created, but we don't mind. } return true; diff --git a/src/console/controllers/SectionsController.php b/src/console/controllers/SectionsController.php index 84bc4ed21eb..08b63ef99f0 100644 --- a/src/console/controllers/SectionsController.php +++ b/src/console/controllers/SectionsController.php @@ -227,6 +227,10 @@ public function actionCreate(): int } if ($saveEntryType) { + if ($this->fromGlobalSet) { + $entryType->showStatusField = false; + } + $this->do('Saving the entry type', function() use ($entryType, $sourceFieldLayout) { if ($sourceFieldLayout) { $fieldLayout = FieldLayout::createFromConfig($sourceFieldLayout->getConfig() ?? []); diff --git a/src/console/controllers/SetupController.php b/src/console/controllers/SetupController.php index 7ae8532f1e9..f9745274f27 100644 --- a/src/console/controllers/SetupController.php +++ b/src/console/controllers/SetupController.php @@ -7,6 +7,7 @@ namespace craft\console\controllers; +use Composer\IO\ConsoleIO; use Composer\Util\Platform; use Craft; use craft\config\DbConfig; @@ -21,8 +22,15 @@ use craft\helpers\StringHelper; use craft\migrations\CreateDbCacheTable; use craft\migrations\CreatePhpSessionTable; +use m150207_210500_i18n_init; use PDOException; use Seld\CliPrompt\CliPrompt; +use Symfony\Component\Console\Helper\HelperSet; +use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\StringInput; +use Symfony\Component\Console\Output\StreamOutput; +use Symfony\Component\Process\PhpExecutableFinder; +use Symfony\Component\Process\Process; use Throwable; use yii\base\InvalidConfigException; use yii\console\ExitCode; @@ -523,6 +531,37 @@ public function actionPhpSessionTable(): int return ExitCode::OK; } + /** + * Creates database tables for storing message translations. (EXPERIMENTAL!) + * + * @return int + * @since 4.5.0 + */ + public function actionMessageTables(): int + { + $db = Craft::$app->getDb(); + if ($db->tableExists('{{%source_message}}')) { + $this->stdout("The `source_message` table already exists.\n", Console::FG_YELLOW); + return ExitCode::OK; + } + if ($db->tableExists('{{%message}}')) { + $this->stdout("The `message` table already exists.\n", Console::FG_YELLOW); + return ExitCode::OK; + } + + require Craft::getAlias('@vendor/yiisoft/yii2/i18n/migrations/m150207_210500_i18n_init.php'); + /** @phpstan-ignore-next-line */ + $migration = new m150207_210500_i18n_init(); + /** @phpstan-ignore-next-line */ + if ($migration->up() === false) { + $this->stderr("An error occurred while creating the `source_message` and `message` tables.\n", Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + + $this->stdout("The `source_message` and `message` tables were created successfully.\n", Console::FG_GREEN); + return ExitCode::OK; + } + /** * Creates a database table for storing DB caches. * @@ -546,6 +585,61 @@ public function actionDbCacheTable(): int return ExitCode::OK; } + /** + * Prepares the Craft install to be deployed to Craft Cloud. + * + * @return int + * @since 4.5.0 + */ + public function actionCloud(): int + { + if (!$this->interactive) { + $this->stderr("The setup/cloud command must be run interactively.\n"); + return ExitCode::UNSPECIFIED_ERROR; + } + + $moduleInstalled = class_exists('craft\cloud\Module'); + $message = $this->markdownToAnsi(sprintf('%s the `craftcms/cloud` extension …', + $moduleInstalled ? 'Updating' : 'Installing', + )); + $this->stdout(" → $message\n\n"); + + $input = new StringInput(''); + $input->setInteractive(false); + $output = new StreamOutput(fopen('php://output', 'w')); + $io = new ConsoleIO($input, $output, new HelperSet([new QuestionHelper()])); + + Craft::$app->getComposer()->install([ + 'craftcms/cloud' => '^1.0.0', + ], $io); + + $message = sprintf('Extension %s', $moduleInstalled ? 'updated' : 'installed'); + $this->stdout("\n ✓ $message\n" . PHP_EOL . PHP_EOL, Console::FG_GREEN); + + $message = $this->markdownToAnsi('Running `cloud/setup` …'); + $this->stdout(" → $message\n\n"); + + try { + $script = $this->request->getScriptFile(); + } catch (InvalidConfigException $e) { + $this->stdout('Error determining the `craft` executable: ' . $e->getMessage() . PHP_EOL, Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + + $process = new Process([ + (new PhpExecutableFinder())->find() ?: 'php', + $script, + 'cloud/setup', + ]); + $process->setTty(true); + $process->setTimeout(null); + $process->mustRun(); + + $this->stdout("\n ✓ The install is now prepared for Craft Cloud\n", Console::FG_GREEN); + + return ExitCode::OK; + } + /** * Outputs a terminal command. * diff --git a/src/controllers/AssetsController.php b/src/controllers/AssetsController.php index f1a87de8a7d..f0c4368b907 100644 --- a/src/controllers/AssetsController.php +++ b/src/controllers/AssetsController.php @@ -10,6 +10,7 @@ use Craft; use craft\assetpreviews\Image as ImagePreview; use craft\base\Element; +use craft\base\LocalFsInterface; use craft\elements\Asset; use craft\elements\conditions\ElementCondition; use craft\errors\AssetException; @@ -63,6 +64,8 @@ */ class AssetsController extends Controller { + use AssetsControllerTrait; + /** * @inheritdoc */ @@ -87,7 +90,6 @@ public function actionIndex(string $defaultSource = null) if ($volume) { $assetsService = Craft::$app->getAssets(); - $rootFolder = $assetsService->getRootFolderByVolumeId($volume->id); $variables['defaultSource'] = "volume:$volume->uid"; if (!empty($defaultSourcePath)) { @@ -1255,87 +1257,6 @@ protected function asBrokenImage(?Throwable $e = null): Response ->setStatusCode($statusCode); } - /** - * Requires a volume permission for a given asset. - * - * @param string $permissionName The name of the permission to require (sans `:` suffix) - * @param Asset $asset The asset whose volume should be checked - * @throws ForbiddenHttpException - * @throws InvalidConfigException - * @throws VolumeException - * @since 3.4.8 - */ - protected function requireVolumePermissionByAsset(string $permissionName, Asset $asset): void - { - if (!$asset->getVolumeId()) { - $userTemporaryFolder = Craft::$app->getAssets()->getUserTemporaryUploadFolder(); - - // Skip permission check only if it’s the user’s temporary folder - if ($userTemporaryFolder->id == $asset->folderId) { - return; - } - } - - $volume = $asset->getVolume(); - $this->requireVolumePermission($permissionName, $volume->uid); - } - - /** - * Requires a peer permission for a given asset, unless it was uploaded by the current user. - * - * @param string $permissionName The name of the peer permission to require (sans `:` suffix) - * @param Asset $asset The asset whose volume should be checked - * @throws ForbiddenHttpException - * @since 3.4.8 - */ - protected function requirePeerVolumePermissionByAsset(string $permissionName, Asset $asset): void - { - if ($asset->getVolumeId()) { - $userId = Craft::$app->getUser()->getId(); - if ($asset->uploaderId != $userId) { - $this->requireVolumePermissionByAsset($permissionName, $asset); - } - } - } - - /** - * Requires a volume permission for a given folder. - * - * @param string $permissionName The name of the peer permission to require (sans `:` suffix) - * @param VolumeFolder $folder The folder whose volume should be checked - * @throws ForbiddenHttpException - * @throws InvalidConfigException - * @throws VolumeException - * @since 3.4.8 - */ - protected function requireVolumePermissionByFolder(string $permissionName, VolumeFolder $folder): void - { - if (!$folder->volumeId) { - $userTemporaryFolder = Craft::$app->getAssets()->getUserTemporaryUploadFolder(); - - // Skip permission check only if it’s the user’s temporary folder - if ($userTemporaryFolder->id == $folder->id) { - return; - } - } - - $volume = $folder->getVolume(); - $this->requireVolumePermission($permissionName, $volume->uid); - } - - /** - * Requires a volume permission by its UID. - * - * @param string $permissionName The name of the peer permission to require (sans `:` suffix) - * @param string $volumeUid The volume’s UID - * @throws ForbiddenHttpException - * @since 3.4.8 - */ - protected function requireVolumePermission(string $permissionName, string $volumeUid): void - { - $this->requirePermission($permissionName . ':' . $volumeUid); - } - /** * @param UploadedFile $uploadedFile * @return string @@ -1360,26 +1281,46 @@ private function _getUploadedFileTempPath(UploadedFile $uploadedFile): string /** * Generates a fallback transform. * - * @param int $assetId * @param string $transform * @return Response * @since 4.4.0 */ - public function actionGenerateFallbackTransform(int $assetId, string $transform): Response + public function actionGenerateFallbackTransform(string $transform): Response { - $transformString = Craft::$app->getSecurity()->validateData($transform); - if ($transformString === false) { + $transform = Craft::$app->getSecurity()->validateData($transform); + if ($transform === false) { throw new BadRequestHttpException('Request contained an invalid transform param.'); } + [$assetId, $transformString] = explode(',', $transform, 2); + /** @var Asset|null $asset */ $asset = Asset::find()->id($assetId)->one(); if (!$asset) { - throw new NotFoundHttpException("Invalid asset ID: $assetId"); + throw new BadRequestHttpException("Invalid asset ID: $assetId"); + } + + $this->response->setCacheHeaders(); + + // If we're returning the original asset, and it's in a local FS, just read the file out directly + $useOriginal = $transformString === 'original'; + if ($useOriginal) { + $fs = $asset->getVolume()->getFs(); + if ($fs instanceof LocalFsInterface) { + $path = sprintf('%s/%s', rtrim($fs->getRootPath(), '/'), $asset->getPath()); + return $this->response->sendFile($path, $asset->getFilename(), [ + 'inline' => true, + ]); + } + } + + if ($useOriginal) { + $ext = $asset->getExtension(); + } else { + $transform = new ImageTransform(ImageTransforms::parseTransformString($transformString)); + $ext = $transform->format ?: ImageTransforms::detectTransformFormat($asset); } - $transform = new ImageTransform(ImageTransforms::parseTransformString($transformString)); - $ext = $transform->format ?: ImageTransforms::detectTransformFormat($asset); $filename = sprintf('%s.%s', $asset->id, $ext); $path = implode(DIRECTORY_SEPARATOR, [ Craft::$app->getPath()->getImageTransformsPath(), @@ -1388,7 +1329,12 @@ public function actionGenerateFallbackTransform(int $assetId, string $transform) ]); if (!file_exists($path) || filemtime($path) < ($asset->dateModified?->getTimestamp() ?? 0)) { - $tempPath = ImageTransforms::generateTransform($asset, $transform); + if ($useOriginal) { + $tempPath = $asset->getCopyOfFile(); + } else { + $tempPath = ImageTransforms::generateTransform($asset, $transform); + } + FileHelper::createDirectory(dirname($path)); rename($tempPath, $path); } diff --git a/src/controllers/AssetsControllerTrait.php b/src/controllers/AssetsControllerTrait.php new file mode 100644 index 00000000000..df63e5f4bdd --- /dev/null +++ b/src/controllers/AssetsControllerTrait.php @@ -0,0 +1,101 @@ + + * @since 4.5.0 + */ +trait AssetsControllerTrait +{ + /** + * Requires a volume permission for a given asset. + * + * @param string $permissionName The name of the permission to require (sans `:` suffix) + * @param Asset $asset The asset whose volume should be checked + * @throws ForbiddenHttpException + * @throws InvalidConfigException + * @throws VolumeException + */ + public function requireVolumePermissionByAsset(string $permissionName, Asset $asset): void + { + if (!$asset->getVolumeId()) { + $userTemporaryFolder = Craft::$app->getAssets()->getUserTemporaryUploadFolder(); + + // Skip permission check only if it’s the user’s temporary folder + if ($userTemporaryFolder->id == $asset->folderId) { + return; + } + } + + $volume = $asset->getVolume(); + $this->requireVolumePermission($permissionName, $volume->uid); + } + + /** + * Requires a peer permission for a given asset, unless it was uploaded by the current user. + * + * @param string $permissionName The name of the peer permission to require (sans `:` suffix) + * @param Asset $asset The asset whose volume should be checked + * @throws ForbiddenHttpException + */ + public function requirePeerVolumePermissionByAsset(string $permissionName, Asset $asset): void + { + if ($asset->getVolumeId()) { + $userId = Craft::$app->getUser()->getId(); + if ($asset->uploaderId != $userId) { + $this->requireVolumePermissionByAsset($permissionName, $asset); + } + } + } + + /** + * Requires a volume permission for a given folder. + * + * @param string $permissionName The name of the peer permission to require (sans `:` suffix) + * @param VolumeFolder $folder The folder whose volume should be checked + * @throws ForbiddenHttpException + * @throws InvalidConfigException + * @throws VolumeException + */ + public function requireVolumePermissionByFolder(string $permissionName, VolumeFolder $folder): void + { + if (!$folder->volumeId) { + $userTemporaryFolder = Craft::$app->getAssets()->getUserTemporaryUploadFolder(); + + // Skip permission check only if it’s the user’s temporary folder + if ($userTemporaryFolder->id == $folder->id) { + return; + } + } + + $volume = $folder->getVolume(); + $this->requireVolumePermission($permissionName, $volume->uid); + } + + /** + * Requires a volume permission by its UID. + * + * @param string $permissionName The name of the peer permission to require (sans `:` suffix) + * @param string $volumeUid The volume’s UID + * @throws ForbiddenHttpException + */ + public function requireVolumePermission(string $permissionName, string $volumeUid): void + { + $this->requirePermission($permissionName . ':' . $volumeUid); + } +} diff --git a/src/controllers/ElementIndexSettingsController.php b/src/controllers/ElementIndexSettingsController.php index 415102e91ab..6ef24f9d275 100644 --- a/src/controllers/ElementIndexSettingsController.php +++ b/src/controllers/ElementIndexSettingsController.php @@ -146,6 +146,10 @@ public function actionGetCustomizeSourcesModalData(): Response $source += compact('conditionBuilderHtml', 'conditionBuilderJs'); } + if (isset($source['sites']) && $source['sites'] === false) { + $source['sites'] = []; + } + if (isset($source['userGroups']) && $source['userGroups'] === false) { $source['userGroups'] = []; } @@ -258,6 +262,10 @@ public function actionSaveCustomizeSourcesModalSettings(): Response 'condition' => $conditionsService->createCondition($postedSettings['condition'])->getConfig(), ]; + if (isset($postedSettings['sites']) && $postedSettings['sites'] !== '*') { + $sourceConfig['sites'] = is_array($postedSettings['sites']) ? $postedSettings['sites'] : false; + } + if (isset($postedSettings['userGroups']) && $postedSettings['userGroups'] !== '*') { $sourceConfig['userGroups'] = is_array($postedSettings['userGroups']) ? $postedSettings['userGroups'] : false; } diff --git a/src/controllers/ElementIndexesController.php b/src/controllers/ElementIndexesController.php index 6047cfc39f5..2ffe3a0c5ba 100644 --- a/src/controllers/ElementIndexesController.php +++ b/src/controllers/ElementIndexesController.php @@ -20,6 +20,7 @@ use craft\elements\db\ElementQueryInterface; use craft\elements\exporters\Raw; use craft\events\ElementActionEvent; +use craft\helpers\Component; use craft\helpers\Cp; use craft\helpers\ElementHelper; use craft\services\ElementSources; @@ -418,7 +419,24 @@ public function actionFilterHud(): Response /** @phpstan-var class-string|ElementInterface $elementType */ $elementType = $this->elementType(); $id = $this->request->getRequiredBodyParam('id'); - $condition = $elementType::createCondition(); + $conditionConfig = $this->request->getBodyParam('conditionConfig'); + $serialized = $this->request->getBodyParam('serialized'); + + $conditionsService = Craft::$app->getConditions(); + + if ($conditionConfig) { + $conditionConfig = Component::cleanseConfig($conditionConfig); + /** @var ElementConditionInterface $condition */ + $condition = $conditionsService->createCondition($conditionConfig); + } elseif ($serialized) { + parse_str($serialized, $conditionConfig); + /** @var ElementConditionInterface $condition */ + $condition = $conditionsService->createCondition($conditionConfig['condition']); + } else { + /** @var ElementConditionInterface $condition */ + $condition = $elementType::createCondition(); + } + $condition->mainTag = 'div'; $condition->id = $id; $condition->addRuleLabel = Craft::t('app', 'Add a filter'); @@ -429,7 +447,7 @@ public function actionFilterHud(): Response $condition->sourceKey = $this->sourceKey; } else { /** @var ElementConditionInterface $sourceCondition */ - $sourceCondition = Craft::$app->getConditions()->createCondition($this->source['condition']); + $sourceCondition = $conditionsService->createCondition($this->source['condition']); $condition->queryParams = []; foreach ($sourceCondition->getConditionRules() as $rule) { /** @var ElementConditionRuleInterface $rule */ @@ -595,11 +613,17 @@ protected function elementQuery(): ElementQueryInterface } // Override with the custom filters - $filterConditionStr = $this->request->getBodyParam('filters'); - if ($filterConditionStr) { - parse_str($filterConditionStr, $filterConditionConfig); + $filterConditionConfig = $this->request->getBodyParam('filterConfig'); + if (!$filterConditionConfig) { + $filterConditionStr = $this->request->getBodyParam('filters'); + if ($filterConditionStr) { + parse_str($filterConditionStr, $filterConditionConfig); + $filterConditionConfig = $filterConditionConfig['condition']; + } + } + if ($filterConditionConfig) { /** @var ElementConditionInterface $filterCondition */ - $filterCondition = $conditionsService->createCondition($filterConditionConfig['condition']); + $filterCondition = $conditionsService->createCondition(Component::cleanseConfig($filterConditionConfig)); $filterCondition->modifyQuery($query); } diff --git a/src/controllers/ElementsController.php b/src/controllers/ElementsController.php index 9e58549d4aa..4bc2f34776e 100644 --- a/src/controllers/ElementsController.php +++ b/src/controllers/ElementsController.php @@ -25,12 +25,14 @@ use craft\helpers\ElementHelper; use craft\helpers\Html; use craft\helpers\UrlHelper; +use craft\models\ElementActivity; use craft\models\FieldLayoutForm; use craft\services\Elements; use craft\web\Controller; use craft\web\CpScreenResponseBehavior; use craft\web\View; use Throwable; +use yii\helpers\Markdown; use yii\web\BadRequestHttpException; use yii\web\ForbiddenHttpException; use yii\web\Response; @@ -106,7 +108,7 @@ public function beforeAction($action): bool $this->_draftId = $this->_param('draftId'); $this->_revisionId = $this->_param('revisionId'); $this->_siteId = $this->_param('siteId'); - $this->_enabled = $this->_param('enabled'); + $this->_enabled = $this->_param('enabled') ?? true; $this->_enabledForSite = $this->_param('enabledForSite'); $this->_slug = $this->_param('slug'); $this->_fresh = (bool)$this->_param('fresh'); @@ -394,13 +396,15 @@ public function actionEdit(?ElementInterface $element, ?int $elementId = null): $isDraft )) ->notice($element->isProvisionalDraft ? fn() => $this->_draftNotice() : null) + ->errorSummary(fn() => $this->_errorSummary($element)) ->prepareScreen( fn(Response $response, string $containerId) => $this->_prepareEditor( $element, + $isUnpublishedDraft, $canSave, $response, $containerId, - fn(?FieldLayoutForm $form) => $this->_editorContent($element, $isUnpublishedDraft, $canSave, $form), + fn(?FieldLayoutForm $form) => $this->_editorContent($element, $canSave, $form), fn(?FieldLayoutForm $form) => $this->_editorSidebar($element, $mergeCanonicalChanges, $canSave), fn(?FieldLayoutForm $form) => [ 'additionalSites' => $addlEditableSites, @@ -424,6 +428,8 @@ public function actionEdit(?ElementInterface $element, ?int $elementId = null): 'siteStatuses' => $siteStatuses, 'siteToken' => (!Craft::$app->getIsLive() || !$element->getSite()->enabled) ? $security->hashData((string)$element->siteId) : null, 'visibleLayoutElements' => $form ? $form->getVisibleElements() : [], + 'updatedTimestamp' => $element->dateUpdated->getTimestamp(), + 'canonicalUpdatedTimestamp' => $canonical->dateUpdated->getTimestamp(), ] ) ); @@ -694,7 +700,11 @@ private function _additionalButtons( bool $isUnpublishedDraft, bool $isDraft, ): string { - $components = []; + $components = [ + Html::tag('div', options: [ + 'class' => ['activity-container'], + ]), + ]; // Preview (View will be added later by JS) if ($canSave && $previewTargets) { @@ -775,6 +785,7 @@ private function _additionalButtons( private function _prepareEditor( ElementInterface $element, + bool $isUnpublishedDraft, bool $canSave, Response $response, string $containerId, @@ -786,38 +797,21 @@ private function _prepareEditor( $form = $fieldLayout?->createForm($element, !$canSave, [ 'registerDeltas' => true, ]); + $contentHtml = $contentFn($form); + $sidebarHtml = $sidebarFn($form); - /** @var Response|CpScreenResponseBehavior $response */ - $response - ->tabs($form?->getTabMenu() ?? []) - ->content($contentFn($form)) - ->sidebar($sidebarFn($form)); - - if ($canSave && !$element->getIsRevision()) { - $this->view->registerJsWithVars(fn($settingsJs) => <<request->getAcceptsJson()) { + $contentHtml = Html::tag('div', $sidebarHtml, [ + 'class' => 'details', ]); - } - - // Give the element a chance to do things here too - $element->prepareEditScreen($response, $containerId); - } - - private function _editorContent( - ElementInterface $element, - bool $isUnpublishedDraft, - bool $canSave, - ?FieldLayoutForm $form, - ): string { - $components = []; - - if ($form) { - $components[] = $form->render(); + $sidebarHtml = ''; + /** @var Response|CpScreenResponseBehavior $response */ + $response->slideoutBodyClass = 'so-full-details'; } if ($canSave) { + $components = []; + if ($element->id) { $components[] = Html::hiddenInput('elementId', (string)$element->getCanonicalId()); } @@ -833,9 +827,35 @@ private function _editorContent( if ($isUnpublishedDraft && $this->_fresh) { $components[] = Html::hiddenInput('fresh', '1'); } + + $components[] = $contentHtml; + $contentHtml = implode("\n", $components); } - $html = implode("\n", $components); + /** @var Response|CpScreenResponseBehavior $response */ + $response + ->tabs($form?->getTabMenu() ?? []) + ->content($contentHtml) + ->sidebar($sidebarHtml); + + if ($canSave && !$element->getIsRevision()) { + $this->view->registerJsWithVars(fn($settingsJs) => <<prepareEditScreen($response, $containerId); + } + + private function _editorContent( + ElementInterface $element, + bool $canSave, + ?FieldLayoutForm $form, + ): string { + $html = $form?->render() ?? ''; // Trigger a defineEditorContent event if ($this->hasEventHandlers(self::EVENT_DEFINE_EDITOR_CONTENT)) { @@ -848,6 +868,86 @@ private function _editorContent( $html = $event->html; } + return trim($html); + } + + /** + * Return html for errors summary box + * + * @param ElementInterface $element + * @return string + */ + private function _errorSummary(ElementInterface $element): string + { + $html = ''; + + if ($element->hasErrors()) { + $allErrors = $element->getErrors(); + $allKeys = array_keys($allErrors); + + // only show "top-level" errors + // if you e.g. have an assets field which is set to validate related assets, + // you should only see the top-level "Fix validation errors on the related asset" error + // and not the details of what's wrong with the selected asset; + foreach ($allKeys as $key) { + $lastNestedKey = substr_replace($key, '', strrpos($key, '.')); + $lastNestedKey = substr_replace($lastNestedKey, '', strrpos($lastNestedKey, '[')); + if (!empty($lastNestedKey)) { + if (in_array($lastNestedKey, $allKeys)) { + unset($allErrors[$key]); + } + } + } + $errorsList = []; + foreach ($allErrors as $key => $errors) { + foreach ($errors as $error) { + $errorItem = Html::beginTag('li'); + + // this is true in case of e.g. cross site validation error + if (preg_match('/^\s?\ [ + 'field-error-key' => $key, + ], + ]); + } + + $errorItem .= Html::endTag('li'); + + $errorsList[] = $errorItem; + } + } + + if (!empty($errorsList)) { + $heading = Craft::t('app', 'Found {num, number} {num, plural, =1{error} other{errors}}', [ + 'num' => count($errorsList), + ]); + + $html = Html::beginTag('div', [ + 'class' => ['error-summary'], + 'tabindex' => '-1', + ]) . + Html::beginTag('div') . + Html::tag('span', '', [ + 'class' => 'notification-icon', + 'data-icon' => 'alert', + 'aria-label' => 'error', + 'role' => 'img', + ]) . + Html::tag('h2', $heading) . + Html::endTag('div') . + Html::beginTag('ul', [ + 'class' => ['errors'], + ]) . + implode('', $errorsList) . + Html::endTag('ul') . + Html::endTag('div'); + } + } + return $html; } @@ -874,7 +974,7 @@ private function _editorSidebar( $components[] = Cp::metadataHtml($element->getMetadata()); } - return implode("\n", $components); + return trim(implode("\n", $components)); } private function _draftNotice(): string @@ -939,7 +1039,12 @@ public function actionSave(): ?Response } try { - $success = $elementsService->saveElement($element); + $namespace = $this->request->getHeaders()->get('X-Craft-Namespace'); + // crossSiteValidate only if it's multisite, element supports drafts and we're not in a slideout + $success = $elementsService->saveElement( + $element, + crossSiteValidate: ($namespace === null && Craft::$app->getIsMultiSite() && $elementsService->canCreateDrafts($element, $user)), + ); } catch (UnsupportedSiteException $e) { $element->addError('siteId', $e->getMessage()); $success = false; @@ -955,6 +1060,8 @@ public function actionSave(): ?Response ])); } + $elementsService->trackActivity($element, ElementActivity::TYPE_SAVE); + // See if the user happens to have a provisional element. If so delete it. $provisional = $element::find() ->provisionalDrafts() @@ -1192,6 +1299,8 @@ public function actionSaveDraft(): ?Response ])); } + $elementsService->trackActivity($element, ElementActivity::TYPE_SAVE); + $creator = $element->getCreator(); $data = [ @@ -1264,6 +1373,8 @@ public function actionSaveDraft(): ?Response 'initialDeltaValues' => $view->getInitialDeltaValues(), 'headHtml' => $view->getHeadHtml(), 'bodyHtml' => $view->getBodyHtml(), + 'updatedTimestamp' => $element->dateUpdated->getTimestamp(), + 'canonicalUpdatedTimestamp' => $element->getCanonical()->dateUpdated->getTimestamp(), ]; } @@ -1321,7 +1432,8 @@ public function actionApplyDraft(): ?Response $element->setScenario(Element::SCENARIO_LIVE); } - if (!$elementsService->saveElement($element)) { + $namespace = $this->request->getHeaders()->get('X-Craft-Namespace'); + if (!$elementsService->saveElement($element, crossSiteValidate: ($namespace === null && Craft::$app->getIsMultiSite()))) { return $this->_asAppyDraftFailure($element); } @@ -1343,6 +1455,8 @@ public function actionApplyDraft(): ?Response } } + $elementsService->trackActivity($canonical, ElementActivity::TYPE_SAVE); + if (!$this->request->getAcceptsJson()) { // Tell all browser windows about the element save $session = Craft::$app->getSession(); @@ -1473,6 +1587,7 @@ public function actionRevert(): Response } $canonical = Craft::$app->getRevisions()->revertToRevision($element, $user->id); + Craft::$app->getElements()->trackActivity($canonical, ElementActivity::TYPE_SAVE); return $this->_asSuccess(Craft::t('app', '{type} reverted to past revision.', [ 'type' => $element::displayName(), @@ -1511,6 +1626,63 @@ public function actionGetElementHtml(): Response return $this->asJson(compact('html', 'headHtml')); } + /** + * Returns any recent activity for an element, and records that the user is viewing the element. + * + * @return Response + * @since 4.5.0 + */ + public function actionRecentActivity(): Response + { + $element = $this->_element(); + + if (!$element || $element->getIsRevision()) { + throw new BadRequestHttpException('No element was identified by the request.'); + } + + $elementsService = Craft::$app->getElements(); + $currentUser = Craft::$app->getUser()->getIdentity(); + $activity = $elementsService->getRecentActivity($element, $currentUser->id); + $elementsService->trackActivity($element, ElementActivity::TYPE_VIEW, $currentUser); + + return $this->asJson([ + 'activity' => array_map(function(ElementActivity $record) use ($element) { + $recordIsCanonical = $record->element->getIsCanonical() || $record->element->isProvisionalDraft; + $recordIsCanonicalAndPublished = $recordIsCanonical && !$record->element->getIsUnpublishedDraft(); + $isSameOrUpstream = $element->id === $record->element->id || $recordIsCanonical; + + if ($isSameOrUpstream) { + $messageParams = [ + 'user' => $record->user->getName(), + 'type' => $recordIsCanonicalAndPublished ? $element::lowerDisplayName() : Craft::t('app', 'draft'), + ]; + $message = match ($record->type) { + ElementActivity::TYPE_VIEW => Craft::t('app', '{user} is viewing this {type}.', $messageParams), + ElementActivity::TYPE_EDIT, ElementActivity::TYPE_SAVE => Craft::t('app', '{user} is editing this {type}.', $messageParams), + }; + } else { + $messageParams = [ + 'user' => $record->user->getName(), + 'type' => $element::lowerDisplayName(), + ]; + $message = match ($record->type) { + ElementActivity::TYPE_VIEW => Craft::t('app', '{user} is viewing a draft of this {type}.', $messageParams), + ElementActivity::TYPE_EDIT, ElementActivity::TYPE_SAVE => Craft::t('app', '{user} is editing a draft of this {type}.', $messageParams), + }; + } + + return [ + 'userId' => $record->user->id, + 'userName' => $record->user->getName(), + 'userThumb' => $record->user->getThumbHtml(26), + 'message' => $message, + ]; + }, $activity), + 'updatedTimestamp' => $element->dateUpdated->getTimestamp(), + 'canonicalUpdatedTimestamp' => $element->getCanonical()->dateUpdated->getTimestamp(), + ]); + } + /** * Returns the requested element, populated with any posted attributes. * @@ -1676,7 +1848,7 @@ private function _applyParamsToElement(ElementInterface $element): void } $element->setEnabledForSite($this->_enabledForSite); - } elseif (isset($this->_enabled)) { + } else { $element->enabled = $this->_enabled; } @@ -1813,6 +1985,7 @@ private function _asFailure(ElementInterface $element, string $message): ?Respon 'modelName' => 'element', 'element' => $element->toArray($element->attributes()), 'errors' => $element->getErrors(), + 'errorSummary' => $this->_errorSummary($element), ]; return $this->asFailure($message, $data, ['element' => $element]); diff --git a/src/controllers/EntriesController.php b/src/controllers/EntriesController.php index 0edbd01e335..050d561c773 100644 --- a/src/controllers/EntriesController.php +++ b/src/controllers/EntriesController.php @@ -94,21 +94,21 @@ public function actionCreate(?string $section = null): ?Response $entry = Craft::createObject(Entry::class); $entry->siteId = $site->id; $entry->sectionId = $section->id; - $entry->authorId = $this->request->getQueryParam('authorId', $user->id); + $entry->authorId = $this->request->getParam('authorId', $user->id); // Type - if (($typeHandle = $this->request->getQueryParam('type')) !== null) { + if (($typeHandle = $this->request->getParam('type')) !== null) { $type = ArrayHelper::firstWhere($entry->getAvailableEntryTypes(), 'handle', $typeHandle); if ($type === null) { throw new BadRequestHttpException("Invalid entry type handle: $typeHandle"); } $entry->typeId = $type->id; } else { - $entry->typeId = $this->request->getQueryParam('typeId') ?? $entry->getAvailableEntryTypes()[0]->id; + $entry->typeId = $this->request->getParam('typeId') ?? $entry->getAvailableEntryTypes()[0]->id; } // Status - if (($status = $this->request->getQueryParam('status')) !== null) { + if (($status = $this->request->getParam('status')) !== null) { $enabled = $status === 'enabled'; } else { // Set the default status based on the section's settings @@ -139,8 +139,8 @@ public function actionCreate(?string $section = null): ?Response } // Title & slug - $entry->title = $this->request->getQueryParam('title'); - $entry->slug = $this->request->getQueryParam('slug'); + $entry->title = $this->request->getParam('title'); + $entry->slug = $this->request->getParam('slug'); if ($entry->title && !$entry->slug) { $entry->slug = ElementHelper::generateSlug($entry->title, null, $site->language); } @@ -152,19 +152,19 @@ public function actionCreate(?string $section = null): ?Response DateTimeHelper::pause(); // Post & expiry dates - if (($postDate = $this->request->getQueryParam('postDate')) !== null) { + if (($postDate = $this->request->getParam('postDate')) !== null) { $entry->postDate = DateTimeHelper::toDateTime($postDate); } else { $entry->postDate = DateTimeHelper::now(); } - if (($expiryDate = $this->request->getQueryParam('expiryDate')) !== null) { + if (($expiryDate = $this->request->getParam('expiryDate')) !== null) { $entry->expiryDate = DateTimeHelper::toDateTime($expiryDate); } // Custom fields foreach ($entry->getFieldLayout()->getCustomFields() as $field) { - if (($value = $this->request->getQueryParam($field->handle)) !== null) { + if (($value = $this->request->getParam($field->handle)) !== null) { $entry->setFieldValue($field->handle, $value); } } diff --git a/src/controllers/FsController.php b/src/controllers/FsController.php index c92e55481aa..73976fb46e5 100644 --- a/src/controllers/FsController.php +++ b/src/controllers/FsController.php @@ -144,8 +144,6 @@ public function actionSave(): ?YiiResponse 'name' => $this->request->getBodyParam('name'), 'handle' => $this->request->getBodyParam('handle'), 'oldHandle' => $this->request->getBodyParam('oldHandle'), - 'hasUrls' => (bool)$this->request->getBodyParam('hasUrls'), - 'url' => $this->request->getBodyParam('url'), 'settings' => $this->request->getBodyParam("types.$type"), ]); diff --git a/src/controllers/GraphqlController.php b/src/controllers/GraphqlController.php index f2e93c18388..3fdf37a5a79 100644 --- a/src/controllers/GraphqlController.php +++ b/src/controllers/GraphqlController.php @@ -18,6 +18,7 @@ use craft\helpers\UrlHelper; use craft\models\GqlSchema; use craft\models\GqlToken; +use craft\models\Site; use craft\services\Gql as GqlService; use craft\web\assets\graphiql\GraphiqlAsset; use craft\web\Controller; @@ -113,6 +114,9 @@ public function actionApi(): Response $gqlService = Craft::$app->getGql(); $schema = $this->_schema($gqlService); + + $this->_enforceSiteAccess($schema); + $query = $operationName = $variables = null; // Check the body if it's a POST request @@ -286,6 +290,45 @@ private function _publicToken(GqlService $gqlService): ?GqlToken return $token->getIsValid() ? $token : null; } + /** + * Enforce site access based on used schema. + * + * @param GqlSchema $schema + * @return void + * @throws ForbiddenHttpException + * @throws \craft\errors\SiteNotFoundException + */ + private function _enforceSiteAccess(GqlSchema $schema): void + { + $sitesService = Craft::$app->getSites(); + $allowedSites = GqlHelper::getAllowedSites($schema); + $allowedSiteIds = array_flip(array_map(fn(Site $site) => $site->id, $allowedSites)); + + // check if schema has access to the current site + $currentSite = $sitesService->getCurrentSite(); + if (isset($allowedSiteIds[$currentSite->id])) { + return; + } + + // if not, check if it has access to the primary site (if different from the current site) + $primarySite = $sitesService->getPrimarySite(); + if ($currentSite->id !== $primarySite->id && isset($allowedSiteIds[$primarySite->id])) { + $sitesService->setCurrentSite($primarySite); + return; + } + + // otherwise, loop through all sites until we find one that the token has access to + foreach ($sitesService->getAllSites() as $site) { + if (isset($allowedSiteIds[$site->id])) { + $sitesService->setCurrentSite($site); + return; + } + } + + // no allowed sites could be found, so throw a ForbiddenHttpException + throw new ForbiddenHttpException(sprintf('Schema doesn’t have access to the “%s” site.', $currentSite->getName())); + } + /** * @return Response * @throws ForbiddenHttpException diff --git a/src/controllers/InstallController.php b/src/controllers/InstallController.php index 6d8043ac5b2..53291f8b7b4 100644 --- a/src/controllers/InstallController.php +++ b/src/controllers/InstallController.php @@ -94,20 +94,12 @@ public function actionIndex(): Response $defaultSiteUrl = InstallHelper::defaultSiteUrl(); $defaultSiteLanguage = InstallHelper::defaultSiteLanguage(); - $iconsPath = Craft::getAlias('@appicons'); - $dbIcon = $showDbScreen ? file_get_contents($iconsPath . DIRECTORY_SEPARATOR . 'database.svg') : null; - $userIcon = file_get_contents($iconsPath . DIRECTORY_SEPARATOR . 'user.svg'); - $worldIcon = file_get_contents($iconsPath . DIRECTORY_SEPARATOR . 'world.svg'); - return $this->renderTemplate('_special/install/index.twig', compact( 'showDbScreen', 'license', 'defaultSystemName', 'defaultSiteUrl', 'defaultSiteLanguage', - 'dbIcon', - 'userIcon', - 'worldIcon' )); } diff --git a/src/controllers/SectionsController.php b/src/controllers/SectionsController.php index b12a55c1b90..20265389a2f 100644 --- a/src/controllers/SectionsController.php +++ b/src/controllers/SectionsController.php @@ -339,6 +339,9 @@ public function actionSaveEntryType(): ?Response $entryType->titleTranslationMethod = $this->request->getBodyParam('titleTranslationMethod', $entryType->titleTranslationMethod); $entryType->titleTranslationKeyFormat = $this->request->getBodyParam('titleTranslationKeyFormat', $entryType->titleTranslationKeyFormat); $entryType->titleFormat = $this->request->getBodyParam('titleFormat', $entryType->titleFormat); + $entryType->slugTranslationMethod = $this->request->getBodyParam('slugTranslationMethod', $entryType->slugTranslationMethod); + $entryType->slugTranslationKeyFormat = $this->request->getBodyParam('slugTranslationKeyFormat', $entryType->slugTranslationKeyFormat); + $entryType->showStatusField = $this->request->getBodyParam('showStatusField', $entryType->showStatusField); // Set the field layout $fieldLayout = Craft::$app->getFields()->assembleLayoutFromPost(); diff --git a/src/controllers/UsersController.php b/src/controllers/UsersController.php index 8650511d328..16169c0a316 100644 --- a/src/controllers/UsersController.php +++ b/src/controllers/UsersController.php @@ -9,6 +9,7 @@ use Craft; use craft\base\Element; +use craft\base\ModelInterface; use craft\base\NameTrait; use craft\elements\Address; use craft\elements\Asset; @@ -46,6 +47,7 @@ use Throwable; use yii\base\Exception; use yii\base\InvalidArgumentException; +use yii\base\Model; use yii\web\BadRequestHttpException; use yii\web\ForbiddenHttpException; use yii\web\HttpException; @@ -187,7 +189,7 @@ public function actionLogin(): ?Response $userSession = Craft::$app->getUser(); if (!$userSession->getIsGuest()) { // Too easy. - return $this->_handleSuccessfulLogin(); + return $this->_handleSuccessfulLogin($userSession->getIdentity()); } if (!$this->request->getIsPost()) { @@ -225,7 +227,7 @@ public function actionLogin(): ?Response return $this->_handleLoginFailure(null, $user); } - return $this->_handleSuccessfulLogin(); + return $this->_handleSuccessfulLogin($user); } private function _findLoginUser(string $loginName): ?User @@ -278,7 +280,7 @@ public function actionImpersonate(): ?Response return null; } - return $this->_handleSuccessfulLogin(); + return $this->_handleSuccessfulLogin($user); } /** @@ -337,19 +339,27 @@ public function actionImpersonateWithToken(int $userId, int $prevUserId): ?Respo $this->requireToken(); $userSession = Craft::$app->getUser(); + $user = Craft::$app->getUsers()->getUserById($userId); + $success = false; + + if ($user) { + // Save the original user ID to the session now so User::findIdentity() + // knows not to worry if the user isn't active yet + Session::set(User::IMPERSONATE_KEY, $prevUserId); + $success = $userSession->login($user); + if (!$success) { + Session::remove(User::IMPERSONATE_KEY); + } + } - // Save the original user ID to the session now so User::findIdentity() - // knows not to worry if the user isn't active yet - Session::set(User::IMPERSONATE_KEY, $prevUserId); - - if (!$userSession->loginByUserId($userId)) { - Session::remove(User::IMPERSONATE_KEY); + if (!$success) { $this->setFailFlash(Craft::t('app', 'There was a problem impersonating this user.')); - Craft::error($userSession->getIdentity()->username . ' tried to impersonate userId: ' . $userId . ' but something went wrong.', __METHOD__); + Craft::error(sprintf('%s tried to impersonate userId: %s but something went wrong.', + $userSession->getIdentity()->username, $userId), __METHOD__); return null; } - return $this->_handleSuccessfulLogin(); + return $this->_handleSuccessfulLogin($user); } /** @@ -1185,6 +1195,7 @@ public function actionEditUser(mixed $userId = null, ?User $user = null, ?array return $this->renderTemplate('users/_edit.twig', compact( 'user', 'isNewUser', + 'isCurrentUser', 'statusLabel', 'actions', 'languageOptions', @@ -2094,10 +2105,21 @@ public function actionSaveFieldLayout(): ?Response $fieldLayout = Craft::$app->getFields()->assembleLayoutFromPost(); $fieldLayout->type = User::class; $fieldLayout->reservedFieldHandles = [ - 'groups', - 'photo', + 'active', + 'addresses', + 'admin', + 'email', 'firstName', + 'friendlyName', + 'groups', 'lastName', + 'locked', + 'name', + 'password', + 'pending', + 'photo', + 'suspended', + 'username', ]; if (!Craft::$app->getUsers()->saveLayout($fieldLayout)) { @@ -2168,9 +2190,10 @@ private function _handleLoginFailure(?string $authError, ?User $user = null): ?R * Redirects the user after a successful login attempt, or if they visited the Login page while they were already * logged in. * + * @param User $user * @return Response */ - private function _handleSuccessfulLogin(): Response + private function _handleSuccessfulLogin(User $user): Response { // Get the return URL $userSession = Craft::$app->getUser(); @@ -2189,7 +2212,7 @@ private function _handleSuccessfulLogin(): Response $return['csrfTokenValue'] = $this->request->getCsrfToken(); } - return $this->asSuccess(data: $return); + return $this->asModelSuccess($user, modelName: 'user', data: $return); } return $this->redirectToPostedUrl($userSession->getIdentity(), $returnUrl); @@ -2682,4 +2705,35 @@ private function populateNameAttributes(object $model): void } } } + + public function asModelSuccess( + ModelInterface|Model $model, + ?string $message = null, + ?string $modelName = null, + array $data = [], + ?string $redirect = null, + ): Response { + $this->clearPassword($model); + return parent::asModelSuccess($model, $message, $modelName, $data, $redirect); + } + + public function asModelFailure( + ModelInterface|Model $model, + ?string $message = null, + ?string $modelName = null, + array $data = [], + array $routeParams = [], + ): ?Response { + $this->clearPassword($model); + return parent::asModelFailure($model, $message, $modelName, $data, $routeParams); + } + + private function clearPassword(ModelInterface|Model $model): void + { + if ($model instanceof User) { + $model->password = null; + $model->newPassword = null; + $model->currentPassword = null; + } + } } diff --git a/src/db/Table.php b/src/db/Table.php index 7a28be0ef50..3202637cc6b 100644 --- a/src/db/Table.php +++ b/src/db/Table.php @@ -41,6 +41,8 @@ abstract class Table public const DEPRECATIONERRORS = '{{%deprecationerrors}}'; /** @since 3.2.0 */ public const DRAFTS = '{{%drafts}}'; + /** @since 4.5.0 */ + public const ELEMENTACTIVITY = '{{%elementactivity}}'; public const ELEMENTS = '{{%elements}}'; public const ELEMENTS_SITES = '{{%elements_sites}}'; public const RESOURCEPATHS = '{{%resourcepaths}}'; diff --git a/src/elements/Address.php b/src/elements/Address.php index 591f9e26d9f..a33e654269a 100644 --- a/src/elements/Address.php +++ b/src/elements/Address.php @@ -182,7 +182,7 @@ public static function gqlTypeNameByContext(mixed $context): string * @var string Two-letter country code * @see https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 */ - public string $countryCode = 'US'; + public string $countryCode; /** * @var string|null Administrative area @@ -245,6 +245,11 @@ public static function gqlTypeNameByContext(mixed $context): string public function init(): void { parent::init(); + + if (!isset($this->countryCode)) { + $this->countryCode = Craft::$app->getConfig()->getGeneral()->defaultCountryCode; + } + $this->normalizeNames(); } diff --git a/src/elements/Asset.php b/src/elements/Asset.php index 6784c404b46..9db79ba6967 100644 --- a/src/elements/Asset.php +++ b/src/elements/Asset.php @@ -443,10 +443,9 @@ protected static function defineFieldLayouts(string $source): array $fieldLayouts = []; if ( preg_match('/^volume:(.+)$/', $source, $matches) && - ($volume = Craft::$app->getVolumes()->getVolumeByUid($matches[1])) && - $fieldLayout = $volume->getFieldLayout() + ($volume = Craft::$app->getVolumes()->getVolumeByUid($matches[1])) ) { - $fieldLayouts[] = $fieldLayout; + $fieldLayouts[] = $volume->getFieldLayout(); } return $fieldLayouts; } @@ -808,7 +807,7 @@ private static function _createFolderQueryForIndex(AssetQuery $assetQuery, ?Volu { if ( is_array($assetQuery->orderBy) && - is_string($firstOrderByCol = ArrayHelper::firstKey($assetQuery->orderBy)) && + is_string($firstOrderByCol = array_key_first($assetQuery->orderBy)) && in_array($firstOrderByCol, ['title', 'filename']) ) { $sortDir = $assetQuery->orderBy[$firstOrderByCol]; @@ -886,8 +885,8 @@ private static function _buildFolderQuerySearchCondition(SearchQueryTerm|SearchQ private static function _assembleSourceInfoForFolder(VolumeFolder $folder, ?User $user = null): array { $volume = $folder->getVolume(); - - if ($volume->getFs() instanceof Temp) { + $fs = $volume->getFs(); + if ($fs instanceof Temp) { $volumeHandle = 'temp'; } elseif (!$folder->parentId) { $volumeHandle = $volume->handle ?? false; @@ -919,6 +918,7 @@ private static function _assembleSourceInfoForFolder(VolumeFolder $folder, ?User 'can-upload' => $folder->volumeId === null || $canUpload, 'can-move-to' => $canMoveTo, 'can-move-peer-files-to' => $canMovePeerFilesTo, + 'fs-type' => $fs::class, ], ]; @@ -1480,46 +1480,68 @@ public function getAdditionalButtons(): string $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 = new Craft.Uploader(\$fileInput, { - url: Craft.getActionUrl('assets/replace-file'), + const uploader = Craft.createUploader('{$fsClass}', \$fileInput, { dropZone: null, fileInput: \$fileInput, paramName: 'replaceFile', + replace: true, events: { fileuploadstart: () => { $('#thumb-container').addClass('loading'); }, fileuploaddone: (event, data) => { - if (data.result.error) { + 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(data.result.error); + alert(result.error); } else { - $('#new-filename').val(data.result.filename); - $('#file-size-value') - .text(data.result.formattedSize) - .attr('title', data.result.formattedSizeInBytes); - let \$dimensionsVal = $('#dimensions-value'); - if (data.result.dimensions) { - if (!\$dimensionsVal.length) { - $( - '
' + - '
$dimensionsLabel' + - '
' + - '' - ).appendTo($('#details > .meta.read-only')); - \$dimensionsVal = $('#dimensions-value'); - } - \$dimensionsVal.text(data.result.dimensions); - } else if (\$dimensionsVal.length) { - \$dimensionsVal.parent().remove(); - } - $updatePreviewThumbJs - Craft.cp.runQueue(); + } - } + }, + 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({ @@ -1562,7 +1584,7 @@ public function getImg(mixed $transform = null, ?array $sizes = null): ?Markup 'width' => $this->getWidth(), 'height' => $this->getHeight(), 'srcset' => $sizes ? $this->getSrcset($sizes) : false, - 'alt' => $this->alt ?? $this->title, + 'alt' => $this->getThumbAlt(), ]); } else { $img = null; @@ -1895,16 +1917,10 @@ private function _url(mixed $transform = null, ?bool $immediately = null): ?stri $volume = $this->getVolume(); $transform = $transform ?? $this->_transform; - if ($transform) { - $mimeType = $this->getMimeType(); - $generalConfig = Craft::$app->getConfig()->getGeneral(); - if ( - ($mimeType === 'image/gif' && !$generalConfig->transformGifs) || - ($mimeType === 'image/svg+xml' && !$generalConfig->transformSvgs) || - !Image::canManipulateAsImage(pathinfo($this->getFilename(), PATHINFO_EXTENSION)) - ) { - $transform = null; - } + if ($transform && + !Image::canManipulateAsImage(pathinfo($this->getFilename(), PATHINFO_EXTENSION)) + ) { + $transform = null; } if ($transform) { @@ -1976,7 +1992,7 @@ private function _url(mixed $transform = null, ?bool $immediately = null): ?stri public function getThumbUrl(int $size): ?string { if ($this->isFolder) { - return Craft::$app->getAssetManager()->getPublishedUrl('@app/web/assets/cp/dist', true, 'images/folder.svg'); + return null; } if ($this->getWidth() && $this->getHeight()) { @@ -1985,7 +2001,19 @@ public function getThumbUrl(int $size): ?string $width = $height = $size; } - return Craft::$app->getAssets()->getThumbUrl($this, $width, $height); + return Craft::$app->getAssets()->getThumbUrl($this, $width, $height, false); + } + + /** + * @inheritdoc + */ + protected function thumbSvg(): ?string + { + if ($this->isFolder) { + return file_get_contents(Craft::getAlias('@appicons/folder.svg')); + } + + return Assets::iconSvg($this->getExtension()); } /** @@ -1997,6 +2025,11 @@ public function getThumbAlt(): ?string return null; } + $extension = $this->getExtension(); + if (!Image::canManipulateAsImage($extension)) { + return $extension; + } + return $this->alt; } @@ -2038,7 +2071,7 @@ public function getPreviewThumbImg(int $desiredWidth, int $desiredHeight): strin return Html::tag('img', '', [ 'sizes' => "{$thumbSizes[0][0]}px", 'srcset' => implode(', ', $srcsets), - 'alt' => $this->alt ?? $this->title, + 'alt' => $this->getThumbAlt(), ]); } diff --git a/src/elements/Entry.php b/src/elements/Entry.php index 45c3d934b4b..05e23f061c4 100644 --- a/src/elements/Entry.php +++ b/src/elements/Entry.php @@ -25,6 +25,8 @@ use craft\elements\actions\Restore; use craft\elements\conditions\ElementConditionInterface; use craft\elements\conditions\entries\EntryCondition; +use craft\elements\conditions\entries\SectionConditionRule; +use craft\elements\conditions\entries\TypeConditionRule; use craft\elements\db\ElementQuery; use craft\elements\db\ElementQueryInterface; use craft\elements\db\EntryQuery; @@ -300,6 +302,47 @@ protected static function defineSources(string $context): array return $sources; } + /** + * @inheritdoc + */ + public static function modifyCustomSource(array $config): array + { + try { + /** @var EntryCondition $condition */ + $condition = Craft::$app->getConditions()->createCondition($config['condition']); + } catch (InvalidConfigException) { + return $config; + } + + $rules = $condition->getConditionRules(); + + // see if it's limited to one section + /** @var SectionConditionRule|null $sectionRule */ + $sectionRule = ArrayHelper::firstWhere($rules, fn($rule) => $rule instanceof SectionConditionRule); + $sectionOptions = $sectionRule?->getValues(); + + if ($sectionOptions && count($sectionOptions) === 1) { + $section = Craft::$app->getSections()->getSectionByUid(reset($sectionOptions)); + if ($section) { + $config['data']['handle'] = $section->handle; + } + } + + // see if it specifies any entry types + /** @var TypeConditionRule|null $entryTypeRule */ + $entryTypeRule = ArrayHelper::firstWhere($rules, fn($rule) => $rule instanceof TypeConditionRule); + $entryTypeOptions = $entryTypeRule?->getValues(); + + if ($entryTypeOptions) { + $entryType = Craft::$app->getSections()->getEntryTypeByUid(reset($entryTypeOptions)); + if ($entryType) { + $config['data']['entry-type'] = $entryType->handle; + } + } + + return $config; + } + /** * @inheritdoc * @since 3.5.0 @@ -999,6 +1042,31 @@ public function getTitleTranslationKey(): string return ElementHelper::translationKey($this, $type->titleTranslationMethod, $type->titleTranslationKeyFormat); } + /** + * @inheritdoc + */ + public function getIsSlugTranslatable(): bool + { + return ($this->getType()->slugTranslationMethod !== Field::TRANSLATION_METHOD_NONE); + } + + /** + * @inheritdoc + */ + public function getSlugTranslationDescription(): ?string + { + return ElementHelper::translationDescription($this->getType()->slugTranslationMethod); + } + + /** + * @inheritdoc + */ + public function getSlugTranslationKey(): string + { + $type = $this->getType(); + return ElementHelper::translationKey($this, $type->slugTranslationMethod, $type->slugTranslationKeyFormat); + } + /** * @inheritdoc */ @@ -1688,6 +1756,20 @@ public function metaFieldsHtml(bool $static): string return implode("\n", $fields); } + /** + * @inheritdoc + */ + public function showStatusField(): bool + { + try { + $showStatusField = $this->getType()->showStatusField; + } catch (InvalidConfigException $e) { + $showStatusField = true; + } + + return $showStatusField; + } + private function _parentOptionCriteria(Section $section): array { $parentOptionCriteria = [ diff --git a/src/elements/GlobalSet.php b/src/elements/GlobalSet.php index c962c989795..04f6dbf0933 100644 --- a/src/elements/GlobalSet.php +++ b/src/elements/GlobalSet.php @@ -9,6 +9,7 @@ use Craft; use craft\base\Element; +use craft\base\FieldLayoutProviderInterface; use craft\behaviors\FieldLayoutBehavior; use craft\elements\db\GlobalSetQuery; use craft\helpers\UrlHelper; @@ -25,7 +26,7 @@ * @author Pixel & Tonic, Inc. * @since 3.0.0 */ -class GlobalSet extends Element +class GlobalSet extends Element implements FieldLayoutProviderInterface { /** * @since 4.4.6 @@ -262,7 +263,7 @@ public function scenarios(): array /** * @inheritdoc */ - public function getFieldLayout(): ?FieldLayout + public function getFieldLayout(): FieldLayout { /** @var FieldLayoutBehavior $behavior */ $behavior = $this->getBehavior('fieldLayout'); diff --git a/src/elements/User.php b/src/elements/User.php index 8bc38e1a96c..99713b7c325 100644 --- a/src/elements/User.php +++ b/src/elements/User.php @@ -99,6 +99,26 @@ class User extends Element implements IdentityInterface public const IMPERSONATE_KEY = 'Craft.UserSessionService.prevImpersonateUserId'; + private static array $photoColors = [ + 'red-100', + 'orange-200', + 'amber-200', + 'yellow-200', + 'lime-200', + 'green-200', + 'emerald-200', + 'teal-200', + 'cyan-200', + 'sky-200', + 'blue-200', + 'indigo-200', + 'violet-200', + 'purple-200', + 'fuchsia-200', + 'pink-100', + 'rose-200', + ]; + // User statuses // ------------------------------------------------------------------------- @@ -1255,10 +1275,53 @@ public function getThumbUrl(int $size): ?string $photo = $this->getPhoto(); if ($photo) { - return Craft::$app->getAssets()->getThumbUrl($photo, $size); + return Craft::$app->getAssets()->getThumbUrl($photo, $size, iconFallback: false); } - return Craft::$app->getAssetManager()->getPublishedUrl('@app/web/assets/cp/dist', true, 'images/user.svg'); + return null; + } + + /** + * @inheritdoc + */ + protected function thumbSvg(): ?string + { + $names = array_filter([$this->firstName, $this->lastName]) ?: [$this->getName()]; + $initials = implode('', array_map(fn($name) => mb_strtoupper(mb_substr($name, 0, 1)), $names)); + + // Choose a color based on the UUID + $uid = strtolower($this->uid ?? '00ff'); + $totalColors = count(self::$photoColors); + $color1Index = base_convert(substr($uid, 0, 2), 16, 10) % $totalColors; + $color2Index = base_convert(substr($uid, 2, 2), 16, 10) % $totalColors; + if ($color2Index === $color1Index) { + $color2Index = ($color1Index + 1) % $totalColors; + } + $color1 = self::$photoColors[$color1Index % $totalColors]; + $color2 = self::$photoColors[$color2Index % $totalColors]; + + $gradientId = sprintf('gradient-%s', StringHelper::randomString(10)); + + return << + + + + + + + + + + $initials + $initials + +XML; } /** diff --git a/src/elements/actions/NewChild.php b/src/elements/actions/NewChild.php index 3d2a02c9426..2c7a5f10e76 100644 --- a/src/elements/actions/NewChild.php +++ b/src/elements/actions/NewChild.php @@ -68,7 +68,13 @@ public function getTriggerHtml(): ?string let trigger = new Craft.ElementActionTrigger({ type: $type, bulk: false, - validateSelection: \$selectedItems => !$maxLevels || $maxLevels > \$selectedItems.find('.element').data('level'), + validateSelection: (selectedItems) => { + const element = selectedItems.find('.element'); + return ( + (!$maxLevels || $maxLevels > element.data('level')) && + !Garnish.hasAttr(element, 'data-disallow-new-children') + ); + }, activate: \$selectedItems => { const url = Craft.getUrl($newChildUrl, 'parentId=' + \$selectedItems.find('.element').data('id')); Craft.redirectTo(url); diff --git a/src/elements/actions/ReplaceFile.php b/src/elements/actions/ReplaceFile.php index 67987d122d7..6b667f0c48b 100644 --- a/src/elements/actions/ReplaceFile.php +++ b/src/elements/actions/ReplaceFile.php @@ -42,14 +42,14 @@ public function getTriggerHtml(): ?string const \$element = \$selectedItems.find('.element'); const \$fileInput = $('').appendTo(Garnish.\$bod); - const options = Craft.elementIndex._currentUploaderSettings; - - options.url = Craft.getActionUrl('assets/replace-file'); - options.dropZone = null; - options.fileInput = \$fileInput; - options.paramName = 'replaceFile'; - - const tempUploader = new Craft.Uploader(\$fileInput, options); + const settings = Craft.elementIndex._currentUploaderSettings; + + settings.dropZone = null; + settings.fileInput = \$fileInput; + settings.paramName = 'replaceFile'; + settings.replace = true; + + const tempUploader = Craft.createUploader(Craft.elementIndex.uploader.fsType, \$fileInput, settings); tempUploader.setParams({ assetId: \$element.data('id') }); diff --git a/src/elements/actions/SetStatus.php b/src/elements/actions/SetStatus.php index a533efc76e9..8b7b05dc291 100644 --- a/src/elements/actions/SetStatus.php +++ b/src/elements/actions/SetStatus.php @@ -60,7 +60,13 @@ public function getTriggerHtml(): ?string (() => { new Craft.ElementActionTrigger({ type: $type, - validateSelection: \$selectedItems => Garnish.hasAttr(\$selectedItems.find('.element'), 'data-savable'), + validateSelection: (selectedItems) => { + const element = selectedItems.find('.element'); + return ( + Garnish.hasAttr(element, 'data-savable') && + !Garnish.hasAttr(element, 'data-disallow-status') + ); + }, }); })(); JS, [static::class]); diff --git a/src/elements/conditions/addresses/AdministrativeAreaConditionRule.php b/src/elements/conditions/addresses/AdministrativeAreaConditionRule.php index eab3317289a..952902a9583 100644 --- a/src/elements/conditions/addresses/AdministrativeAreaConditionRule.php +++ b/src/elements/conditions/addresses/AdministrativeAreaConditionRule.php @@ -111,32 +111,18 @@ protected function inputHtml(): string ]); $multiSelectId = 'multiselect'; - $namespacedId = Craft::$app->getView()->namespaceInputId($multiSelectId); - - $js = << { - htmx.trigger(htmx.find('#$namespacedId'), 'change'); - }, -}); -JS; - Craft::$app->getView()->registerJs($js); $adminSelectize = Html::hiddenLabel(Html::encode($this->getLabel()), $multiSelectId) . - Cp::multiSelectHtml([ + Cp::selectizeHtml([ 'id' => $multiSelectId, 'class' => 'selectize fullwidth', 'name' => 'values', 'values' => $this->getValues(), 'options' => $this->options(), - 'inputAttributes' => [ - 'style' => [ - 'display' => 'none', // Hide it before selectize does its thing - ], + 'multi' => true, + 'selectizeOptions' => [ + 'create' => true, // Must allow creation since administrative area field on addresses could be free text input ], ]); diff --git a/src/elements/db/ElementQuery.php b/src/elements/db/ElementQuery.php index f94d7bcbff9..e8cb36882e8 100644 --- a/src/elements/db/ElementQuery.php +++ b/src/elements/db/ElementQuery.php @@ -75,6 +75,15 @@ class ElementQuery extends Query implements ElementQueryInterface */ public const EVENT_DEFINE_CACHE_TAGS = 'defineCacheTags'; + /** + * @event PopulateElementEvent The event that is triggered before an element is populated. + * + * If [[PopulateElementEvent::$element]] is set by an event handler, the replacement will be returned by [[createElement()]] instead. + * + * @since 4.5.0 + */ + public const EVENT_BEFORE_POPULATE_ELEMENT = 'beforePopulateElement'; + /** * @event PopulateElementEvent The event that is triggered after an element is populated. * @@ -1950,7 +1959,22 @@ public function createElement(array $row): ElementInterface } } - $element = new $class($row); + $element = null; + + // Fire a 'beforePopulateElement' event + if ($this->hasEventHandlers(self::EVENT_BEFORE_POPULATE_ELEMENT)) { + $event = new PopulateElementEvent([ + 'row' => $row, + ]); + $this->trigger(self::EVENT_BEFORE_POPULATE_ELEMENT, $event); + + $row = $event->row ?? $row; + if (isset($event->element)) { + $element = $event->element; + } + } + + $element ??= new $class($row); $element->attachBehaviors($behaviors); // Fire an 'afterPopulateElement' event diff --git a/src/events/AssetBundleEvent.php b/src/events/AssetBundleEvent.php new file mode 100644 index 00000000000..7ca1d0873df --- /dev/null +++ b/src/events/AssetBundleEvent.php @@ -0,0 +1,35 @@ + + * @since 4.5.0 + */ +class AssetBundleEvent extends Event +{ + /** + * @var string The name of the asset bundle + */ + public string $bundleName; + + /** + * @var int|null The position of the asset bundle + */ + public ?int $position; + + /** + * @var AssetBundle The asset bundle instance + */ + public AssetBundle $bundle; +} diff --git a/src/events/DefineAddressSubdivisionsEvent.php b/src/events/DefineAddressSubdivisionsEvent.php new file mode 100644 index 00000000000..e1594b171e7 --- /dev/null +++ b/src/events/DefineAddressSubdivisionsEvent.php @@ -0,0 +1,29 @@ + + * @since 4.5.0 + */ +class DefineAddressSubdivisionsEvent extends Event +{ + /** + * @var array The field's parents; always in order of: countryCode, administrativeArea, locality + */ + public array $parents; + + /** + * @var string[] $subdivisions The subdivisions + */ + public array $subdivisions; +} diff --git a/src/events/ElementStructureEvent.php b/src/events/ElementStructureEvent.php index 4073b408ec2..4c3cb2cca24 100644 --- a/src/events/ElementStructureEvent.php +++ b/src/events/ElementStructureEvent.php @@ -12,6 +12,10 @@ * * @author Pixel & Tonic, Inc. * @since 3.0.0 + * @deprecated in 4.5.0. [[\craft\services\Structures::EVENT_BEFORE_INSERT_ELEMENT]], + * [[\craft\services\Structures::EVENT_AFTER_INSERT_ELEMENT|EVENT_AFTER_INSERT_ELEMENT]], + * [[\craft\services\Structures::EVENT_BEFORE_MOVE_ELEMENT|EVENT_BEFORE_MOVE_ELEMENT]] and + * [[\craft\services\Structures::EVENT_AFTER_MOVE_ELEMENT|EVENT_AFTER_MOVE_ELEMENT]] should be used instead. */ class ElementStructureEvent extends ModelEvent { diff --git a/src/events/MoveElementEvent.php b/src/events/MoveElementEvent.php index cf7fd9fbc14..951e526aaff 100644 --- a/src/events/MoveElementEvent.php +++ b/src/events/MoveElementEvent.php @@ -7,9 +7,14 @@ namespace craft\events; +use craft\base\ElementInterface; +use craft\services\Structures; +use yii\base\InvalidConfigException; + /** * Move element event class. * + * @property-read ?ElementInterface $targetElement * @author Pixel & Tonic, Inc. * @since 3.0.0 */ @@ -19,4 +24,57 @@ class MoveElementEvent extends ElementEvent * @var int The ID of the structure the element is being moved within. */ public int $structureId; + + /** + * @var int|null The ID of the element that [[element]] is being moved in reference to, or `null` if + * [[element]] is being appended/prepended to the root of the structure. + * @since 4.5.0 + */ + public ?int $targetElementId; + + /** + * @var string The type of structure action being performed (one of [[Structures::ACTION_PREPEND]], + * [[Structures::ACTION_APPEND|ACTION_APPEND]], [[Structures::ACTION_PLACE_BEFORE|ACTION_PLACE_BEFORE]], + * or [[Structures::ACTION_PLACE_AFTER|ACTION_PLACE_AFTER]]). + * @phpstan-var Structures::ACTION_* + * @since 4.5.0 + */ + public string $action; + + private ElementInterface $_targetElement; + + /** + * Returns the element that [[element]] is being moved in reference to, or `null` if [[element]] is being + * appended/prepended to the root of the structure. + * + * @return ElementInterface|null + * @since 4.5.0 + */ + public function getTargetElement(): ?ElementInterface + { + if (!isset($this->targetElementId)) { + return null; + } + + if (!isset($this->_targetElement)) { + $targetElement = $this->element::find() + ->id($this->targetElementId) + ->site('*') + ->preferSites([$this->element->siteId]) + ->status(null) + ->drafts(null) + ->provisionalDrafts(null) + ->revisions(null) + ->structureId($this->structureId) + ->one(); + + if (!$targetElement) { + throw new InvalidConfigException("Invalid target element ID: $this->targetElementId"); + } + + $this->_targetElement = $targetElement; + } + + return $this->_targetElement; + } } diff --git a/src/fieldlayoutelements/addresses/AddressField.php b/src/fieldlayoutelements/addresses/AddressField.php index b7024e8d7d3..6ab9564e716 100644 --- a/src/fieldlayoutelements/addresses/AddressField.php +++ b/src/fieldlayoutelements/addresses/AddressField.php @@ -103,9 +103,11 @@ public function formHtml(ElementInterface $element = null, bool $static = false) if (field.prop('nodeName') !== 'SELECT') { break; } + + let oldFieldVal = field.val(); const spinner = $('#' + Craft.namespaceId(name + '-spinner', $namespace)); field.off().on('change', () => { - if (!field.val()) { + if (!field.val() || oldFieldVal === field.val()) { return; } spinner.removeClass('hidden'); @@ -133,9 +135,9 @@ public function formHtml(ElementInterface $element = null, bool $static = false) ); \$addressFields.eq(0).replaceWith(response.data.fieldsHtml); \$addressFields.remove(); - initFields(values); Craft.appendHeadHtml(response.data.headHtml); Craft.appendBodyHtml(response.data.bodyHtml); + initFields(values); if (activeElementId) { $('#' + activeElementId).focus(); } diff --git a/src/fields/Assets.php b/src/fields/Assets.php index 7fbcb110adf..c6888d381b2 100644 --- a/src/fields/Assets.php +++ b/src/fields/Assets.php @@ -356,7 +356,7 @@ public function validateFileType(ElementInterface $element): void */ public function validateFileSize(ElementInterface $element): void { - $maxSize = AssetsHelper::getMaxUploadSize(); + $maxSize = Craft::$app->getConfig()->getGeneral()->maxUploadFileSize; $filenames = []; @@ -706,10 +706,13 @@ protected function inputTemplateVariables(array|ElementQueryInterface $value = n $variables = parent::inputTemplateVariables($value, $element); $uploadVolume = $this->_uploadVolume(); + $uploadFs = $uploadVolume?->getFs(); + $variables['fsType'] = $uploadFs::class; $variables['showFolders'] = !$this->restrictLocation || $this->allowSubfolders; $variables['canUpload'] = ( $this->allowUploads && $uploadVolume && + $uploadFs && Craft::$app->getUser()->checkPermission("saveAssets:$uploadVolume->uid") ); $variables['defaultFieldLayoutId'] = $uploadVolume->fieldLayoutId ?? null; @@ -850,9 +853,6 @@ private function _findFolder(string $sourceKey, ?string $subpath, ?ElementInterf $assetsService = Craft::$app->getAssets(); $rootFolder = $assetsService->getRootFolderByVolumeId($volume->id); - if (!$rootFolder) { - $rootFolder = Craft::$app->getVolumes()->ensureTopFolder($volume); - } // Are we looking for the root folder? $subpath = trim($subpath ?? '', '/'); diff --git a/src/fields/BaseOptionsField.php b/src/fields/BaseOptionsField.php index d6cca3e4e19..39c733d56cb 100644 --- a/src/fields/BaseOptionsField.php +++ b/src/fields/BaseOptionsField.php @@ -22,6 +22,7 @@ use craft\helpers\ArrayHelper; use craft\helpers\Cp; use craft\helpers\Db; +use craft\helpers\Html; use craft\helpers\Json; use craft\helpers\StringHelper; use GraphQL\Type\Definition\Type; @@ -46,6 +47,12 @@ abstract class BaseOptionsField extends Field implements PreviewableFieldInterfa */ public array $options; + /** + * @var string|null The type of database column the field should have in the content table + * @since 4.5.0 + */ + public ?string $columnType = null; + /** * @var bool Whether the field should support multiple selections */ @@ -88,6 +95,17 @@ public function __construct($config = []) } $config['options'] = $options; + if (!$this->multi) { + if (($config['columnType'] ?? null) === 'auto') { + $config['columnType'] = null; + } + + // Default columnType to string for existing fields + if (isset($config['id']) && !array_key_exists('columnType', $config)) { + $config['columnType'] = Schema::TYPE_STRING; + } + } + parent::__construct($config); } @@ -99,6 +117,10 @@ public function settingsAttributes(): array $attributes = parent::settingsAttributes(); $attributes[] = 'options'; + if (!$this->multi) { + $attributes[] = 'columnType'; + } + return $attributes; } @@ -109,6 +131,17 @@ protected function defineRules(): array { $rules = parent::defineRules(); $rules[] = ['options', 'validateOptions']; + + if (!$this->multi) { + $rules[] = [ + 'columnType', + 'in', + 'range' => [null, Schema::TYPE_STRING], + 'strict' => true, + 'skipOnEmpty' => false, + ]; + } + return $rules; } @@ -179,7 +212,12 @@ public function getContentColumnType(): string return Db::getTextualColumnTypeByContentLength($length + 1); } - return Schema::TYPE_STRING; + if (isset($this->columnType)) { + return $this->columnType; + } + + $maxLength = max([1, ...array_map(fn(array $option) => isset($option['value']) ? strlen($option['value']) : 0, $this->options())]); + return $maxLength === 1 ? Schema::TYPE_CHAR : sprintf('%s(%s)', Schema::TYPE_STRING, $maxLength); } /** @@ -227,7 +265,7 @@ public function getSettingsHtml(): ?string $rows[] = $option; } - return Cp::editableTableFieldHtml([ + $html = Cp::editableTableFieldHtml([ 'label' => $this->optionsSettingLabel(), 'instructions' => Craft::t('app', 'Define the available options.'), 'id' => 'options', @@ -240,6 +278,33 @@ public function getSettingsHtml(): ?string 'rows' => $rows, 'errors' => $this->getErrors('options'), ]); + + if (!$this->multi) { + $html .= Html::tag('hr') . + Html::a(Craft::t('app', 'Advanced'), options: [ + 'class' => 'fieldtoggle', + 'data' => ['target' => 'advanced'], + ]) . + Html::beginTag('div', [ + 'id' => 'advanced', + 'class' => 'hidden', + ]) . + Cp::selectFieldHtml([ + 'label' => Craft::t('app', 'Column Type'), + 'id' => 'column-type', + 'name' => 'columnType', + 'instructions' => Craft::t('app', 'The type of column this field should get in the database.'), + 'options' => [ + ['value' => 'auto', 'label' => Craft::t('app', 'Automatic')], + ['value' => Schema::TYPE_STRING, 'label' => 'varchar'], + ], + 'value' => $this->columnType ?? 'auto', + 'warning' => ($this->columnType && $this->id) ? Craft::t('app', 'Changing this may result in data loss.') : null, + ]) . + Html::endTag('div'); + } + + return $html; } /** @@ -272,12 +337,25 @@ public function normalizeValue(mixed $value, ?ElementInterface $element = null): $selectedValues[] = $val; } + $selectedBlankOption = false; $options = []; $optionValues = []; $optionLabels = []; foreach ($this->options() as $option) { if (!isset($option['optgroup'])) { - $selected = in_array($option['value'], $selectedValues, true); + // special case for blank options, when $value is null + if ($value === null && $option['value'] === '') { + if (!$selectedBlankOption) { + $selectedValues[] = ''; + $selectedBlankOption = true; + $selected = true; + } else { + $selected = false; + } + } else { + $selected = in_array($option['value'], $selectedValues, true); + } + $options[] = new OptionData($option['label'], $option['value'], $selected, true); $optionValues[] = (string)$option['value']; $optionLabels[] = (string)$option['label']; diff --git a/src/fields/BaseRelationField.php b/src/fields/BaseRelationField.php index 02fd131d3eb..37415bd936c 100644 --- a/src/fields/BaseRelationField.php +++ b/src/fields/BaseRelationField.php @@ -495,8 +495,9 @@ public function validateRelatedElements(ElementInterface $element): void if ($errorCount) { /** @var ElementInterface|string $elementType */ $elementType = static::elementType(); - $element->addError($this->handle, Craft::t('app', 'Fix validation errors on the related {type}.', [ + $element->addError($this->handle, Craft::t('app', 'Validation errors found in {attribute} {type}; please fix them.', [ 'type' => $errorCount === 1 ? $elementType::lowerDisplayName() : $elementType::pluralLowerDisplayName(), + 'attribute' => $this->getAttributeLabel($this->handle), ])); } } diff --git a/src/fields/Color.php b/src/fields/Color.php index 4a82963c935..132b8fd8936 100644 --- a/src/fields/Color.php +++ b/src/fields/Color.php @@ -51,7 +51,7 @@ public static function valueType(): string */ public function getContentColumnType(): string { - return Schema::TYPE_STRING . '(7)'; + return sprintf('%s(7)', Schema::TYPE_CHAR); } /** @inheritdoc */ diff --git a/src/fields/Dropdown.php b/src/fields/Dropdown.php index 4528fd3049d..ffdb55ab1b6 100644 --- a/src/fields/Dropdown.php +++ b/src/fields/Dropdown.php @@ -8,6 +8,7 @@ namespace craft\fields; use Craft; +use craft\base\Element; use craft\base\ElementInterface; use craft\base\SortableFieldInterface; use craft\fields\data\SingleOptionFieldData; @@ -43,6 +44,23 @@ public static function valueType(): string */ protected bool $optgroups = true; + public function getStatus(ElementInterface $element): ?array + { + // If the value is invalid and has a default value (which is going to be pulled in via inputHtml()), + // preemptively mark the field as modified + /** @var SingleOptionFieldData $value */ + $value = $element->getFieldValue($this->handle); + + if (!$value->valid && $this->defaultValue() !== null) { + return [ + Element::ATTR_STATUS_MODIFIED, + Craft::t('app', 'This field has been modified.'), + ]; + } + + return parent::getStatus($element); + } + /** * @inheritdoc */ @@ -57,11 +75,17 @@ protected function inputHtml(mixed $value, ?ElementInterface $element = null): s if (!$value->valid) { Craft::$app->getView()->setInitialDeltaValue($this->handle, $this->encodeValue($value->value)); - $value = null; + $default = $this->defaultValue(); + + if ($default !== null) { + $value = $this->normalizeValue($this->defaultValue()); + } else { + $value = null; - // Add a blank option to the beginning if one doesn't already exist - if (!$hasBlankOption) { - array_unshift($options, ['label' => '', 'value' => '']); + // Add a blank option to the beginning if one doesn't already exist + if (!$hasBlankOption) { + array_unshift($options, ['label' => '', 'value' => '']); + } } } diff --git a/src/fields/Matrix.php b/src/fields/Matrix.php index f0a098873b2..1f3cf238d22 100644 --- a/src/fields/Matrix.php +++ b/src/fields/Matrix.php @@ -22,6 +22,7 @@ use craft\elements\db\MatrixBlockQuery; use craft\elements\ElementCollection; use craft\elements\MatrixBlock; +use craft\errors\InvalidFieldException; use craft\events\BlockTypesEvent; use craft\fieldlayoutelements\CustomField; use craft\fields\conditions\EmptyFieldConditionRule; @@ -494,6 +495,19 @@ public function getSettingsHtml(): ?string * @inheritdoc */ public function normalizeValue(mixed $value, ?ElementInterface $element = null): mixed + { + return $this->_normalizeValueInternal($value, $element, false); + } + + /** + * @inheritdoc + */ + public function normalizeValueFromRequest(mixed $value, ?ElementInterface $element = null): mixed + { + return $this->_normalizeValueInternal($value, $element, true); + } + + private function _normalizeValueInternal(mixed $value, ?ElementInterface $element, bool $fromRequest): mixed { if ($value instanceof ElementQueryInterface) { return $value; @@ -507,7 +521,7 @@ public function normalizeValue(mixed $value, ?ElementInterface $element = null): if ($value === '') { $query->setCachedResult([]); } elseif ($element && is_array($value)) { - $query->setCachedResult($this->_createBlocksFromSerializedData($value, $element)); + $query->setCachedResult($this->_createBlocksFromSerializedData($value, $element, $fromRequest)); } return $query; @@ -561,7 +575,7 @@ public function serializeValue(mixed $value, ?ElementInterface $element = null): 'type' => $block->getType()->handle, 'enabled' => $block->enabled, 'collapsed' => $block->collapsed, - 'fields' => $block->getSerializedFieldValues(), + 'fields' => fn() => $block->getSerializedFieldValues(), ]; } @@ -1251,9 +1265,10 @@ private function _getBlockTypeInfoForInput(?ElementInterface $element, array $bl * * @param array $value The raw field value * @param ElementInterface $element The element the field is associated with + * @param bool $fromRequest Whether the data came from the request post data * @return MatrixBlock[] */ - private function _createBlocksFromSerializedData(array $value, ElementInterface $element): array + private function _createBlocksFromSerializedData(array $value, ElementInterface $element, bool $fromRequest): array { // Get the possible block types for this field /** @var MatrixBlockType[] $blockTypes */ @@ -1381,7 +1396,16 @@ private function _createBlocksFromSerializedData(array $value, ElementInterface } if (isset($blockData['fields'])) { - $block->setFieldValues($blockData['fields']); + foreach ($blockData['fields'] as $fieldHandle => $fieldValue) { + try { + if ($fromRequest) { + $block->setFieldValueFromRequest($fieldHandle, $fieldValue); + } else { + $block->setFieldValue($fieldHandle, $fieldValue); + } + } catch (InvalidFieldException) { + } + } } // Set the prev/next blocks diff --git a/src/fields/MultiSelect.php b/src/fields/MultiSelect.php index 2ecd5a2ea80..9673cc44364 100644 --- a/src/fields/MultiSelect.php +++ b/src/fields/MultiSelect.php @@ -57,30 +57,14 @@ protected function inputHtml(mixed $value, ?ElementInterface $element = null): s Craft::$app->getView()->setInitialDeltaValue($this->handle, null); } - $id = $this->getInputId(); - - $view = Craft::$app->getView(); - $view->registerJsWithVars(fn($id) => <<namespaceInputId($id), - ]); - - return Cp::multiSelectHtml([ - 'id' => $id, + return Cp::selectizeHtml([ + 'id' => $this->getInputId(), 'describedBy' => $this->describedBy, 'class' => 'selectize', 'name' => $this->handle, 'values' => $this->encodeValue($value), 'options' => $this->translatedOptions(true, $value, $element), - 'inputAttributes' => [ - 'style' => [ - 'display' => 'none', // Hide it before selectize does its thing - ], - ], + 'multi' => true, ]); } diff --git a/src/fields/PlainText.php b/src/fields/PlainText.php index b10cc019ee5..6e76b968cf0 100644 --- a/src/fields/PlainText.php +++ b/src/fields/PlainText.php @@ -127,7 +127,7 @@ public function init(): void public function getSettings(): array { $settings = parent::getSettings(); - if (isset($settings['placeholder'])) { + if (isset($settings['placeholder']) && !Craft::$app->getDb()->getSupportsMb4()) { $settings['placeholder'] = StringHelper::emojiToShortcodes($settings['placeholder']); } return $settings; @@ -197,9 +197,25 @@ public function getContentColumnType(): string * @inheritdoc */ public function normalizeValue(mixed $value, ?ElementInterface $element = null): mixed + { + return $this->_normalizeValueInternal($value, $element, false); + } + + /** + * @inheritdoc + */ + public function normalizeValueFromRequest(mixed $value, ?ElementInterface $element = null): mixed + { + return $this->_normalizeValueInternal($value, $element, true); + } + + private function _normalizeValueInternal(mixed $value, ?ElementInterface $element, bool $fromRequest): mixed { if ($value !== null) { - $value = StringHelper::shortcodesToEmoji($value); + if (!$fromRequest) { + $value = StringHelper::unescapeShortcodes(StringHelper::shortcodesToEmoji($value)); + } + $value = trim(preg_replace('/\R/u', "\n", $value)); } @@ -215,6 +231,7 @@ protected function inputHtml(mixed $value, ?ElementInterface $element = null): s 'name' => $this->handle, 'value' => $value, 'field' => $this, + 'placeholder' => $this->placeholder !== null ? Craft::t('site', StringHelper::unescapeShortcodes($this->placeholder)) : null, 'orientation' => $this->getOrientation($element), ]); } @@ -238,8 +255,8 @@ public function getElementValidationRules(): array */ public function serializeValue(mixed $value, ?ElementInterface $element = null): mixed { - if ($value !== null) { - $value = StringHelper::emojiToShortcodes($value); + if ($value !== null && !Craft::$app->getDb()->getSupportsMb4()) { + $value = StringHelper::emojiToShortcodes(StringHelper::escapeShortcodes($value)); } return $value; } diff --git a/src/fields/Table.php b/src/fields/Table.php index bf307750fab..206ea0b7d48 100644 --- a/src/fields/Table.php +++ b/src/fields/Table.php @@ -53,6 +53,12 @@ public static function valueType(): string return 'array|null'; } + /** + * @var bool Whether the rows should be static. + * @since 4.5.0 + */ + public bool $staticRows = false; + /** * @var string|null Custom add row button label */ @@ -156,6 +162,11 @@ public function init(): void if (!isset($this->addRowLabel)) { $this->addRowLabel = Craft::t('app', 'Add a row'); } + + if ($this->staticRows) { + $this->minRows = null; + $this->maxRows = null; + } } /** @@ -236,6 +247,7 @@ public function getSettingsHtml(): ?string 'date' => Craft::t('app', 'Date'), 'select' => Craft::t('app', 'Dropdown'), 'email' => Craft::t('app', 'Email'), + 'heading' => Craft::t('app', 'Row heading'), 'lightswitch' => Craft::t('app', 'Lightswitch'), 'multiline' => Craft::t('app', 'Multi-line text'), 'number' => Craft::t('app', 'Number'), @@ -305,6 +317,15 @@ public function getSettingsHtml(): ?string 'initJs' => false, ]); + // Replace heading columns with singleline, for the Default Values table + $columns = array_map(function(array $column) { + if ($column['type'] === 'heading') { + $column['type'] = 'singleline'; + $column['class'] = 'heading'; + } + return $column; + }, $this->columns); + $view = Craft::$app->getView(); $view->registerAssetBundle(TimepickerAsset::class); @@ -312,7 +333,7 @@ public function getSettingsHtml(): ?string $view->registerJs('new Craft.TableFieldSettings(' . Json::encode($view->namespaceInputName('columns')) . ', ' . Json::encode($view->namespaceInputName('defaults')) . ', ' . - Json::encode($this->columns) . ', ' . + Json::encode($columns) . ', ' . Json::encode($this->defaults ?? []) . ', ' . Json::encode($columnSettings) . ', ' . Json::encode($dropdownSettingsHtml) . ', ' . @@ -333,7 +354,7 @@ public function getSettingsHtml(): ?string 'allowAdd' => true, 'allowReorder' => true, 'allowDelete' => true, - 'cols' => $this->columns, + 'cols' => $columns, 'rows' => $this->defaults, 'initJs' => false, ]); @@ -400,27 +421,65 @@ public function validateTableData(ElementInterface $element): void */ public function normalizeValue(mixed $value, ?ElementInterface $element = null): mixed { + return $this->_normalizeValueInternal($value, $element, false); + } + + /** + * @inheritdoc + */ + public function normalizeValueFromRequest(mixed $value, ?ElementInterface $element = null): mixed + { + return $this->_normalizeValueInternal($value, $element, true); + } + + private function _normalizeValueInternal(mixed $value, ?ElementInterface $element, bool $fromRequest): ?array + { + if (empty($this->columns)) { + return null; + } + + $defaults = $this->defaults ?? []; + if (is_string($value) && !empty($value)) { $value = Json::decodeIfJson($value); } elseif ($value === null && $this->isFresh($element)) { - $value = array_values($this->defaults ?? []); + $value = $defaults; } - if (!is_array($value) || empty($this->columns)) { - return null; + if (!is_array($value)) { + $value = []; } // Normalize the values and make them accessible from both the col IDs and the handles - foreach ($value as &$row) { + $value = array_values($value); + + if ($this->staticRows) { + $valueRows = count($value); + $totalRows = count($defaults); + if ($valueRows < $totalRows) { + $value = array_pad($value, $totalRows, []); + } elseif ($valueRows > $totalRows) { + array_splice($value, $totalRows); + } + } + + // If the value is still empty, return null + if (empty($value)) { + return null; + } + + foreach ($value as $rowIndex => &$row) { foreach ($this->columns as $colId => $col) { - if (array_key_exists($colId, $row)) { + if ($col['type'] === 'heading') { + $cellValue = $defaults[$rowIndex][$colId] ?? ''; + } elseif (array_key_exists($colId, $row)) { $cellValue = $row[$colId]; } elseif ($col['handle'] && array_key_exists($col['handle'], $row)) { $cellValue = $row[$col['handle']]; } else { $cellValue = null; } - $cellValue = $this->_normalizeCellValue($col['type'], $cellValue); + $cellValue = $this->_normalizeCellValue($col['type'], $cellValue, $fromRequest); $row[$colId] = $cellValue; if ($col['handle']) { $row[$col['handle']] = $cellValue; @@ -441,14 +500,19 @@ public function serializeValue(mixed $value, ?ElementInterface $element = null): } $serialized = []; + $supportsMb4 = Craft::$app->getDb()->getSupportsMb4(); foreach ($value as $row) { $serializedRow = []; - foreach (array_keys($this->columns) as $colId) { + foreach ($this->columns as $colId => $column) { + if ($column['type'] === 'heading') { + continue; + } + $value = $row[$colId]; - if (is_string($value) && in_array($this->columns[$colId]['type'], ['singleline', 'multiline'], true)) { - $value = StringHelper::emojiToShortcodes($value); + if (is_string($value) && !$supportsMb4) { + $value = StringHelper::emojiToShortcodes(StringHelper::escapeShortcodes($value)); } $serializedRow[$colId] = parent::serializeValue($value ?? null); @@ -507,20 +571,10 @@ public function getContentGqlMutationArgumentType(): Type|array { $typeName = $this->handle . '_TableRowInput'; - if ($argumentType = GqlEntityRegistry::getEntity($typeName)) { - return Type::listOf($argumentType); - } - - $contentFields = TableRow::prepareRowFieldDefinition($this->columns, false); - - $argumentType = GqlEntityRegistry::createEntity($typeName, new InputObjectType([ + return Type::listOf(GqlEntityRegistry::getOrCreate($typeName, fn() => new InputObjectType([ 'name' => $typeName, - 'fields' => function() use ($contentFields) { - return $contentFields; - }, - ])); - - return Type::listOf($argumentType); + 'fields' => fn() => TableRow::prepareRowFieldDefinition($this->columns, false), + ]))); } /** @@ -528,10 +582,11 @@ public function getContentGqlMutationArgumentType(): Type|array * * @param string $type The cell type * @param mixed $value The cell value + * @param bool $fromRequest * @return mixed * @see normalizeValue() */ - private function _normalizeCellValue(string $type, mixed $value): mixed + private function _normalizeCellValue(string $type, mixed $value, bool $fromRequest): mixed { switch ($type) { case 'color': @@ -558,7 +613,9 @@ private function _normalizeCellValue(string $type, mixed $value): mixed case 'multiline': case 'singleline': if ($value !== null) { - $value = StringHelper::shortcodesToEmoji($value); + if (!$fromRequest) { + $value = StringHelper::unescapeShortcodes(StringHelper::shortcodesToEmoji($value)); + } return trim(preg_replace('/\R/u', "\n", $value)); } // no break @@ -661,6 +718,7 @@ private function _getInputHtml(mixed $value, ?ElementInterface $element, bool $s 'minRows' => $this->minRows, 'maxRows' => $this->maxRows, 'static' => $static, + 'staticRows' => $this->staticRows, 'allowAdd' => true, 'allowDelete' => true, 'allowReorder' => true, diff --git a/src/fields/Url.php b/src/fields/Url.php index 44d6eae426e..d4e97cf09cf 100644 --- a/src/fields/Url.php +++ b/src/fields/Url.php @@ -112,7 +112,7 @@ protected function defineRules(): array */ public function getContentColumnType(): string { - return Schema::TYPE_STRING . "($this->maxLength)"; + return sprintf('%s(%s)', Schema::TYPE_STRING, $this->maxLength); } /** diff --git a/src/gql/GqlEntityRegistry.php b/src/gql/GqlEntityRegistry.php index f518e9bd1b9..dc775971a2d 100644 --- a/src/gql/GqlEntityRegistry.php +++ b/src/gql/GqlEntityRegistry.php @@ -35,10 +35,16 @@ class GqlEntityRegistry */ public static function prefixTypeName(string $typeName): string { + $prefix = self::getPrefix(); + + if (!$prefix || str_starts_with($typeName, $prefix)) { + return $typeName; + } + $rootTypes = ['Query', 'Mutation', 'Subscription']; if (Craft::$app->getConfig()->getGeneral()->prefixGqlRootTypes || !in_array($typeName, $rootTypes)) { - return self::getPrefix() . $typeName; + return $prefix . $typeName; } return $typeName; @@ -71,19 +77,14 @@ public static function setPrefix(string $prefix): void } /** - * Get a registered entity. + * Return a registered entity. * * @param string $entityName * @return mixed */ public static function getEntity(string $entityName): mixed { - // Check if we need to apply the prefix. - $prefix = self::getPrefix(); - if ($prefix && !str_starts_with($entityName, $prefix)) { - $entityName = self::prefixTypeName($entityName); - } - + $entityName = self::prefixTypeName($entityName); return self::$_entities[$entityName] ?? false; } @@ -100,14 +101,25 @@ public static function createEntity(string $entityName, mixed $entity): mixed $entity->name = self::prefixTypeName($entity->name); self::$_entities[$entityName] = $entity; - - TypeLoader::registerType($entityName, function() use ($entity) { - return $entity; - }); + TypeLoader::registerType($entityName, fn() => $entity); return $entity; } + /** + * Returns a registered entity, creating it in the process if it doesn’t exist yet. + * + * @param string $name + * @param callable $factory + * @return mixed + * @since 4.5.0 + */ + public static function getOrCreate(string $name, callable $factory): mixed + { + $name = self::prefixTypeName($name); + return self::$_entities[$name] ??= self::createEntity($name, $factory()); + } + /** * Flush all registered entities. */ diff --git a/src/gql/base/GqlTypeTrait.php b/src/gql/base/GqlTypeTrait.php index 603c6c79248..103129d2f23 100644 --- a/src/gql/base/GqlTypeTrait.php +++ b/src/gql/base/GqlTypeTrait.php @@ -27,7 +27,7 @@ trait GqlTypeTrait */ public static function getType(?array $fields = null): Type { - return GqlEntityRegistry::getEntity(static::class) ?: GqlEntityRegistry::createEntity(static::class, new GqlObjectType([ + return GqlEntityRegistry::getOrCreate(static::class, fn() => new GqlObjectType([ /** @phpstan-ignore-next-line */ 'name' => static::getName(), 'fields' => $fields ?: (static::class . '::getFieldDefinitions'), diff --git a/src/gql/directives/FormatDateTime.php b/src/gql/directives/FormatDateTime.php index 7165e884979..4da053f4e88 100644 --- a/src/gql/directives/FormatDateTime.php +++ b/src/gql/directives/FormatDateTime.php @@ -35,12 +35,10 @@ class FormatDateTime extends Directive */ public static function create(): GqlDirective { - if ($type = GqlEntityRegistry::getEntity(self::name())) { - return $type; - } + $typeName = static::name(); - return GqlEntityRegistry::createEntity(static::name(), new self([ - 'name' => static::name(), + return GqlEntityRegistry::getOrCreate($typeName, fn() => new self([ + 'name' => $typeName, 'locations' => [ DirectiveLocation::FIELD, ], diff --git a/src/gql/directives/Markdown.php b/src/gql/directives/Markdown.php index 068e14db0c3..3fb5777f49e 100644 --- a/src/gql/directives/Markdown.php +++ b/src/gql/directives/Markdown.php @@ -32,12 +32,10 @@ class Markdown extends Directive */ public static function create(): GqlDirective { - if ($type = GqlEntityRegistry::getEntity(self::name())) { - return $type; - } + $typeName = static::name(); - return GqlEntityRegistry::createEntity(static::name(), new self([ - 'name' => static::name(), + return GqlEntityRegistry::getOrCreate($typeName, fn() => new self([ + 'name' => $typeName, 'locations' => [ DirectiveLocation::FIELD, ], diff --git a/src/gql/directives/Money.php b/src/gql/directives/Money.php index c7a88c842e7..9311f68da5c 100644 --- a/src/gql/directives/Money.php +++ b/src/gql/directives/Money.php @@ -42,12 +42,10 @@ class Money extends Directive */ public static function create(): GqlDirective { - if ($type = GqlEntityRegistry::getEntity(self::name())) { - return $type; - } + $typeName = static::name(); - return GqlEntityRegistry::createEntity(static::name(), new self([ - 'name' => static::name(), + return GqlEntityRegistry::getOrCreate($typeName, fn() => new self([ + 'name' => $typeName, 'locations' => [ DirectiveLocation::FIELD, ], diff --git a/src/gql/directives/ParseRefs.php b/src/gql/directives/ParseRefs.php index 7086bb9fc99..ee86b1a28fa 100644 --- a/src/gql/directives/ParseRefs.php +++ b/src/gql/directives/ParseRefs.php @@ -27,12 +27,10 @@ class ParseRefs extends Directive */ public static function create(): GqlDirective { - if ($type = GqlEntityRegistry::getEntity(self::name())) { - return $type; - } + $typeName = static::name(); - return GqlEntityRegistry::createEntity(static::name(), new self([ - 'name' => static::name(), + return GqlEntityRegistry::getOrCreate($typeName, fn() => new self([ + 'name' => $typeName, 'locations' => [ DirectiveLocation::FIELD, ], diff --git a/src/gql/directives/Transform.php b/src/gql/directives/Transform.php index 0f243f631c8..3ce2aa2eed3 100644 --- a/src/gql/directives/Transform.php +++ b/src/gql/directives/Transform.php @@ -43,12 +43,10 @@ public function __construct(array $config) */ public static function create(): GqlDirective { - if ($type = GqlEntityRegistry::getEntity(self::name())) { - return $type; - } + $typeName = static::name(); - return GqlEntityRegistry::createEntity(static::name(), new self([ - 'name' => static::name(), + return GqlEntityRegistry::getOrCreate(static::name(), fn() => new self([ + 'name' => $typeName, 'locations' => [ DirectiveLocation::FIELD, ], diff --git a/src/gql/resolvers/elements/Address.php b/src/gql/resolvers/elements/Address.php index dfeee8a6b7b..8bb6b4ff3e5 100644 --- a/src/gql/resolvers/elements/Address.php +++ b/src/gql/resolvers/elements/Address.php @@ -13,6 +13,7 @@ use craft\gql\base\ElementResolver; use craft\helpers\Gql as GqlHelper; use Illuminate\Support\Collection; +use yii\base\UnknownMethodException; /** * Class Address @@ -42,7 +43,13 @@ public static function prepareQuery(mixed $source, array $arguments, ?string $fi } foreach ($arguments as $key => $value) { - $query->$key($value); + try { + $query->$key($value); + } catch (UnknownMethodException $e) { + if ($value !== null) { + throw $e; + } + } } if (!GqlHelper::canQueryUsers()) { diff --git a/src/gql/resolvers/elements/Asset.php b/src/gql/resolvers/elements/Asset.php index 64ff87eaf04..eccd5a87d05 100644 --- a/src/gql/resolvers/elements/Asset.php +++ b/src/gql/resolvers/elements/Asset.php @@ -13,6 +13,7 @@ use craft\gql\base\ElementResolver; use craft\helpers\Gql as GqlHelper; use Illuminate\Support\Collection; +use yii\base\UnknownMethodException; /** * Class Asset @@ -41,7 +42,13 @@ public static function prepareQuery(mixed $source, array $arguments, ?string $fi } foreach ($arguments as $key => $value) { - $query->$key($value); + try { + $query->$key($value); + } catch (UnknownMethodException $e) { + if ($value !== null) { + throw $e; + } + } } $pairs = GqlHelper::extractAllowedEntitiesFromSchema('read'); diff --git a/src/gql/resolvers/elements/Category.php b/src/gql/resolvers/elements/Category.php index 76884dc1ff7..404bfd4e8df 100644 --- a/src/gql/resolvers/elements/Category.php +++ b/src/gql/resolvers/elements/Category.php @@ -13,6 +13,7 @@ use craft\gql\base\ElementResolver; use craft\helpers\Gql as GqlHelper; use Illuminate\Support\Collection; +use yii\base\UnknownMethodException; /** * Class Category @@ -41,7 +42,13 @@ public static function prepareQuery(mixed $source, array $arguments, ?string $fi } foreach ($arguments as $key => $value) { - $query->$key($value); + try { + $query->$key($value); + } catch (UnknownMethodException $e) { + if ($value !== null) { + throw $e; + } + } } $pairs = GqlHelper::extractAllowedEntitiesFromSchema('read'); diff --git a/src/gql/resolvers/elements/Entry.php b/src/gql/resolvers/elements/Entry.php index 1665d150ccc..8a1059e4dc1 100644 --- a/src/gql/resolvers/elements/Entry.php +++ b/src/gql/resolvers/elements/Entry.php @@ -13,6 +13,7 @@ use craft\gql\base\ElementResolver; use craft\helpers\Gql as GqlHelper; use Illuminate\Support\Collection; +use yii\base\UnknownMethodException; /** * Class Entry @@ -41,7 +42,13 @@ public static function prepareQuery(mixed $source, array $arguments, ?string $fi } foreach ($arguments as $key => $value) { - $query->$key($value); + try { + $query->$key($value); + } catch (UnknownMethodException $e) { + if ($value !== null) { + throw $e; + } + } } $pairs = GqlHelper::extractAllowedEntitiesFromSchema('read'); diff --git a/src/gql/resolvers/elements/GlobalSet.php b/src/gql/resolvers/elements/GlobalSet.php index 90712bdf3ce..702e9915eed 100644 --- a/src/gql/resolvers/elements/GlobalSet.php +++ b/src/gql/resolvers/elements/GlobalSet.php @@ -11,6 +11,7 @@ use craft\gql\base\ElementResolver; use craft\helpers\Gql as GqlHelper; use Illuminate\Support\Collection; +use yii\base\UnknownMethodException; /** * Class GlobalSet @@ -28,7 +29,13 @@ public static function prepareQuery(mixed $source, array $arguments, ?string $fi $query = GlobalSetElement::find(); foreach ($arguments as $key => $value) { - $query->$key($value); + try { + $query->$key($value); + } catch (UnknownMethodException $e) { + if ($value !== null) { + throw $e; + } + } } $pairs = GqlHelper::extractAllowedEntitiesFromSchema('read'); diff --git a/src/gql/resolvers/elements/MatrixBlock.php b/src/gql/resolvers/elements/MatrixBlock.php index 469acddf2f2..360db928bb7 100644 --- a/src/gql/resolvers/elements/MatrixBlock.php +++ b/src/gql/resolvers/elements/MatrixBlock.php @@ -10,6 +10,7 @@ use craft\elements\db\ElementQuery; use craft\elements\MatrixBlock as MatrixBlockElement; use craft\gql\base\ElementResolver; +use yii\base\UnknownMethodException; /** * Class MatrixBlock @@ -38,7 +39,13 @@ public static function prepareQuery(mixed $source, array $arguments, ?string $fi } foreach ($arguments as $key => $value) { - $query->$key($value); + try { + $query->$key($value); + } catch (UnknownMethodException $e) { + if ($value !== null) { + throw $e; + } + } } return $query; diff --git a/src/gql/resolvers/elements/Tag.php b/src/gql/resolvers/elements/Tag.php index 86e5d23e5c3..f71431bbd03 100644 --- a/src/gql/resolvers/elements/Tag.php +++ b/src/gql/resolvers/elements/Tag.php @@ -13,6 +13,7 @@ use craft\gql\base\ElementResolver; use craft\helpers\Gql as GqlHelper; use Illuminate\Support\Collection; +use yii\base\UnknownMethodException; /** * Class Tag @@ -41,7 +42,13 @@ public static function prepareQuery(mixed $source, array $arguments, ?string $fi } foreach ($arguments as $key => $value) { - $query->$key($value); + try { + $query->$key($value); + } catch (UnknownMethodException $e) { + if ($value !== null) { + throw $e; + } + } } $pairs = GqlHelper::extractAllowedEntitiesFromSchema('read'); diff --git a/src/gql/resolvers/elements/User.php b/src/gql/resolvers/elements/User.php index a96d4576637..fee1c3f6fe0 100644 --- a/src/gql/resolvers/elements/User.php +++ b/src/gql/resolvers/elements/User.php @@ -15,6 +15,7 @@ use craft\helpers\ArrayHelper; use craft\helpers\Gql as GqlHelper; use Illuminate\Support\Collection; +use yii\base\UnknownMethodException; /** * Class User @@ -66,7 +67,13 @@ public static function prepareQuery(mixed $source, array $arguments, ?string $fi } foreach ($arguments as $key => $value) { - $query->$key($value); + try { + $query->$key($value); + } catch (UnknownMethodException $e) { + if ($value !== null) { + throw $e; + } + } } if (!GqlHelper::canQueryUsers()) { diff --git a/src/gql/resolvers/mutations/Entry.php b/src/gql/resolvers/mutations/Entry.php index 44fe7d24f9d..ca9ac050c18 100644 --- a/src/gql/resolvers/mutations/Entry.php +++ b/src/gql/resolvers/mutations/Entry.php @@ -22,6 +22,7 @@ use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; use Throwable; +use yii\base\InvalidConfigException; /** * Class Entry @@ -52,15 +53,23 @@ public function saveEntry(mixed $source, array $arguments, mixed $context, Resol // If saving an entry for a site and the enabled status is provided, honor it. if (array_key_exists('enabled', $arguments)) { - if (!empty($arguments['siteId'])) { - $entry->setEnabledForSite([$arguments['siteId'] => $arguments['enabled']]); - // Set the global status to true if it's currently disabled, - // and we're enabling entry for a site - if ($arguments['enabled'] && !$entry->enabled) { + try { + $showStatusField = $entry->getType()->showStatusField; + } catch (InvalidConfigException) { + $showStatusField = true; + } + + if ($showStatusField) { + if (!empty($arguments['siteId'])) { + $entry->setEnabledForSite([$arguments['siteId'] => $arguments['enabled']]); + // Set the global status to true if it's currently disabled, + // and we're enabling entry for a site + if ($arguments['enabled'] && !$entry->enabled) { + $entry->enabled = $arguments['enabled']; + } + } else { $entry->enabled = $arguments['enabled']; } - } else { - $entry->enabled = $arguments['enabled']; } unset($arguments['enabled']); } diff --git a/src/gql/types/DateTime.php b/src/gql/types/DateTime.php index f377deff9d5..40a46322fb5 100644 --- a/src/gql/types/DateTime.php +++ b/src/gql/types/DateTime.php @@ -39,7 +39,7 @@ class DateTime extends ScalarType implements SingularTypeInterface */ public static function getType(): DateTime { - return GqlEntityRegistry::getEntity(self::getName()) ?: GqlEntityRegistry::createEntity(self::getName(), new self()); + return GqlEntityRegistry::getOrCreate(self::getName(), fn() => new self()); } /** diff --git a/src/gql/types/Money.php b/src/gql/types/Money.php index 16a1c9ccf22..8e6568927ee 100644 --- a/src/gql/types/Money.php +++ b/src/gql/types/Money.php @@ -42,7 +42,7 @@ class Money extends ScalarType implements SingularTypeInterface */ public static function getType(): Money { - return GqlEntityRegistry::getEntity(static::getName()) ?: GqlEntityRegistry::createEntity(self::getName(), new self()); + return GqlEntityRegistry::getOrCreate(static::getName(), fn() => new self()); } /** diff --git a/src/gql/types/Number.php b/src/gql/types/Number.php index 6f9701c4e49..8cca90f3e4d 100644 --- a/src/gql/types/Number.php +++ b/src/gql/types/Number.php @@ -41,7 +41,7 @@ class Number extends ScalarType implements SingularTypeInterface */ public static function getType(): Number { - return GqlEntityRegistry::getEntity(static::getName()) ?: GqlEntityRegistry::createEntity(self::getName(), new self()); + return GqlEntityRegistry::getOrCreate(static::getName(), fn() => new self()); } /** diff --git a/src/gql/types/QueryArgument.php b/src/gql/types/QueryArgument.php index 6c27b8893b4..859eb0bcda0 100644 --- a/src/gql/types/QueryArgument.php +++ b/src/gql/types/QueryArgument.php @@ -45,7 +45,7 @@ public function __construct(array $config = []) */ public static function getType(): QueryArgument { - return GqlEntityRegistry::getEntity(self::getName()) ?: GqlEntityRegistry::createEntity(self::getName(), new self()); + return GqlEntityRegistry::getOrCreate(self::getName(), fn() => new self()); } /** diff --git a/src/gql/types/generators/AddressType.php b/src/gql/types/generators/AddressType.php index 4b98811a334..ba0f0349b76 100644 --- a/src/gql/types/generators/AddressType.php +++ b/src/gql/types/generators/AddressType.php @@ -41,16 +41,15 @@ public static function generateTypes(mixed $context = null): array */ public static function generateType(mixed $context): ObjectType { - // Users don't have different types, so the context for a user will be the same every time. - $context = $context ?: Craft::$app->getFields()->getLayoutByType(AddressElement::class); - $typeName = AddressElement::gqlTypeNameByContext(null); - $contentFieldGqlTypes = self::getContentFields($context); - $addressFields = array_merge(AddressInterface::getFieldDefinitions(), $contentFieldGqlTypes); - return GqlEntityRegistry::getEntity($typeName) ?: GqlEntityRegistry::createEntity($typeName, new Address([ + return GqlEntityRegistry::getOrCreate($typeName, fn() => new Address([ 'name' => $typeName, - 'fields' => function() use ($addressFields, $typeName) { + 'fields' => function() use ($context, $typeName) { + // Users don't have different types, so the context for a user will be the same every time. + $context ??= Craft::$app->getFields()->getLayoutByType(AddressElement::class); + $contentFieldGqlTypes = self::getContentFields($context); + $addressFields = array_merge(AddressInterface::getFieldDefinitions(), $contentFieldGqlTypes); return Craft::$app->getGql()->prepareFieldDefinitions($addressFields, $typeName); }, ])); diff --git a/src/gql/types/generators/AssetType.php b/src/gql/types/generators/AssetType.php index adfe222acf0..740b89aa964 100644 --- a/src/gql/types/generators/AssetType.php +++ b/src/gql/types/generators/AssetType.php @@ -56,13 +56,12 @@ public static function generateTypes(mixed $context = null): array public static function generateType(mixed $context): ObjectType { $typeName = AssetElement::gqlTypeNameByContext($context); - $contentFieldGqlTypes = self::getContentFields($context); - $assetFields = array_merge(AssetInterface::getFieldDefinitions(), $contentFieldGqlTypes); - - return GqlEntityRegistry::getEntity($typeName) ?: GqlEntityRegistry::createEntity($typeName, new Asset([ + return GqlEntityRegistry::getOrCreate($typeName, fn() => new Asset([ 'name' => $typeName, - 'fields' => function() use ($assetFields, $typeName) { + 'fields' => function() use ($context, $typeName) { + $contentFieldGqlTypes = self::getContentFields($context); + $assetFields = array_merge(AssetInterface::getFieldDefinitions(), $contentFieldGqlTypes); return Craft::$app->getGql()->prepareFieldDefinitions($assetFields, $typeName); }, ])); diff --git a/src/gql/types/generators/CategoryType.php b/src/gql/types/generators/CategoryType.php index 0c5f1d37728..19e17563d90 100644 --- a/src/gql/types/generators/CategoryType.php +++ b/src/gql/types/generators/CategoryType.php @@ -55,13 +55,12 @@ public static function generateTypes(mixed $context = null): array public static function generateType(mixed $context): ObjectType { $typeName = CategoryElement::gqlTypeNameByContext($context); - $contentFieldGqlTypes = self::getContentFields($context); - $categoryGroupFields = array_merge(CategoryInterface::getFieldDefinitions(), $contentFieldGqlTypes); - - return GqlEntityRegistry::getEntity($typeName) ?: GqlEntityRegistry::createEntity($typeName, new Category([ + return GqlEntityRegistry::getOrCreate($typeName, fn() => new Category([ 'name' => $typeName, - 'fields' => function() use ($categoryGroupFields, $typeName) { + 'fields' => function() use ($context, $typeName) { + $contentFieldGqlTypes = self::getContentFields($context); + $categoryGroupFields = array_merge(CategoryInterface::getFieldDefinitions(), $contentFieldGqlTypes); return Craft::$app->getGql()->prepareFieldDefinitions($categoryGroupFields, $typeName); }, ])); diff --git a/src/gql/types/generators/ElementType.php b/src/gql/types/generators/ElementType.php index 39220c5a26a..50a67524c6e 100644 --- a/src/gql/types/generators/ElementType.php +++ b/src/gql/types/generators/ElementType.php @@ -40,11 +40,11 @@ public static function generateTypes(mixed $context = null): array public static function generateType(mixed $context): ObjectType { $typeName = BaseElement::gqlTypeNameByContext(null); - $elementFields = ElementInterface::getFieldDefinitions(); - return GqlEntityRegistry::getEntity($typeName) ?: GqlEntityRegistry::createEntity($typeName, new Element([ + return GqlEntityRegistry::getOrCreate($typeName, fn() => new Element([ 'name' => $typeName, - 'fields' => function() use ($elementFields, $typeName) { + 'fields' => function() use ($typeName) { + $elementFields = ElementInterface::getFieldDefinitions(); return Craft::$app->getGql()->prepareFieldDefinitions($elementFields, $typeName); }, ])); diff --git a/src/gql/types/generators/EntryType.php b/src/gql/types/generators/EntryType.php index 36fc2db794d..1291a45f762 100644 --- a/src/gql/types/generators/EntryType.php +++ b/src/gql/types/generators/EntryType.php @@ -51,16 +51,11 @@ public static function generateType(mixed $context): ObjectType { $typeName = EntryElement::gqlTypeNameByContext($context); - if ($createdType = GqlEntityRegistry::getEntity($typeName)) { - return $createdType; - } - - $contentFieldGqlTypes = self::getContentFields($context); - $entryTypeFields = array_merge(EntryInterface::getFieldDefinitions(), $contentFieldGqlTypes); - - return GqlEntityRegistry::createEntity($typeName, new Entry([ + return GqlEntityRegistry::getOrCreate($typeName, fn() => new Entry([ 'name' => $typeName, - 'fields' => function() use ($entryTypeFields, $typeName) { + 'fields' => function() use ($context, $typeName) { + $contentFieldGqlTypes = self::getContentFields($context); + $entryTypeFields = array_merge(EntryInterface::getFieldDefinitions(), $contentFieldGqlTypes); return Craft::$app->getGql()->prepareFieldDefinitions($entryTypeFields, $typeName); }, ])); diff --git a/src/gql/types/generators/GlobalSetType.php b/src/gql/types/generators/GlobalSetType.php index 5afaf5a9874..a95efc6a996 100644 --- a/src/gql/types/generators/GlobalSetType.php +++ b/src/gql/types/generators/GlobalSetType.php @@ -65,13 +65,11 @@ public static function generateType(mixed $context): ObjectType { $typeName = self::getName($context); - $contentFieldGqlTypes = self::getContentFields($context); - - $globalSetFields = array_merge(GlobalSetInterface::getFieldDefinitions(), $contentFieldGqlTypes); - - return GqlEntityRegistry::getEntity($typeName) ?: GqlEntityRegistry::createEntity($typeName, new GlobalSet([ + return GqlEntityRegistry::getOrCreate($typeName, fn() => new GlobalSet([ 'name' => $typeName, - 'fields' => function() use ($globalSetFields, $typeName) { + 'fields' => function() use ($context, $typeName) { + $contentFieldGqlTypes = self::getContentFields($context); + $globalSetFields = array_merge(GlobalSetInterface::getFieldDefinitions(), $contentFieldGqlTypes); return Craft::$app->getGql()->prepareFieldDefinitions($globalSetFields, $typeName); }, ])); diff --git a/src/gql/types/generators/MatrixBlockType.php b/src/gql/types/generators/MatrixBlockType.php index ace816e8620..b00aaf7cd0e 100644 --- a/src/gql/types/generators/MatrixBlockType.php +++ b/src/gql/types/generators/MatrixBlockType.php @@ -56,26 +56,13 @@ public static function generateType(mixed $context): ObjectType { $typeName = MatrixBlockElement::gqlTypeNameByContext($context); - if (!($entity = GqlEntityRegistry::getEntity($typeName))) { - $contentFieldGqlTypes = self::getContentFields($context); - $blockTypeFields = array_merge(MatrixBlockInterface::getFieldDefinitions(), $contentFieldGqlTypes); - - // Generate a type for each block type - $entity = GqlEntityRegistry::getEntity($typeName); - - if (!$entity) { - $entity = new MatrixBlock([ - 'name' => $typeName, - 'fields' => function() use ($blockTypeFields, $typeName) { - return Craft::$app->getGql()->prepareFieldDefinitions($blockTypeFields, $typeName); - }, - ]); - - // It's possible that creating the matrix block triggered creating all matrix block types, so check again. - $entity = GqlEntityRegistry::getEntity($typeName) ?: GqlEntityRegistry::createEntity($typeName, $entity); - } - } - - return $entity; + return GqlEntityRegistry::getOrCreate($typeName, fn() => new MatrixBlock([ + 'name' => $typeName, + 'fields' => function() use ($context, $typeName) { + $contentFieldGqlTypes = self::getContentFields($context); + $blockTypeFields = array_merge(MatrixBlockInterface::getFieldDefinitions(), $contentFieldGqlTypes); + return Craft::$app->getGql()->prepareFieldDefinitions($blockTypeFields, $typeName); + }, + ])); } } diff --git a/src/gql/types/generators/TableRowType.php b/src/gql/types/generators/TableRowType.php index 6f7ce019709..6e94f4ddbb9 100644 --- a/src/gql/types/generators/TableRowType.php +++ b/src/gql/types/generators/TableRowType.php @@ -45,13 +45,13 @@ public static function getName($context = null): string */ public static function generateType(mixed $context): ObjectType { - /** @var TableField $context */ $typeName = self::getName($context); - $contentFields = TableRow::prepareRowFieldDefinition($context->columns); - return GqlEntityRegistry::getEntity($typeName) ?: GqlEntityRegistry::createEntity($typeName, new TableRow([ + return GqlEntityRegistry::getOrCreate($typeName, fn() => new TableRow([ 'name' => $typeName, - 'fields' => function() use ($contentFields, $typeName) { + 'fields' => function() use ($context, $typeName) { + /** @var TableField $context */ + $contentFields = TableRow::prepareRowFieldDefinition($context->columns); return Craft::$app->getGql()->prepareFieldDefinitions($contentFields, $typeName); }, ])); diff --git a/src/gql/types/generators/TagType.php b/src/gql/types/generators/TagType.php index 1d19fd38341..c687abf2a34 100644 --- a/src/gql/types/generators/TagType.php +++ b/src/gql/types/generators/TagType.php @@ -55,12 +55,12 @@ public static function generateTypes(mixed $context = null): array public static function generateType(mixed $context): ObjectType { $typeName = TagElement::gqlTypeNameByContext($context); - $contentFieldGqlTypes = self::getContentFields($context); - $tagGroupFields = array_merge(TagInterface::getFieldDefinitions(), $contentFieldGqlTypes); - return GqlEntityRegistry::getEntity($typeName) ?: GqlEntityRegistry::createEntity($typeName, new Tag([ + return GqlEntityRegistry::getOrCreate($typeName, fn() => new Tag([ 'name' => $typeName, - 'fields' => function() use ($tagGroupFields, $typeName) { + 'fields' => function() use ($context, $typeName) { + $contentFieldGqlTypes = self::getContentFields($context); + $tagGroupFields = array_merge(TagInterface::getFieldDefinitions(), $contentFieldGqlTypes); return Craft::$app->getGql()->prepareFieldDefinitions($tagGroupFields, $typeName); }, ])); diff --git a/src/gql/types/generators/UserType.php b/src/gql/types/generators/UserType.php index 8f48a1f25e5..ab32c32894f 100644 --- a/src/gql/types/generators/UserType.php +++ b/src/gql/types/generators/UserType.php @@ -40,16 +40,15 @@ public static function generateTypes(mixed $context = null): array */ public static function generateType(mixed $context): ObjectType { - // Users don't have different types, so the context for a user will be the same every time. - $context = $context ?: Craft::$app->getFields()->getLayoutByType(UserElement::class); - $typeName = UserElement::gqlTypeNameByContext(null); - $contentFieldGqlTypes = self::getContentFields($context); - $userFields = array_merge(UserInterface::getFieldDefinitions(), $contentFieldGqlTypes); - return GqlEntityRegistry::getEntity($typeName) ?: GqlEntityRegistry::createEntity($typeName, new User([ + return GqlEntityRegistry::getOrCreate($typeName, fn() => new User([ 'name' => $typeName, - 'fields' => function() use ($userFields, $typeName) { + 'fields' => function() use ($context, $typeName) { + // Users don't have different types, so the context for a user will be the same every time. + $context ??= Craft::$app->getFields()->getLayoutByType(UserElement::class); + $contentFieldGqlTypes = self::getContentFields($context); + $userFields = array_merge(UserInterface::getFieldDefinitions(), $contentFieldGqlTypes); return Craft::$app->getGql()->prepareFieldDefinitions($userFields, $typeName); }, ])); diff --git a/src/gql/types/input/File.php b/src/gql/types/input/File.php index 7f96b420a16..1cb114587f7 100644 --- a/src/gql/types/input/File.php +++ b/src/gql/types/input/File.php @@ -26,7 +26,7 @@ public static function getType(): mixed { $typeName = 'FileInput'; - return GqlEntityRegistry::getEntity($typeName) ?: GqlEntityRegistry::createEntity($typeName, new InputObjectType([ + return GqlEntityRegistry::getOrCreate($typeName, fn() => new InputObjectType([ 'name' => $typeName, 'fields' => [ 'fileData' => [ diff --git a/src/gql/types/input/Matrix.php b/src/gql/types/input/Matrix.php index 69cc09149a1..8e1ba2b1ea4 100644 --- a/src/gql/types/input/Matrix.php +++ b/src/gql/types/input/Matrix.php @@ -33,53 +33,46 @@ public static function getType(MatrixField $context): mixed { $typeName = $context->handle . '_MatrixInput'; - if ($inputType = GqlEntityRegistry::getEntity($typeName)) { - return $inputType; - } - - // Array of block types. - $blockTypes = $context->getBlockTypes(); - $blockInputTypes = []; - - // For all the blocktypes - foreach ($blockTypes as $blockType) { - $fields = $blockType->getCustomFields(); - $blockTypeFields = [ - 'id' => [ - 'name' => 'id', - 'type' => Type::id(), - ], - ]; - - // Get the field input types - foreach ($fields as $field) { - /** @var Field $field */ - $blockTypeFields[$field->handle] = $field->getContentGqlMutationArgumentType(); - } - - $blockTypeGqlName = $context->handle . '_' . $blockType->handle . '_MatrixBlockInput'; - $blockInputTypes[$blockType->handle] = [ - 'name' => $blockType->handle, - 'type' => GqlEntityRegistry::createEntity($blockTypeGqlName, new InputObjectType([ - 'name' => $blockTypeGqlName, - 'fields' => $blockTypeFields, - ])), - ]; - } - - // All the different field block types now get wrapped in a container input. - // If two different block types are passed, the selected block type to parse is undefined. - $blockTypeContainerName = $context->handle . '_MatrixBlockContainerInput'; - $blockContainerInputType = GqlEntityRegistry::createEntity($blockTypeContainerName, new InputObjectType([ - 'name' => $blockTypeContainerName, - 'fields' => function() use ($blockInputTypes) { - return $blockInputTypes; - }, - ])); - - return GqlEntityRegistry::createEntity($typeName, new InputObjectType([ + return GqlEntityRegistry::getOrCreate($typeName, fn() => new InputObjectType([ 'name' => $typeName, - 'fields' => function() use ($blockContainerInputType) { + 'fields' => function() use ($context) { + // All the different field block types now get wrapped in a container input. + // If two different block types are passed, the selected block type to parse is undefined. + $blockTypeContainerName = $context->handle . '_MatrixBlockContainerInput'; + $blockContainerInputType = GqlEntityRegistry::getOrCreate($blockTypeContainerName, fn() => new InputObjectType([ + 'name' => $blockTypeContainerName, + 'fields' => function() use ($context) { + $blockInputTypes = []; + + foreach ($context->getBlockTypes() as $blockType) { + $blockTypeGqlName = $context->handle . '_' . $blockType->handle . '_MatrixBlockInput'; + $blockInputTypes[$blockType->handle] = [ + 'name' => $blockType->handle, + 'type' => GqlEntityRegistry::getOrCreate($blockTypeGqlName, fn() => new InputObjectType([ + 'name' => $blockTypeGqlName, + 'fields' => function() use ($blockType) { + $blockTypeFields = [ + 'id' => [ + 'name' => 'id', + 'type' => Type::id(), + ], + ]; + + // Get the field input types + foreach ($blockType->getCustomFields() as $field) { + $blockTypeFields[$field->handle] = $field->getContentGqlMutationArgumentType(); + } + + return $blockTypeFields; + }, + ])), + ]; + } + + return $blockInputTypes; + }, + ])); + return [ 'sortOrder' => [ 'name' => 'sortOrder', diff --git a/src/gql/types/input/criteria/Asset.php b/src/gql/types/input/criteria/Asset.php index 7a4ab314c58..727e441fd6a 100644 --- a/src/gql/types/input/criteria/Asset.php +++ b/src/gql/types/input/criteria/Asset.php @@ -26,7 +26,7 @@ public static function getType(): mixed { $typeName = 'AssetCriteriaInput'; - return GqlEntityRegistry::getEntity($typeName) ?: GqlEntityRegistry::createEntity($typeName, new InputObjectType([ + return GqlEntityRegistry::getOrCreate($typeName, fn() => new InputObjectType([ 'name' => $typeName, 'fields' => function() { return AssetArguments::getArguments(); diff --git a/src/gql/types/input/criteria/Category.php b/src/gql/types/input/criteria/Category.php index bac407dbb5a..daa048299de 100644 --- a/src/gql/types/input/criteria/Category.php +++ b/src/gql/types/input/criteria/Category.php @@ -26,7 +26,7 @@ public static function getType(): mixed { $typeName = 'CategoryCriteriaInput'; - return GqlEntityRegistry::getEntity($typeName) ?: GqlEntityRegistry::createEntity($typeName, new InputObjectType([ + return GqlEntityRegistry::getOrCreate($typeName, fn() => new InputObjectType([ 'name' => $typeName, 'fields' => function() { return CategoryArguments::getArguments(); diff --git a/src/gql/types/input/criteria/Entry.php b/src/gql/types/input/criteria/Entry.php index 48e8eebb1f8..1d182533202 100644 --- a/src/gql/types/input/criteria/Entry.php +++ b/src/gql/types/input/criteria/Entry.php @@ -26,7 +26,7 @@ public static function getType(): mixed { $typeName = 'EntryCriteriaInput'; - return GqlEntityRegistry::getEntity($typeName) ?: GqlEntityRegistry::createEntity($typeName, new InputObjectType([ + return GqlEntityRegistry::getOrCreate($typeName, fn() => new InputObjectType([ 'name' => $typeName, 'fields' => function() { return EntryArguments::getArguments(); diff --git a/src/gql/types/input/criteria/Tag.php b/src/gql/types/input/criteria/Tag.php index 59cc9952d8f..1d76ff5a684 100644 --- a/src/gql/types/input/criteria/Tag.php +++ b/src/gql/types/input/criteria/Tag.php @@ -26,7 +26,7 @@ public static function getType(): mixed { $typeName = 'TagCriteriaInput'; - return GqlEntityRegistry::getEntity($typeName) ?: GqlEntityRegistry::createEntity($typeName, new InputObjectType([ + return GqlEntityRegistry::getOrCreate($typeName, fn() => new InputObjectType([ 'name' => $typeName, 'fields' => function() { return TagArguments::getArguments(); diff --git a/src/gql/types/input/criteria/User.php b/src/gql/types/input/criteria/User.php index 8a528be8d90..6c540ee9a5a 100644 --- a/src/gql/types/input/criteria/User.php +++ b/src/gql/types/input/criteria/User.php @@ -26,7 +26,7 @@ public static function getType(): mixed { $typeName = 'UserCriteriaInput'; - return GqlEntityRegistry::getEntity($typeName) ?: GqlEntityRegistry::createEntity($typeName, new InputObjectType([ + return GqlEntityRegistry::getOrCreate($typeName, fn() => new InputObjectType([ 'name' => $typeName, 'fields' => function() { return UserArguments::getArguments(); diff --git a/src/helpers/Api.php b/src/helpers/Api.php index 3e95864583d..49e2fd7eee6 100644 --- a/src/helpers/Api.php +++ b/src/helpers/Api.php @@ -85,6 +85,12 @@ public static function headers(): array $headers['X-Craft-Plugin-Licenses'] = implode(',', $pluginLicenses); } + // Craft Cloud + $craftCloudProjectId = App::env('CRAFT_CLOUD_PROJECT_ID'); + if ($craftCloudProjectId) { + $headers['X-Craft-Cloud-Project-Id'] = $craftCloudProjectId; + } + return $headers; } diff --git a/src/helpers/App.php b/src/helpers/App.php index 3ed708abd86..ab5ffc526ee 100644 --- a/src/helpers/App.php +++ b/src/helpers/App.php @@ -35,6 +35,7 @@ use ReflectionClass; use ReflectionProperty; use yii\base\Event; +use yii\base\Exception; use yii\base\InvalidArgumentException; use yii\base\InvalidValueException; use yii\helpers\Inflector; @@ -60,6 +61,11 @@ class App */ private static array $_basePaths; + /** + * @var string[] + */ + private static array $_secrets; + /** * Returns whether Dev Mode is enabled. * @@ -72,14 +78,37 @@ public static function devMode(): bool } /** - * Returns an environment variable, falling back to a PHP constant of the same name. + * Returns an environment-specific value. + * + * Values will be looked for in the following places: + * + * 1. “Secret” values returned by a PHP file identified by a `CRAFT_SECRETS_PATH` environment variable + * 2. Environment variables stored in `$_SERVER` + * 3. Environment variables returned by `getenv()` + * 4. PHP constants * - * @param string $name The environment variable name - * @return mixed The environment variable, PHP constant, or `null` if neither are found + * If the value cannot be found, `null` will be returned. + * + * @param string $name The name to search for. + * @return mixed The value, or `null` if not found. + * @throws Exception * @since 3.4.18 */ public static function env(string $name): mixed { + if (!isset(self::$_secrets)) { + // set it to an empty array initially, so the nested env() call doesn’t cause infinite recursion + self::$_secrets = []; + $secretsPath = static::env('CRAFT_SECRETS_PATH'); + if ($secretsPath && is_file($secretsPath)) { + self::$_secrets = require $secretsPath; + } + } + + if (isset(self::$_secrets[$name])) { + return static::normalizeValue(self::$_secrets[$name]); + } + if (isset($_SERVER[$name])) { return static::normalizeValue($_SERVER[$name]); } @@ -1002,8 +1031,8 @@ public static function viewConfig(): array if ($request->getIsCpRequest()) { $headers = $request->getHeaders(); - $config['registeredAssetBundles'] = explode(',', $headers->get('X-Registered-Asset-Bundles', '')); - $config['registeredJsFiles'] = explode(',', $headers->get('X-Registered-Js-Files', '')); + $config['registeredAssetBundles'] = array_filter(explode(',', $headers->get('X-Registered-Asset-Bundles', ''))); + $config['registeredJsFiles'] = array_filter(explode(',', $headers->get('X-Registered-Js-Files', ''))); } return $config; diff --git a/src/helpers/ArrayHelper.php b/src/helpers/ArrayHelper.php index 6e271a9d92f..9d44e6e24ce 100644 --- a/src/helpers/ArrayHelper.php +++ b/src/helpers/ArrayHelper.php @@ -347,15 +347,11 @@ public static function filterEmptyStringsFromArray(array $array): array * * @param array $array * @return string|int|null The first key, whether that is a number (if the array is numerically indexed) or a string, or null if $array isn’t an array, or is empty. + * @deprecated in 4.5.0. `array_key_first()` should be used instead. */ public static function firstKey(array $array): int|string|null { - /** @noinspection LoopWhichDoesNotLoopInspection */ - foreach ($array as $key => $value) { - return $key; - } - - return null; + return array_key_first($array); } /** diff --git a/src/helpers/Assets.php b/src/helpers/Assets.php index 6d8bc9a972e..99e7e69df06 100644 --- a/src/helpers/Assets.php +++ b/src/helpers/Assets.php @@ -180,7 +180,7 @@ public static function urlAppendix(Asset $asset, ?DateTime $dateUpdated = null): } $revParams = self::revParams($asset, $dateUpdated); - return sprintf('?%s', UrlHelper::buildQuery($revParams)); + return sprintf('?%s', http_build_query($revParams)); } /** @@ -884,6 +884,7 @@ public static function downloadFile(BaseFsInterface $fs, string $uriPath, string * @param string $extension * @return string * @since 4.0.0 + * @deprecated in 4.5.0 */ public static function iconUrl(string $extension): string { @@ -898,8 +899,30 @@ public static function iconUrl(string $extension): string * @param string $extension * @return string * @since 4.0.0 + * @deprecated in 4.5.0. [[iconSvg()]] or [[Asset::getThumbSvg()]] should be used instead. */ public static function iconPath(string $extension): string + { + $path = sprintf('%s%s%s.svg', Craft::$app->getPath()->getAssetsIconsPath(), DIRECTORY_SEPARATOR, strtolower($extension)); + + if (file_exists($path)) { + return $path; + } + + $svg = static::iconSvg($extension); + + FileHelper::writeToFile($path, $svg); + return $path; + } + + /** + * Returns the SVG contents for an asset icon with a given extension. + * + * @param string $extension + * @return string + * @since 4.5.0 + */ + public static function iconSvg(string $extension): string { if (!preg_match('/^\w+$/', $extension)) { throw new InvalidArgumentException("$extension isn’t a valid file extension."); @@ -915,20 +938,22 @@ public static function iconPath(string $extension): string $extLength = strlen($extension); if ($extLength <= 3) { - $textSize = '20'; + $textSize = '19'; } elseif ($extLength === 4) { - $textSize = '17'; + $textSize = '16'; } else { - if ($extLength > 5) { - $extension = substr($extension, 0, 4) . '…'; - } - $textSize = '14'; - } - - $textNode = "" . strtoupper($extension) . ''; - $svg = str_replace('', $textNode, $svg); + $extension = substr($extension, 0, 3) . '…'; + $textSize = '15'; + } + $textNode = Html::tag('text', strtoupper($extension), [ + 'x' => 50, + 'y' => 73, + 'text-anchor' => 'middle', + 'font-family' => 'sans-serif', + 'fill' => 'hsl(210, 10%, 47%)', + 'font-size' => $textSize, + ]); - FileHelper::writeToFile($path, $svg); - return $path; + return Html::appendToTag($svg, $textNode); } } diff --git a/src/helpers/Cp.php b/src/helpers/Cp.php index 882c8cccbde..dc67f0163f7 100644 --- a/src/helpers/Cp.php +++ b/src/helpers/Cp.php @@ -406,37 +406,11 @@ public static function elementHtml( $showStatus = $showStatus && ($isDraft || $element::hasStatuses()); // Create the thumb/icon image, if there is one - if ($showThumb) { - $thumbSizePx = $size === self::ELEMENT_SIZE_SMALL ? 34 : 120; - $thumbUrl = $element->getThumbUrl($thumbSizePx); - } else { - $thumbSizePx = $thumbUrl = null; - } + $thumbHtml = null; - if ($thumbUrl !== null) { - $imageSize2x = $thumbSizePx * 2; - $thumbUrl2x = $element->getThumbUrl($imageSize2x); - - $srcsets = [ - "$thumbUrl {$thumbSizePx}w", - "$thumbUrl2x {$imageSize2x}w", - ]; - $sizesHtml = "{$thumbSizePx}px"; - $srcsetHtml = implode(', ', $srcsets); - $imgHtml = Html::tag('div', '', [ - 'class' => array_filter([ - 'elementthumb', - $element->getHasCheckeredThumb() ? 'checkered' : null, - $size === self::ELEMENT_SIZE_SMALL && $element->getHasRoundedThumb() ? 'rounded' : null, - ]), - 'data' => [ - 'sizes' => $sizesHtml, - 'srcset' => $srcsetHtml, - 'alt' => $element->getThumbAlt(), - ], - ]); - } else { - $imgHtml = ''; + if ($showThumb) { + $thumbSize = $size === self::ELEMENT_SIZE_SMALL ? 34 : 120; + $thumbHtml = $element->getThumbHtml($thumbSize); } $title = ''; @@ -487,7 +461,7 @@ public static function elementHtml( $attributes['class'][] = 'hasstatus'; } - if ($thumbUrl !== null) { + if ($thumbHtml !== null) { $attributes['class'][] = 'hasthumb'; } @@ -531,7 +505,9 @@ public static function elementHtml( ]); } - $innerHtml .= $imgHtml; + if ($thumbHtml !== null) { + $innerHtml .= $thumbHtml; + } if ($showLabel) { $innerHtml .= '
'; @@ -789,7 +765,6 @@ public static function fieldHtml(string $input, array $config = []): string $labelHtml = ''; } - $containerTag = $fieldset ? 'fieldset' : 'div'; return @@ -800,6 +775,7 @@ public static function fieldHtml(string $input, array $config = []): string 'data' => [ 'attribute' => $attribute, ], + 'tabindex' => -1, ], $config['fieldAttributes'] ?? [] )) . @@ -1613,10 +1589,7 @@ public static function fieldLayoutDesignerHtml(FieldLayout $fieldLayout, array $ 'customizableUi' => true, ]; - $tabs = array_values(array_filter( - $fieldLayout->getTabs(), - fn(FieldLayoutTab $tab) => !empty($tab->getElements()) - )); + $tabs = array_values($fieldLayout->getTabs()); if (!$config['customizableTabs']) { $tab = array_shift($tabs) ?? new FieldLayoutTab([ diff --git a/src/helpers/FileHelper.php b/src/helpers/FileHelper.php index 4db2a5e48ee..cf067ff3ae3 100644 --- a/src/helpers/FileHelper.php +++ b/src/helpers/FileHelper.php @@ -264,7 +264,7 @@ public static function sanitizeFilename(string $filename, array $options = []): // Strip any characters not allowed. $filename = str_replace($disallowedChars, '', strip_tags($filename)); - if (Craft::$app->getDb()->getIsMysql()) { + if (!Craft::$app->getDb()->getSupportsMb4()) { // Strip emojis $filename = StringHelper::replaceMb4($filename, ''); } @@ -854,11 +854,13 @@ public static function addFilesToZip(ZipArchive $zip, string $dir, ?string $pref * Return a file extension for the given MIME type. * * @param string $mimeType + * @param bool $preferShort + * @param string|null $magicFile * @return string * @throws InvalidArgumentException if no known extensions exist for the given MIME type. * @since 3.5.15 */ - public static function getExtensionByMimeType(string $mimeType): string + public static function getExtensionByMimeType($mimeType, $preferShort = false, $magicFile = null): string { // cover the ambiguous, web-friendly MIME types up front switch (strtolower($mimeType)) { @@ -881,7 +883,7 @@ public static function getExtensionByMimeType(string $mimeType): string case 'video/quicktime': return 'mov'; } - $extensions = FileHelper::getExtensionsByMimeType($mimeType); + $extensions = self::getExtensionsByMimeType($mimeType); if (empty($extensions)) { throw new InvalidArgumentException("No file extensions are known for the MIME Type $mimeType."); diff --git a/src/helpers/Gql.php b/src/helpers/Gql.php index 188930c2452..6476fdf0ac6 100644 --- a/src/helpers/Gql.php +++ b/src/helpers/Gql.php @@ -292,13 +292,9 @@ public static function canQueryUsers(?GqlSchema $schema = null): bool */ public static function getUnionType(string $typeName, array $includedTypes, ?callable $resolveFunction = null): mixed { - if (!$resolveFunction) { - $resolveFunction = function(ElementInterface $value) { - return $value->getGqlTypeName(); - }; - } + $resolveFunction ??= fn(ElementInterface $value) => $value->getGqlTypeName(); - return GqlEntityRegistry::getEntity($typeName) ?: GqlEntityRegistry::createEntity($typeName, new UnionType([ + return GqlEntityRegistry::getOrCreate($typeName, fn() => new UnionType([ 'name' => $typeName, 'types' => $includedTypes, 'resolveType' => $resolveFunction, diff --git a/src/helpers/StringHelper.php b/src/helpers/StringHelper.php index fd29e33c9e8..feaa9cade69 100644 --- a/src/helpers/StringHelper.php +++ b/src/helpers/StringHelper.php @@ -39,6 +39,12 @@ class StringHelper extends \yii\helpers\StringHelper */ private static array $_asciiCharMaps; + /** + * @var string[]|false + * @see escapeShortcodes() + */ + private static array|false $_shortcodeEscapeMap; + /** * Gets the substring after the first occurrence of a separator. * @@ -1977,4 +1983,55 @@ public static function shortcodesToEmoji(string $str): string { return LitEmoji::shortcodeToUnicode($str); } + + /** + * Escapes shortcodes. + * + * @param string $str + * @return string + * @since 4.5.0 + */ + public static function escapeShortcodes(string $str): string + { + $map = self::shortcodeEscapeMap(); + if ($map === false) { + return $str; + } + return str_replace(array_keys($map), $map, $str); + } + + /** + * Unscapes shortcodes. + * + * @param string $str + * @return string + * @since 4.5.0 + */ + public static function unescapeShortcodes(string $str): string + { + $map = self::shortcodeEscapeMap(); + if ($map === false) { + return $str; + } + return str_replace($map, array_keys($map), $str); + } + + private static function shortcodeEscapeMap(): array|false + { + if (!isset(self::$_shortcodeEscapeMap)) { + $path = Craft::$app->getPath()->getVendorPath() . '/elvanto/litemoji/src/shortcodes-array.php'; + if (file_exists($path)) { + $shortcodes = array_keys(require $path); + self::$_shortcodeEscapeMap = array_combine( + array_map(fn(string $shortcode) => ":$shortcode:", $shortcodes), + array_map(fn(string $shortcode) => "\\:$shortcode\\:", $shortcodes), + ); + } else { + Craft::warning('Unable to escape shortcodes: shortcodes-array.php doesn’t exist at the expected location.'); + self::$_shortcodeEscapeMap = false; + } + } + + return self::$_shortcodeEscapeMap; + } } diff --git a/src/helpers/Template.php b/src/helpers/Template.php index d2b2b8a0192..2ff93e494f8 100644 --- a/src/helpers/Template.php +++ b/src/helpers/Template.php @@ -9,7 +9,6 @@ use Craft; use craft\base\ElementInterface; -use craft\base\ExpirableElementInterface; use craft\db\Paginator; use craft\web\twig\variables\Paginate; use craft\web\View; @@ -108,23 +107,7 @@ public static function attribute(Environment $env, Source $source, mixed $object { // Include this element in any active caches if ($object instanceof ElementInterface) { - $elementsService = Craft::$app->getElements(); - if ($elementsService->getIsCollectingCacheInfo()) { - $class = get_class($object); - $elementsService->collectCacheTags([ - 'element', - "element::$class", - "element::$class::$object->id", - ]); - - // If the element is expirable, register its expiry date - if ( - $object instanceof ExpirableElementInterface && - ($expiryDate = $object->getExpiryDate()) !== null - ) { - $elementsService->setCacheExpiryDate($expiryDate); - } - } + Craft::$app->getElements()->collectCacheInfoForElement($object); } if ( diff --git a/src/helpers/Typecast.php b/src/helpers/Typecast.php index f5552021d03..5298043ab29 100644 --- a/src/helpers/Typecast.php +++ b/src/helpers/Typecast.php @@ -8,6 +8,7 @@ namespace craft\helpers; +use BackedEnum; use DateTime; use ReflectionException; use ReflectionNamedType; @@ -118,6 +119,15 @@ private static function property(string $class, string $property, mixed &$value) $value = $date ?: null; } return; + default: + if ( + is_scalar($value) && + interface_exists(BackedEnum::class) && + is_subclass_of($typeName, BackedEnum::class) + ) { + /** @var BackedEnum $typeName */ + $value = $typeName::from($value); + } } } diff --git a/src/helpers/UrlHelper.php b/src/helpers/UrlHelper.php index 28beceb3d0b..561025842f6 100644 --- a/src/helpers/UrlHelper.php +++ b/src/helpers/UrlHelper.php @@ -69,6 +69,7 @@ public static function isFullUrl(string $url): bool * @param array $params * @return string * @since 3.3.0 + * @deprecated in 4.5.0. `http_build_query()` should be used instead. */ public static function buildQuery(array $params): string { @@ -84,7 +85,6 @@ public static function buildQuery(array $params): string $params = []; foreach (explode('&', $query) as $param) { [$n, $v] = array_pad(explode('=', $param, 2), 2, ''); - $n = urldecode($n); $v = str_replace(['%2F', '%7B', '%7D'], ['/', '{', '}'], $v); $params[] = $v !== '' ? "$n=$v" : $n; } @@ -115,7 +115,7 @@ public static function urlWithParams(string $url, array|string $params): string $fragment = $fragment ?? $baseFragment; // Append to the base URL and return - if (($query = static::buildQuery($params)) !== '') { + if (($query = http_build_query($params)) !== '') { $url .= '?' . $query; } if ($fragment !== null) { @@ -141,7 +141,7 @@ public static function removeParam(string $url, string $param): string unset($params[$param]); // Rebuild - if (($query = static::buildQuery($params)) !== '') { + if (($query = http_build_query($params)) !== '') { $url .= '?' . $query; } if ($fragment !== null) { @@ -672,7 +672,7 @@ private static function _createUrl(string $path, array|string|null $params, ?str */ private static function _buildUrl(string $url, array $params, ?string $fragment): string { - if (($query = static::buildQuery($params)) !== '') { + if (($query = http_build_query($params)) !== '') { $url .= '?' . $query; } diff --git a/src/i18n/I18N.php b/src/i18n/I18N.php index 7eb130301c9..8f9e3c48f56 100644 --- a/src/i18n/I18N.php +++ b/src/i18n/I18N.php @@ -320,6 +320,17 @@ public function translate($category, $message, $params, $language): ?string return $translation; } + /** + * @inheritdoc + */ + public function format($message, $params, $language) + { + // wrap attribute value in an tag + array_walk($params, fn(&$val, $key) => ($key == 'attribute') ? $val = "*$val*" : $val); + + return parent::format($message, $params, $language); + } + /** * Returns whether [[translate()]] should wrap translations with `@` characters, * per the `translationDebugOutput` config setting. diff --git a/src/icons/file.svg b/src/icons/file.svg index 4a7569fbc87..75a76b3d42e 100644 --- a/src/icons/file.svg +++ b/src/icons/file.svg @@ -1,12 +1,12 @@ - + - - - + + + diff --git a/src/web/assets/cp/dist/images/folder.svg b/src/icons/folder.svg similarity index 90% rename from src/web/assets/cp/dist/images/folder.svg rename to src/icons/folder.svg index 7da743149da..53e95b4f688 100644 --- a/src/web/assets/cp/dist/images/folder.svg +++ b/src/icons/folder.svg @@ -1,5 +1,4 @@ -
diff --git a/src/templates/_components/utilities/AssetIndexes.twig b/src/templates/_components/utilities/AssetIndexes.twig index 440822ea0b3..5e7278811f3 100644 --- a/src/templates/_components/utilities/AssetIndexes.twig +++ b/src/templates/_components/utilities/AssetIndexes.twig @@ -23,13 +23,15 @@

{{ 'Options'|t('app') }}

- {{ forms.lightswitchField({ - name: 'cacheImages', - label: 'Cache remote images'|t('app'), - class: 'volume-selector', - instructions: 'Whether remotely-stored images should be downloaded and stored locally, to speed up transform generation.'|t('app'), - on: true, - }) }} + {% if not isEphemeral %} + {{ forms.lightswitchField({ + name: 'cacheImages', + label: 'Cache remote images'|t('app'), + class: 'volume-selector', + instructions: 'Whether remotely-stored images should be downloaded and stored locally, to speed up transform generation.'|t('app'), + on: true, + }) }} + {% endif %} {{ forms.lightswitchField({ name: 'listEmptyFolders', diff --git a/src/templates/_elements/sources.twig b/src/templates/_elements/sources.twig index 689fc61cd52..576eb3208b1 100644 --- a/src/templates/_elements/sources.twig +++ b/src/templates/_elements/sources.twig @@ -43,6 +43,7 @@ sites: (source.sites ?? false) ? source.sites|join(',') : false, 'override-status': (source.criteria.status ?? false) ? true : false, disabled: source.disabled ?? false, + 'default-filter': source.defaultFilter ?? false, }|merge(source.data ?? {}), html: _self.sourceInnerHtml(source) }) }} diff --git a/src/templates/_elements/tableview/elements.twig b/src/templates/_elements/tableview/elements.twig index bc198736acc..82050f63cb5 100644 --- a/src/templates/_elements/tableview/elements.twig +++ b/src/templates/_elements/tableview/elements.twig @@ -44,6 +44,19 @@ {%- include '_elements/element' with { autoReload: false, } -%} + + {%- if structure %} + {% set textAlternative = 'Level {num}'|t('app', { + num: element.level, + }) %} + {{ tag('span', { + class: 'visually-hidden', + data: { + 'text-alternative': true, + }, + text: textAlternative, + }) }} + {% endif %} {% else %} diff --git a/src/templates/_includes/forms.twig b/src/templates/_includes/forms.twig index ba7f0e8e153..35d39f1af81 100644 --- a/src/templates/_includes/forms.twig +++ b/src/templates/_includes/forms.twig @@ -314,7 +314,9 @@ {% macro autosuggestField(config) %} - {% set config = config|merge({id: config.id ?? "autosuggest#{random()}"}) %} + {% set config = config|merge({ + id: config.id ?? "autosuggest#{random()}", + }) %} {# Suggest an environment variable / alias? #} {% if (config.suggestEnvVars ?? false) %} diff --git a/src/templates/_includes/forms/autosuggest.twig b/src/templates/_includes/forms/autosuggest.twig index e8fbb1beec7..8c28152ed0b 100644 --- a/src/templates/_includes/forms/autosuggest.twig +++ b/src/templates/_includes/forms/autosuggest.twig @@ -30,6 +30,8 @@ @focus="updateFilteredOptions" @blur="onBlur" @input="onInputChange" + @opened="onOpened" + @closed="onClosed" v-model="inputProps.initialValue" >