diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md new file mode 100644 index 00000000000..b06cfaeaede --- /dev/null +++ b/CHANGELOG-WIP.md @@ -0,0 +1,171 @@ +# Release Notes for Craft CMS 5.9 (WIP) + +### Content Management +- Matrix fields using “Cards” or “Inline” view modes now show an “Add” button per entry type group, when the viewport is wide enough to support it. ([#17731](https://github.com/craftcms/cms/pull/17731)) +- Matrix fields set to the “Inline” view mode now have “Expand/collapse selected blocks” and “Copy selected blocks” field-level actions, if any blocks are selected. ([#18001](https://github.com/craftcms/cms/discussions/18001)) +- Matrix fields set to the “Inline” view mode now have block action menus with “Expand/Collapse”, “Entry type settings”, and “Copy” actions, even if the field isn’t editable. ([#18013](https://github.com/craftcms/cms/discussions/18013)) +- Chips and cards are generally no longer hyperlinked. ([#17591](https://github.com/craftcms/cms/pull/17591)) +- Entry revision menus now always include a “View all revisions” link. ([#18050](https://github.com/craftcms/cms/pull/18050)) +- Timestamps within entry revision menus now have tooltips that reveal the full date and time. ([#18050](https://github.com/craftcms/cms/pull/18050)) +- It’s now possible to add new sites to entries via their slideout editors. ([#17795](https://github.com/craftcms/cms/issues/17795)) +- Elements created via “Save as a new…” actions now initially have an empty slug. ([#17932](https://github.com/craftcms/cms/pull/17932)) +- The control panel is no longer scrollable when a menu is expanded. ([#17960](https://github.com/craftcms/cms/issues/17960)) +- Most site breadcrumbs no longer include selection menus if there’s only one selectable site. ([#16526](https://github.com/craftcms/cms/discussions/16526)) +- Number fields with “Step Size” and “Min Value” or “Max Value” settings will now get `min`/`max` attributes set on their input. ([#17973](https://github.com/craftcms/cms/pull/17973)) +- Element, field, and entry type edit pages now redirect back to the previous page’s URL on save. ([#16140](https://github.com/craftcms/cms/pull/16140)) +- Bulk element actions are now available on element indexes for mobile devices. +- Textual condition rules are now case-insensitive. ([#18107](https://github.com/craftcms/cms/issues/18107)) +- Added support for exporting elements as XLSX and YAML files. ([#18160](https://github.com/craftcms/cms/pull/18160)) + +### Accessibility +- Improved the accessibility of the Orientation setting within the Image Editor’s crop tool. ([#17690](https://github.com/craftcms/cms/pull/17690)) +- The Image Editor’s focal point tool is now keyboard accessible. ([#17880](https://github.com/craftcms/cms/pull/17880)) +- All sortable checkbox select options, selected Dashboard widgets, and site listings now have keyboard-accessible “Move up” and “Move down” action items. ([#18067](https://github.com/craftcms/cms/pull/18067)) + +### Administration +- It’s now possible to divide entry sources into multiple index pages, via the Customize Sources modal. ([#17779](https://github.com/craftcms/cms/pull/17779)) +- The Customize Sources modal now supports mobile devices. ([#18067](https://github.com/craftcms/cms/pull/18067)) +- Added the “UI Label Format” entry type setting. ([#18044](https://github.com/craftcms/cms/pull/18044)) +- Added the “View user” GraphQL schema option for Craft Solo. ([#17863](https://github.com/craftcms/cms/pull/17863)) +- Users’ User Groups settings now show a component select input, and support inline group editing/creation on environments that allow administrative changes. +- Address labels can now be made optional. ([#11410](https://github.com/craftcms/cms/discussions/11410)) +- Relational fields now have an “Inline list” view mode. ([#17744](https://github.com/craftcms/cms/pull/17744)) +- Relational fields and Matrix fields now have a “Card grid” view mode, replacing the “Show cards in a grid” setting. ([#17744](https://github.com/craftcms/cms/pull/17744)) +- Relational fields’ selectable element conditions can now have “Status” condition rules. ([#17945](https://github.com/craftcms/cms/discussions/17945)) +- Added the “Show ON/OFF labels in cards” setting to Lightswitch fields. ([#17743](https://github.com/craftcms/cms/discussions/17743)) +- Control panel-defined routes now have action menus with “Move up”/“Move down” actions. ([#17706](https://github.com/craftcms/cms/pull/17706)) +- “Generate image transform” jobs now include the asset’s filename in the job description. ([#17753](https://github.com/craftcms/cms/issues/17753)) +- “Field” and “Section” condition rules now show field/section handles for users with the “Show field handles in edit forms” preference enabled. ([#17909](https://github.com/craftcms/cms/pull/17909)) +- Native fields within element edit pages now have “Copy attribute name” actions. ([#18114](https://github.com/craftcms/cms/pull/18114)) +- “Remove” actions on the Plugins index page now show a confirmation dialog. ([#17922](https://github.com/craftcms/cms/pull/17922)) +- `entrify` commands no longer require a category group/tag group/global set handle to be passed. +- `entrify` commands now automatically assign newly-created channel/structure sections to “Categories” or “Tags” pages. ([#17779](https://github.com/craftcms/cms/pull/17779)) +- The `clear-cache` command now accepts a space-delimited list of cache IDs that should be cleared. +- Compiled templates are now deleted by the `up` command rather than from `migrate` commands. +- Added the `useIdnaNontransitionalToUnicode` config setting. ([#17946](https://github.com/craftcms/cms/pull/17946)) +- The `maxCachedCloudImageSize` config setting is now set to `0` by default. ([#17997](https://github.com/craftcms/cms/pull/17997)) +- System message emails are now rendered using GitHub-flavored Markdown. ([#18058](https://github.com/craftcms/cms/discussions/18058)) +- Drag-and-drop icons are now longer shown for devices that don’t support pointer events. ([#18067](https://github.com/craftcms/cms/pull/18067)) +- The Caches utility now keeps track of which options were previously selected. ([#9447](https://github.com/craftcms/cms/discussions/9447)) +- Field layouts can now set editability conditions on custom fields, based on the edited element. ([#18181](https://github.com/craftcms/cms/discussions/18181)) +- Element cards can now include fields nested within Content Block fields. ([#18206](https://github.com/craftcms/cms/pull/18206)) + +### Development +- Reference tags now support fallback values when no attribute is specified. ([#17688](https://github.com/craftcms/cms/pull/17688)) +- Added support for referencing environment variables anywhere within settings that support them (e.g. `foo/$ENV_NAME/bar` or `foo-${ENV_NAME}-bar`). ([#17794](https://github.com/craftcms/cms/pull/17794)) +- Environmental settings can now reference `CRAFT_SITE` (the current site’s handle) and `CRAFT_SITE_UPPER` (the current site’s handle in UPPER_SNAKE_CASE) environment variables, which are defined at runtime. ([#17794](https://github.com/craftcms/cms/pull/17794)) +- It’s now possible to create unpublished drafts via GraphQL. ([#17805](https://github.com/craftcms/cms/pull/17805)) +- Added the `randomString()` Twig function. ([#18020](https://github.com/craftcms/cms/discussions/18020)) +- Added the `uuid()` Twig function. +- The Twig `hash` filter now supports passing a hashing algorithm, such as `'md5'` or `'sha256'`. ([#17885](https://github.com/craftcms/cms/issues/17885)) + +### Extensibility +- Subnav items within the global control panel navigation can now have icons. ([#17879](https://github.com/craftcms/cms/pull/17879)) +- It’s now possible to modify the template path via `craft\web\View::EVENT_BEFORE_RENDER_TEMPLATE` and `EVENT_BEFORE_RENDER_PAGE_TEMPLATE`. ([#18125](https://github.com/craftcms/cms/issues/18125)) +- Added `Craft.BaseElementIndex::asyncSelectDefaultSource()`. +- Added `Craft.BaseElementIndex::asyncSelectSource()`. +- Added `Craft.BaseElementIndex::asyncSelectSourceByKey()`. +- Added `Craft.BaseElementIndex::ensureSourceAttributeInfo()`. +- Added `craft\base\ElementIndex::multiPageSources()`. ([#17779](https://github.com/craftcms/cms/pull/17779)) +- Added `craft\base\ElementTrait::$applyingDraft`. ([#18057](https://github.com/craftcms/cms/pull/18057)) +- Added `craft\base\ElementTrait::$hasProvisionalChanges`. ([#17915](https://github.com/craftcms/cms/pull/17915)) +- Added `craft\base\ElementTrait::$propagateRequired`. +- Added `craft\base\FieldInterface::propagateValue()`. +- Added `craft\elements\User::isInGroups()`. ([#17989](https://github.com/craftcms/cms/discussions/17989)) +- Added `craft\elements\conditions\HintableConditionRuleTrait`. ([#17909](https://github.com/craftcms/cms/pull/17909)) +- Added `craft\events\DefineFieldActionsEvent`. +- Added `craft\events\DefineGqlArgumentsEvent`. +- Added `craft\events\RegisterElementCardAttributesEvent::$fieldLayout`. ([#17920](https://github.com/craftcms/cms/pull/17920)) +- Added `craft\fieldlayoutelements\BaseField::EVENT_DEFINE_ACTION_MENU_ITEMS`. ([#18037](https://github.com/craftcms/cms/issues/18037)) +- Added `craft\fieldlayoutelements\BaseField::copyAttributeAction()`. ([#18114](https://github.com/craftcms/cms/pull/18114)) +- Added `craft\fieldlayoutelements\BaseField::getPreviewOptions()`. +- Added `craft\fieldlayoutelements\BaseField::key()`. +- Added `craft\fieldlayoutelements\CustomField::getElementEditCondition()`. +- Added `craft\fieldlayoutelements\CustomField::setElementEditCondition()`. +- Added `craft\fields\BaseRelationField::VIEW_MODE_CARDS_GRID`. +- Added `craft\fields\BaseRelationField::VIEW_MODE_CARDS`. +- Added `craft\fields\BaseRelationField::VIEW_MODE_LIST_INLINE`. +- Added `craft\fields\BaseRelationField::VIEW_MODE_LIST`. +- Added `craft\fields\BaseRelationField::VIEW_MODE_THUMBS`. +- Added `craft\fields\Matrix::VIEW_MODE_CARDS_GRID`. +- Added `craft\fields\data\LinkData::getAttributes()`. ([#18184](https://github.com/craftcms/cms/discussions/18184)) +- Added `craft\gql\base\ElementArguments::EVENT_DEFINE_ARGUMENTS`. ([#18062](https://github.com/craftcms/cms/discussions/18062)) +- Added `craft\helpers\Assets::resolveSubpath()`. ([#18103](https://github.com/craftcms/cms/pull/18103)) +- Added `craft\helpers\Cp::cardPreviewOptions()`. +- Added `craft\helpers\ElementHelper::loadProvisionalChanges()`. ([#17915](https://github.com/craftcms/cms/pull/17915)) +- Added `craft\helpers\UrlHelper::cpReferralUrl()`. +- Added `craft\models\EntryType::$uiLabelFormat`. +- Added `craft\models\FieldLayout::$thumbFieldKey`. +- Added `craft\models\FieldLayout::getCardBodyHtmlForElement()`. +- Added `craft\models\FieldLayout::getElementByKey()`. +- Added `craft\models\Section::getCpIndexUri()`. +- Added `craft\models\Section::getPage()`. +- Added `craft\services\ElementSources::getFirstPage()`. ([#17779](https://github.com/craftcms/cms/pull/17779)) +- Added `craft\services\ElementSources::getPageSettings()`. ([#17779](https://github.com/craftcms/cms/pull/17779)) +- Added `craft\services\ElementSources::getPages()`. ([#17779](https://github.com/craftcms/cms/pull/17779)) +- Added `craft\services\ElementSources::pageExists()`. ([#17779](https://github.com/craftcms/cms/pull/17779)) +- Added `craft\services\ElementSources::pageNameId()`. ([#17779](https://github.com/craftcms/cms/pull/17779)) +- Added `craft\services\Search::deleteOrphanedIndexJobs()`. +- Added `craft\services\Structure::EVENT_AFTER_UPDATE_ELEMENT`. +- Added `craft\services\Structure::EVENT_BEFORE_UPDATE_ELEMENT`. +- Added `craft\web\BaseSpreadsheetResponseFormatter`. +- Added `craft\web\GqlResponseFormatter`. +- Added `craft\web\Request::getHasInvalidToken()`. +- Added `craft\web\Response::FORMAT_GQL`. +- Added `craft\web\Response::FORMAT_XLSX`. +- Added `craft\web\Response::FORMAT_YAML`. +- Added `craft\web\XlsxResponseFormatter`. +- Added `craft\web\YamlResponseFormatter`. +- Added `craft\web\twig\nodes\BaseNode`. +- `craft\base\Element::EVENT_AFTER_MOVE_IN_STRUCTURE` is no longer deprecated. +- `craft\base\Element::EVENT_BEFORE_MOVE_IN_STRUCTURE` is no longer deprecated. +- `craft\base\ElementInterface::afterMoveInStructure()` is no longer deprecated. +- `craft\base\ElementInterface::beforeMoveInStructure()` is no longer deprecated. +- `craft\base\ElementInterface::cardAttributes()` now has a `$fieldLayout` argument. ([#17920](https://github.com/craftcms/cms/pull/17920)) +- `craft\events\ElementStructureEvent` is no longer deprecated. +- `craft\fieldlayoutelements\CustomField::editable()` now has an `$element` argument. +- `craft\helpers\ElementHelper::findSource()` now has `$withDisabled` and `$page` arguments. +- `craft\helpers\FileHelper::writeToFile()` now throws an exception if the file path isn’t writable, or there isn’t sufficient free space on the disk. ([#17762](https://github.com/craftcms/cms/pull/17762)) +- `craft\helpers\UrlHelper` now encodes square brackets in generated URLs. ([#17840](https://github.com/craftcms/cms/pull/17840)) +- `craft\models\FieldLayout::getCardBodyElements()` now always returns an array of arrays with `html` keys. +- `craft\services\ElementSources::getSources()` now has a `$page` argument. ([#17779](https://github.com/craftcms/cms/pull/17779)) +- `craft\services\ElementSources::sourceExists()` now has a `$page` argument. ([#17779](https://github.com/craftcms/cms/pull/17779)) +- `craft\web\Request::accepts()` now accepts wildcard characters (`*`) in the `$contentType` argument, to check for a range of MIME types (e.g. `application/*+json`). +- `craft\web\Request::getAcceptsJson()` now returns `true` for requests with `Content-Type` headers that match `application/*+json`, in addition to `application/json`. +- Checkbox selects can now be configured with a `storageKey` setting. +- Deprecated `craft\fieldlayoutelements\BaseField::$includeInCards`. +- Deprecated `craft\fieldlayoutelements\BaseField::$providesThumbs`. +- Deprecated `craft\fields\BaseRelationField::$showCardsInGrid`. +- Deprecated `craft\fields\Matrix::$showCardsInGrid`. +- Deprecated `craft\helpers\StringHelper::capitalizePersonalName()`. `toPascalCase()` should be used instead. +- Deprecated `craft\helpers\StringHelper::isWhitespace()`. `isBlank()` should be used instead. +- Deprecated `craft\helpers\StringHelper::upperCamelize()`. `toPascalCase()` should be used instead. +- Deprecated `craft\models\FieldLayout::getCardBodyAttributes()`. +- Deprecated `craft\models\FieldLayout::getCardBodyFields()`. +- Deprecated `craft\services\Structure::EVENT_AFTER_MOVE_ELEMENT`. `EVENT_AFTER_UPDATE_ELEMENT` should be used instead. +- Deprecated `craft\services\Structure::EVENT_BEFORE_MOVE_ELEMENT`. `EVENT_BEFORE_UPDATE_ELEMENT` should be used instead. +- Deprecated `craft\web\CsvResponseResponseFormatter::$escapeChar`. +- Deprecated `Craft.BaseElementIndex::selectDefaultSource()`. +- Deprecated `Craft.BaseElementIndex::selectSource()`. +- Deprecated `Craft.BaseElementIndex::selectSourceByKey()`. +- Deprecated the `$cardElements` argument in `craft\helpers\Cp::cardPreviewHtml()`. +- Deprecated the `$cardElements` argument in `craft\models\FieldLayout::getCardBodyElements()`. + +### System +- GraphQL API responses now set their `Content-Type` header to `application/graphql-response+json`. +- GraphQL API responses now set cache headers based on whether a mutation was performed, regardless of the request type. +- Global set queries no longer register cache tags. +- Improved element index performance. ([#17557](https://github.com/craftcms/cms/pull/17557)) +- Improved element query performance. ([#17850](https://github.com/craftcms/cms/pull/17850)) +- Reduced the number of queries executed when working with nested entries, addresses, and content blocks. ([#18142](https://github.com/craftcms/cms/issues/18142)) +- Session-based cookies no longer use colons (`:`) in their names. ([#18158](https://github.com/craftcms/cms/pull/18158)) +- Fixed a bug where elements with unsaved changes could show outdated attribute/field values within element index tables, chips, and cards throughout the control panel. ([#17915](https://github.com/craftcms/cms/pull/17915)) +- Fixed a bug where Table fields with the “Static Rows” setting enabled would lose track of which values belonged to which row headings, if the “Default Values” table was reordered. ([#17090](https://github.com/craftcms/cms/issues/17090)) +- Fixed a bug where requests with invalid tokens would throw an exception before the application was fully initialized, which could lead to other errors. ([#18000](https://github.com/craftcms/cms/issues/18000)) +- Fixed a bug where titles, slugs, and required custom field values weren’t always getting propagated to other sites when creating a new element. ([#17955](https://github.com/craftcms/cms/issues/17955)) +- Fixed a bug where it was possible to create more than five users with the Team edition. +- Fixed a bug where deadlocks could occur when updating elements’ search indexes. ([#18139](https://github.com/craftcms/cms/pull/18139)) +- Added the Illuminate Support library. +- Added the PhpSpreadsheet library. +- Updated Twig to 3.21. ([#17603](https://github.com/craftcms/cms/discussions/17603)) +- Removed the Stringy library. ([#16606](https://github.com/craftcms/cms/issues/16606)) diff --git a/composer.json b/composer.json index 7ee8be23978..37ba295d4a8 100644 --- a/composer.json +++ b/composer.json @@ -44,11 +44,13 @@ "enshrined/svg-sanitize": "~0.22.0", "guzzlehttp/guzzle": "^7.2.0", "illuminate/collections": "^v10.42.0", + "illuminate/support": "^10.49", "league/uri": "^7.0", "mikehaertl/php-shellcommand": "^1.6.3", "moneyphp/money": "^4.0", "monolog/monolog": "^3.0", "phpdocumentor/reflection-docblock": "^5.3", + "phpoffice/phpspreadsheet": "^5.3", "pixelandtonic/imagine": "~1.3.3.1", "pragmarx/google2fa": "^8.0", "pragmarx/recovery": "^0.2.1", @@ -64,8 +66,8 @@ "symfony/var-dumper": "^5.0|^6.0|^7.0", "symfony/yaml": "^5.2.3|^6.0|^7.0", "theiconic/name-parser": "^1.2", - "twig/twig": "~3.15.0", - "voku/stringy": "^6.4.0", + "twig/twig": "~3.21.1", + "voku/portable-ascii": "^2.0", "web-auth/webauthn-lib": "~4.9.0", "webonyx/graphql-php": "~14.11.10", "yiisoft/yii2": "~2.0.52.0", diff --git a/composer.lock b/composer.lock index 646d3568666..910c8aa7f5f 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": "624a3ef2bc60f00422f372db1b1c14dd", + "content-hash": "2a13256bb63107558907255a1f639ee5", "packages": [ { "name": "bacon/bacon-qr-code", @@ -120,6 +120,75 @@ ], "time": "2025-03-29T13:50:30+00:00" }, + { + "name": "carbonphp/carbon-doctrine-types", + "version": "3.2.0", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon-doctrine-types.git", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon-doctrine-types/zipball/18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "reference": "18ba5ddfec8976260ead6e866180bd5d2f71aa1d", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "conflict": { + "doctrine/dbal": "<4.0.0 || >=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.0" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2024-02-09T16:56:22+00:00" + }, { "name": "cebe/markdown", "version": "1.2.1", @@ -248,6 +317,85 @@ }, "time": "2025-01-13T16:03:24+00:00" }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "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": "2024-11-12T16:29:46+00:00" + }, { "name": "composer/semver", "version": "3.4.4", @@ -514,73 +662,6 @@ }, "time": "2025-09-16T12:23:56+00:00" }, - { - "name": "defuse/php-encryption", - "version": "v2.4.0", - "source": { - "type": "git", - "url": "https://github.com/defuse/php-encryption.git", - "reference": "f53396c2d34225064647a05ca76c1da9d99e5828" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/defuse/php-encryption/zipball/f53396c2d34225064647a05ca76c1da9d99e5828", - "reference": "f53396c2d34225064647a05ca76c1da9d99e5828", - "shasum": "" - }, - "require": { - "ext-openssl": "*", - "paragonie/random_compat": ">= 2", - "php": ">=5.6.0" - }, - "require-dev": { - "phpunit/phpunit": "^5|^6|^7|^8|^9|^10", - "yoast/phpunit-polyfills": "^2.0.0" - }, - "bin": [ - "bin/generate-defuse-key" - ], - "type": "library", - "autoload": { - "psr-4": { - "Defuse\\Crypto\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Taylor Hornby", - "email": "taylor@defuse.ca", - "homepage": "https://defuse.ca/" - }, - { - "name": "Scott Arciszewski", - "email": "info@paragonie.com", - "homepage": "https://paragonie.com" - } - ], - "description": "Secure PHP Encryption Library", - "keywords": [ - "aes", - "authenticated encryption", - "cipher", - "crypto", - "cryptography", - "encrypt", - "encryption", - "openssl", - "security", - "symmetric key cryptography" - ], - "support": { - "issues": "https://github.com/defuse/php-encryption/issues", - "source": "https://github.com/defuse/php-encryption/tree/v2.4.0" - }, - "time": "2023-06-19T06:10:36+00:00" - }, { "name": "doctrine/collections", "version": "2.4.0", @@ -715,6 +796,96 @@ }, "time": "2025-04-07T20:06:18+00:00" }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, { "name": "doctrine/lexer", "version": "3.0.1", @@ -1529,40 +1700,56 @@ "time": "2023-06-05T12:46:42+00:00" }, { - "name": "lcobucci/clock", - "version": "3.3.1", + "name": "illuminate/support", + "version": "v10.49.0", "source": { "type": "git", - "url": "https://github.com/lcobucci/clock.git", - "reference": "db3713a61addfffd615b79bf0bc22f0ccc61b86b" + "url": "https://github.com/illuminate/support.git", + "reference": "28b505e671dbe119e4e32a75c78f87189d046e39" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/lcobucci/clock/zipball/db3713a61addfffd615b79bf0bc22f0ccc61b86b", - "reference": "db3713a61addfffd615b79bf0bc22f0ccc61b86b", + "url": "https://api.github.com/repos/illuminate/support/zipball/28b505e671dbe119e4e32a75c78f87189d046e39", + "reference": "28b505e671dbe119e4e32a75c78f87189d046e39", "shasum": "" }, "require": { - "php": "~8.2.0 || ~8.3.0 || ~8.4.0", - "psr/clock": "^1.0" + "doctrine/inflector": "^2.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-mbstring": "*", + "illuminate/collections": "^10.0", + "illuminate/conditionable": "^10.0", + "illuminate/contracts": "^10.0", + "illuminate/macroable": "^10.0", + "nesbot/carbon": "^2.67", + "php": "^8.1", + "voku/portable-ascii": "^2.0" }, - "provide": { - "psr/clock-implementation": "1.0" + "conflict": { + "tightenco/collect": "<5.5.33" }, - "require-dev": { - "infection/infection": "^0.29", - "lcobucci/coding-standard": "^11.1.0", - "phpstan/extension-installer": "^1.3.1", - "phpstan/phpstan": "^1.10.25", - "phpstan/phpstan-deprecation-rules": "^1.1.3", - "phpstan/phpstan-phpunit": "^1.3.13", - "phpstan/phpstan-strict-rules": "^1.5.1", - "phpunit/phpunit": "^11.3.6" + "suggest": { + "illuminate/filesystem": "Required to use the composer class (^10.0).", + "league/commonmark": "Required to use Str::markdown() and Stringable::markdown() (^2.6).", + "ramsey/uuid": "Required to use Str::uuid() (^4.7).", + "symfony/process": "Required to use the composer class (^6.2).", + "symfony/uid": "Required to use Str::ulid() (^6.2).", + "symfony/var-dumper": "Required to use the dd function (^6.2).", + "vlucas/phpdotenv": "Required to use the Env class and env helper (^5.4.1)." }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "10.x-dev" + } + }, "autoload": { + "files": [ + "helpers.php" + ], "psr-4": { - "Lcobucci\\Clock\\": "src" + "Illuminate\\Support\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -1571,18 +1758,73 @@ ], "authors": [ { - "name": "Luís Cobucci", - "email": "lcobucci@gmail.com" + "name": "Taylor Otwell", + "email": "taylor@laravel.com" } ], - "description": "Yet another clock abstraction", + "description": "The Illuminate Support package.", + "homepage": "https://laravel.com", "support": { - "issues": "https://github.com/lcobucci/clock/issues", - "source": "https://github.com/lcobucci/clock/tree/3.3.1" + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" }, - "funding": [ - { - "url": "https://github.com/lcobucci", + "time": "2025-09-08T19:05:53+00:00" + }, + { + "name": "lcobucci/clock", + "version": "3.3.1", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/clock.git", + "reference": "db3713a61addfffd615b79bf0bc22f0ccc61b86b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/clock/zipball/db3713a61addfffd615b79bf0bc22f0ccc61b86b", + "reference": "db3713a61addfffd615b79bf0bc22f0ccc61b86b", + "shasum": "" + }, + "require": { + "php": "~8.2.0 || ~8.3.0 || ~8.4.0", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "infection/infection": "^0.29", + "lcobucci/coding-standard": "^11.1.0", + "phpstan/extension-installer": "^1.3.1", + "phpstan/phpstan": "^1.10.25", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.13", + "phpstan/phpstan-strict-rules": "^1.5.1", + "phpunit/phpunit": "^11.3.6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\Clock\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com" + } + ], + "description": "Yet another clock abstraction", + "support": { + "issues": "https://github.com/lcobucci/clock/issues", + "source": "https://github.com/lcobucci/clock/tree/3.3.1" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", "type": "github" }, { @@ -1774,6 +2016,191 @@ ], "time": "2025-11-18T12:17:23+00:00" }, + { + "name": "maennchen/zipstream-php", + "version": "3.1.2", + "source": { + "type": "git", + "url": "https://github.com/maennchen/ZipStream-PHP.git", + "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/aeadcf5c412332eb426c0f9b4485f6accba2a99f", + "reference": "aeadcf5c412332eb426c0f9b4485f6accba2a99f", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "ext-zlib": "*", + "php-64bit": "^8.2" + }, + "require-dev": { + "brianium/paratest": "^7.7", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.16", + "guzzlehttp/guzzle": "^7.5", + "mikey179/vfsstream": "^1.6", + "php-coveralls/php-coveralls": "^2.5", + "phpunit/phpunit": "^11.0", + "vimeo/psalm": "^6.0" + }, + "suggest": { + "guzzlehttp/psr7": "^2.4", + "psr/http-message": "^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "ZipStream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Paul Duncan", + "email": "pabs@pablotron.org" + }, + { + "name": "Jonatan Männchen", + "email": "jonatan@maennchen.ch" + }, + { + "name": "Jesse Donat", + "email": "donatj@gmail.com" + }, + { + "name": "András Kolesár", + "email": "kolesar@kolesar.hu" + } + ], + "description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.", + "keywords": [ + "stream", + "zip" + ], + "support": { + "issues": "https://github.com/maennchen/ZipStream-PHP/issues", + "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.1.2" + }, + "funding": [ + { + "url": "https://github.com/maennchen", + "type": "github" + } + ], + "time": "2025-01-27T12:07:53+00:00" + }, + { + "name": "markbaker/complex", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPComplex.git", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Complex\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@lange.demon.co.uk" + } + ], + "description": "PHP Class for working with complex numbers", + "homepage": "https://github.com/MarkBaker/PHPComplex", + "keywords": [ + "complex", + "mathematics" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPComplex/issues", + "source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2" + }, + "time": "2022-12-06T16:21:08+00:00" + }, + { + "name": "markbaker/matrix", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/MarkBaker/PHPMatrix.git", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c", + "reference": "728434227fe21be27ff6d86621a1b13107a2562c", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-master", + "phpcompatibility/php-compatibility": "^9.3", + "phpdocumentor/phpdocumentor": "2.*", + "phploc/phploc": "^4.0", + "phpmd/phpmd": "2.*", + "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "sebastian/phpcpd": "^4.0", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Matrix\\": "classes/src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mark Baker", + "email": "mark@demon-angel.eu" + } + ], + "description": "PHP Class for working with matrices", + "homepage": "https://github.com/MarkBaker/PHPMatrix", + "keywords": [ + "mathematics", + "matrix", + "vector" + ], + "support": { + "issues": "https://github.com/MarkBaker/PHPMatrix/issues", + "source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1" + }, + "time": "2022-12-02T22:17:43+00:00" + }, { "name": "masterminds/html5", "version": "2.10.0", @@ -2080,6 +2507,113 @@ ], "time": "2025-03-24T10:02:05+00:00" }, + { + "name": "nesbot/carbon", + "version": "2.73.0", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "9228ce90e1035ff2f0db84b40ec2e023ed802075" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/9228ce90e1035ff2f0db84b40ec2e023ed802075", + "reference": "9228ce90e1035ff2f0db84b40ec2e023ed802075", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "*", + "ext-json": "*", + "php": "^7.1.8 || ^8.0", + "psr/clock": "^1.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php80": "^1.16", + "symfony/translation": "^3.4 || ^4.0 || ^5.0 || ^6.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^2.0 || ^3.1.4 || ^4.0", + "doctrine/orm": "^2.7 || ^3.0", + "friendsofphp/php-cs-fixer": "^3.0", + "kylekatarnls/multi-tester": "^2.0", + "ondrejmirtes/better-reflection": "<6", + "phpmd/phpmd": "^2.9", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^0.12.99 || ^1.7.14", + "phpunit/php-file-iterator": "^2.0.5 || ^3.0.6", + "phpunit/phpunit": "^7.5.20 || ^8.5.26 || ^9.5.20", + "squizlabs/php_codesniffer": "^3.4" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbon.nesbot.com", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbon.nesbot.com/docs", + "issues": "https://github.com/briannesbitt/Carbon/issues", + "source": "https://github.com/briannesbitt/Carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2025-01-08T20:10:23+00:00" + }, { "name": "paragonie/constant_time_encoding", "version": "v3.1.3", @@ -2150,95 +2684,45 @@ "time": "2025-09-24T15:06:41+00:00" }, { - "name": "paragonie/random_compat", - "version": "v9.99.100", + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", "source": { "type": "git", - "url": "https://github.com/paragonie/random_compat.git", - "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", - "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", "shasum": "" }, "require": { - "php": ">= 7" + "php": "^7.2 || ^8.0" }, - "require-dev": { - "phpunit/phpunit": "4.*|5.*", - "vimeo/psalm": "^1" + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } }, - "suggest": { - "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } }, - "type": "library", "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], "authors": [ { - "name": "Paragon Initiative Enterprises", - "email": "security@paragonie.com", - "homepage": "https://paragonie.com" + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" } ], - "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", - "keywords": [ - "csprng", - "polyfill", - "pseudorandom", - "random" - ], - "support": { - "email": "info@paragonie.com", - "issues": "https://github.com/paragonie/random_compat/issues", - "source": "https://github.com/paragonie/random_compat" - }, - "time": "2020-10-15T08:29:30+00:00" - }, - { - "name": "phpdocumentor/reflection-common", - "version": "2.2.0", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/ReflectionCommon.git", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-2.x": "2.x-dev" - } - }, - "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jaap van Otterdijk", - "email": "opensource@ijaap.nl" - } - ], - "description": "Common reflection classes used by phpdocumentor to reflect the code structure", - "homepage": "http://www.phpdoc.org", + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", "keywords": [ "FQSEN", "phpDocumentor", @@ -2374,6 +2858,112 @@ }, "time": "2025-11-21T15:09:14+00:00" }, + { + "name": "phpoffice/phpspreadsheet", + "version": "5.3.0", + "source": { + "type": "git", + "url": "https://github.com/PHPOffice/PhpSpreadsheet.git", + "reference": "4d597c1aacdde1805a33c525b9758113ea0d90df" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/4d597c1aacdde1805a33c525b9758113ea0d90df", + "reference": "4d597c1aacdde1805a33c525b9758113ea0d90df", + "shasum": "" + }, + "require": { + "composer/pcre": "^1||^2||^3", + "ext-ctype": "*", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-gd": "*", + "ext-iconv": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-simplexml": "*", + "ext-xml": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "ext-zip": "*", + "ext-zlib": "*", + "maennchen/zipstream-php": "^2.1 || ^3.0", + "markbaker/complex": "^3.0", + "markbaker/matrix": "^3.0", + "php": "^8.1", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "dev-main", + "dompdf/dompdf": "^2.0 || ^3.0", + "friendsofphp/php-cs-fixer": "^3.2", + "mitoteam/jpgraph": "^10.5", + "mpdf/mpdf": "^8.1.1", + "phpcompatibility/php-compatibility": "^9.3", + "phpstan/phpstan": "^1.1 || ^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0", + "phpstan/phpstan-phpunit": "^1.0 || ^2.0", + "phpunit/phpunit": "^10.5", + "squizlabs/php_codesniffer": "^3.7", + "tecnickcom/tcpdf": "^6.5" + }, + "suggest": { + "dompdf/dompdf": "Option for rendering PDF with PDF Writer", + "ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard", + "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers", + "mpdf/mpdf": "Option for rendering PDF with PDF Writer", + "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer" + }, + "type": "library", + "autoload": { + "psr-4": { + "PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Maarten Balliauw", + "homepage": "https://blog.maartenballiauw.be" + }, + { + "name": "Mark Baker", + "homepage": "https://markbakeruk.net" + }, + { + "name": "Franck Lefevre", + "homepage": "https://rootslabs.net" + }, + { + "name": "Erik Tilt" + }, + { + "name": "Adrien Crivelli" + } + ], + "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine", + "homepage": "https://github.com/PHPOffice/PhpSpreadsheet", + "keywords": [ + "OpenXML", + "excel", + "gnumeric", + "ods", + "php", + "spreadsheet", + "xls", + "xlsx" + ], + "support": { + "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues", + "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.3.0" + }, + "time": "2025-11-24T15:47:10+00:00" + }, { "name": "phpstan/phpdoc-parser", "version": "2.3.0", @@ -4295,90 +4885,6 @@ ], "time": "2024-09-09T11:45:10+00:00" }, - { - "name": "symfony/polyfill-iconv", - "version": "v1.33.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-iconv.git", - "reference": "5f3b930437ae03ae5dff61269024d8ea1b3774aa" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/5f3b930437ae03ae5dff61269024d8ea1b3774aa", - "reference": "5f3b930437ae03ae5dff61269024d8ea1b3774aa", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "provide": { - "ext-iconv": "*" - }, - "suggest": { - "ext-iconv": "For best performance" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Iconv\\": "" - } - }, - "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 for the Iconv extension", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "iconv", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-iconv/tree/v1.33.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-17T14:58:18+00:00" - }, { "name": "symfony/polyfill-intl-grapheme", "version": "v1.33.0", @@ -4719,82 +5225,17 @@ "time": "2024-12-23T08:48:59+00:00" }, { - "name": "symfony/polyfill-php72", - "version": "v1.31.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php72.git", - "reference": "fa2ae56c44f03bed91a39bfc9822e31e7c5c38ce" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php72/zipball/fa2ae56c44f03bed91a39bfc9822e31e7c5c38ce", - "reference": "fa2ae56c44f03bed91a39bfc9822e31e7c5c38ce", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "type": "metapackage", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "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 7.2+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php72/tree/v1.31.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": "2024-09-09T11:45:10+00:00" - }, - { - "name": "symfony/polyfill-php81", + "name": "symfony/polyfill-php80", "version": "v1.33.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php81.git", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", + "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", "shasum": "" }, "require": { @@ -4812,7 +5253,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php81\\": "" + "Symfony\\Polyfill\\Php80\\": "" }, "classmap": [ "Resources/stubs" @@ -4823,6 +5264,10 @@ "MIT" ], "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, { "name": "Nicolas Grekas", "email": "p@tchwork.com" @@ -4832,7 +5277,7 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", "homepage": "https://symfony.com", "keywords": [ "compatibility", @@ -4841,7 +5286,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" }, "funding": [ { @@ -4861,7 +5306,7 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2025-01-02T08:10:11+00:00" }, { "name": "symfony/polyfill-php83", @@ -5623,34 +6068,60 @@ "time": "2025-11-27T13:27:24+00:00" }, { - "name": "symfony/type-info", - "version": "v7.4.0", + "name": "symfony/translation", + "version": "v6.4.30", "source": { "type": "git", - "url": "https://github.com/symfony/type-info.git", - "reference": "7f9743e921abcce92a03fc693530209c59e73076" + "url": "https://github.com/symfony/translation.git", + "reference": "d1fdeefd0707d15eb150c04e8837bf0b15ebea39" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/type-info/zipball/7f9743e921abcce92a03fc693530209c59e73076", - "reference": "7f9743e921abcce92a03fc693530209c59e73076", + "url": "https://api.github.com/repos/symfony/translation/zipball/d1fdeefd0707d15eb150c04e8837bf0b15ebea39", + "reference": "d1fdeefd0707d15eb150c04e8837bf0b15ebea39", "shasum": "" }, "require": { - "php": ">=8.2", - "psr/container": "^1.1|^2.0", - "symfony/deprecation-contracts": "^2.5|^3" + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0", + "symfony/translation-contracts": "^2.5|^3.0" }, "conflict": { - "phpstan/phpdoc-parser": "<1.30" - }, - "require-dev": { - "phpstan/phpdoc-parser": "^1.30|^2.0" + "symfony/config": "<5.4", + "symfony/console": "<5.4", + "symfony/dependency-injection": "<5.4", + "symfony/http-client-contracts": "<2.5", + "symfony/http-kernel": "<5.4", + "symfony/service-contracts": "<2.5", + "symfony/twig-bundle": "<5.4", + "symfony/yaml": "<5.4" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^4.18|^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^5.4|^6.0|^7.0", + "symfony/console": "^5.4|^6.0|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/finder": "^5.4|^6.0|^7.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^5.4|^6.0|^7.0", + "symfony/intl": "^5.4|^6.0|^7.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^5.4|^6.0|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^5.4|^6.0|^7.0" }, "type": "library", "autoload": { + "files": [ + "Resources/functions.php" + ], "psr-4": { - "Symfony\\Component\\TypeInfo\\": "" + "Symfony\\Component\\Translation\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -5662,28 +6133,18 @@ ], "authors": [ { - "name": "Mathias Arlaud", - "email": "mathias.arlaud@gmail.com" - }, - { - "name": "Baptiste LEDUC", - "email": "baptiste.leduc@gmail.com" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Extracts PHP types information.", + "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", - "keywords": [ - "PHPStan", - "phpdoc", - "symfony", - "type" - ], "support": { - "source": "https://github.com/symfony/type-info/tree/v7.4.0" + "source": "https://github.com/symfony/translation/tree/v6.4.30" }, "funding": [ { @@ -5703,36 +6164,41 @@ "type": "tidelift" } ], - "time": "2025-11-07T09:36:46+00:00" + "time": "2025-11-24T13:57:00+00:00" }, { - "name": "symfony/uid", - "version": "v7.4.0", + "name": "symfony/translation-contracts", + "version": "v3.6.1", "source": { "type": "git", - "url": "https://github.com/symfony/uid.git", - "reference": "2498e9f81b7baa206f44de583f2f48350b90142c" + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "65a8bc82080447fae78373aa10f8d13b38338977" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/2498e9f81b7baa206f44de583f2f48350b90142c", - "reference": "2498e9f81b7baa206f44de583f2f48350b90142c", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/65a8bc82080447fae78373aa10f8d13b38338977", + "reference": "65a8bc82080447fae78373aa10f8d13b38338977", "shasum": "" }, "require": { - "php": ">=8.2", - "symfony/polyfill-uuid": "^1.15" - }, - "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0" + "php": ">=8.1" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, "autoload": { "psr-4": { - "Symfony\\Component\\Uid\\": "" + "Symfony\\Contracts\\Translation\\": "" }, "exclude-from-classmap": [ - "/Tests/" + "/Test/" ] }, "notification-url": "https://packagist.org/downloads/", @@ -5740,10 +6206,6 @@ "MIT" ], "authors": [ - { - "name": "Grégoire Pineau", - "email": "lyrixx@lyrixx.info" - }, { "name": "Nicolas Grekas", "email": "p@tchwork.com" @@ -5753,15 +6215,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Provides an object-oriented API to generate and represent UIDs", + "description": "Generic abstractions related to translation", "homepage": "https://symfony.com", "keywords": [ - "UID", - "ulid", - "uuid" + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.0" + "source": "https://github.com/symfony/translation-contracts/tree/v3.6.1" }, "funding": [ { @@ -5781,47 +6246,37 @@ "type": "tidelift" } ], - "time": "2025-09-25T11:02:55+00:00" + "time": "2025-07-15T13:41:35+00:00" }, { - "name": "symfony/var-dumper", + "name": "symfony/type-info", "version": "v7.4.0", "source": { "type": "git", - "url": "https://github.com/symfony/var-dumper.git", - "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece" + "url": "https://github.com/symfony/type-info.git", + "reference": "7f9743e921abcce92a03fc693530209c59e73076" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/41fd6c4ae28c38b294b42af6db61446594a0dece", - "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece", + "url": "https://api.github.com/repos/symfony/type-info/zipball/7f9743e921abcce92a03fc693530209c59e73076", + "reference": "7f9743e921abcce92a03fc693530209c59e73076", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-mbstring": "~1.0" + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" }, "conflict": { - "symfony/console": "<6.4" + "phpstan/phpdoc-parser": "<1.30" }, "require-dev": { - "symfony/console": "^6.4|^7.0|^8.0", - "symfony/http-kernel": "^6.4|^7.0|^8.0", - "symfony/process": "^6.4|^7.0|^8.0", - "symfony/uid": "^6.4|^7.0|^8.0", - "twig/twig": "^3.12" + "phpstan/phpdoc-parser": "^1.30|^2.0" }, - "bin": [ - "Resources/bin/var-dump-server" - ], "type": "library", "autoload": { - "files": [ - "Resources/functions/dump.php" - ], "psr-4": { - "Symfony\\Component\\VarDumper\\": "" + "Symfony\\Component\\TypeInfo\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -5833,22 +6288,28 @@ ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "description": "Extracts PHP types information.", "homepage": "https://symfony.com", "keywords": [ - "debug", - "dump" + "PHPStan", + "phpdoc", + "symfony", + "type" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.0" + "source": "https://github.com/symfony/type-info/tree/v7.4.0" }, "funding": [ { @@ -5868,40 +6329,33 @@ "type": "tidelift" } ], - "time": "2025-10-27T20:36:44+00:00" + "time": "2025-11-07T09:36:46+00:00" }, { - "name": "symfony/yaml", + "name": "symfony/uid", "version": "v7.4.0", "source": { "type": "git", - "url": "https://github.com/symfony/yaml.git", - "reference": "6c84a4b55aee4cd02034d1c528e83f69ddf63810" + "url": "https://github.com/symfony/uid.git", + "reference": "2498e9f81b7baa206f44de583f2f48350b90142c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/6c84a4b55aee4cd02034d1c528e83f69ddf63810", - "reference": "6c84a4b55aee4cd02034d1c528e83f69ddf63810", + "url": "https://api.github.com/repos/symfony/uid/zipball/2498e9f81b7baa206f44de583f2f48350b90142c", + "reference": "2498e9f81b7baa206f44de583f2f48350b90142c", "shasum": "" }, "require": { "php": ">=8.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-ctype": "^1.8" - }, - "conflict": { - "symfony/console": "<6.4" + "symfony/polyfill-uuid": "^1.15" }, "require-dev": { "symfony/console": "^6.4|^7.0|^8.0" }, - "bin": [ - "Resources/bin/yaml-lint" - ], "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Yaml\\": "" + "Symfony\\Component\\Uid\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -5913,18 +6367,27 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Loads and dumps YAML files", + "description": "Provides an object-oriented API to generate and represent UIDs", "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], "support": { - "source": "https://github.com/symfony/yaml/tree/v7.4.0" + "source": "https://github.com/symfony/uid/tree/v7.4.0" }, "funding": [ { @@ -5944,38 +6407,51 @@ "type": "tidelift" } ], - "time": "2025-11-16T10:14:42+00:00" + "time": "2025-09-25T11:02:55+00:00" }, { - "name": "theiconic/name-parser", - "version": "v1.2.11", + "name": "symfony/var-dumper", + "version": "v7.4.0", "source": { "type": "git", - "url": "https://github.com/theiconic/name-parser.git", - "reference": "9a54a713bf5b2e7fd990828147d42de16bf8a253" + "url": "https://github.com/symfony/var-dumper.git", + "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theiconic/name-parser/zipball/9a54a713bf5b2e7fd990828147d42de16bf8a253", - "reference": "9a54a713bf5b2e7fd990828147d42de16bf8a253", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/41fd6c4ae28c38b294b42af6db61446594a0dece", + "reference": "41fd6c4ae28c38b294b42af6db61446594a0dece", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/console": "<6.4" }, "require-dev": { - "php-coveralls/php-coveralls": "^2.1", - "php-mock/php-mock-phpunit": "^2.1", - "phpunit/phpunit": "^7.0" + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "twig/twig": "^3.12" }, + "bin": [ + "Resources/bin/var-dump-server" + ], "type": "library", "autoload": { + "files": [ + "Resources/functions/dump.php" + ], "psr-4": { - "TheIconic\\NameParser\\": [ - "src/", - "tests/" - ] - } + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -5983,539 +6459,148 @@ ], "authors": [ { - "name": "The Iconic", - "email": "engineering@theiconic.com.au" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "PHP library for parsing a string containing a full name into its parts", + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], "support": { - "issues": "https://github.com/theiconic/name-parser/issues", - "source": "https://github.com/theiconic/name-parser/tree/v1.2.11" - }, - "time": "2019-11-14T14:08:48+00:00" - }, - { - "name": "twig/twig", - "version": "v3.15.0", - "source": { - "type": "git", - "url": "https://github.com/twigphp/Twig.git", - "reference": "2d5b3964cc21d0188633d7ddce732dc8e874db02" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/2d5b3964cc21d0188633d7ddce732dc8e874db02", - "reference": "2d5b3964cc21d0188633d7ddce732dc8e874db02", - "shasum": "" - }, - "require": { - "php": ">=8.0.2", - "symfony/deprecation-contracts": "^2.5|^3", - "symfony/polyfill-ctype": "^1.8", - "symfony/polyfill-mbstring": "^1.3", - "symfony/polyfill-php81": "^1.29" - }, - "require-dev": { - "psr/container": "^1.0|^2.0", - "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" - }, - "type": "library", - "autoload": { - "files": [ - "src/Resources/core.php", - "src/Resources/debug.php", - "src/Resources/escaper.php", - "src/Resources/string_loader.php" - ], - "psr-4": { - "Twig\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com", - "homepage": "http://fabien.potencier.org", - "role": "Lead Developer" - }, - { - "name": "Twig Team", - "role": "Contributors" - }, - { - "name": "Armin Ronacher", - "email": "armin.ronacher@active-4.com", - "role": "Project Founder" - } - ], - "description": "Twig, the flexible, fast, and secure template language for PHP", - "homepage": "https://twig.symfony.com", - "keywords": [ - "templating" - ], - "support": { - "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.15.0" - }, - "funding": [ - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/twig/twig", - "type": "tidelift" - } - ], - "time": "2024-11-17T15:59:19+00:00" - }, - { - "name": "voku/anti-xss", - "version": "4.1.42", - "source": { - "type": "git", - "url": "https://github.com/voku/anti-xss.git", - "reference": "bca1f8607e55a3c5077483615cd93bd8f11bd675" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/voku/anti-xss/zipball/bca1f8607e55a3c5077483615cd93bd8f11bd675", - "reference": "bca1f8607e55a3c5077483615cd93bd8f11bd675", - "shasum": "" - }, - "require": { - "php": ">=7.0.0", - "voku/portable-utf8": "~6.0.2" - }, - "require-dev": { - "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "4.1.x-dev" - } - }, - "autoload": { - "psr-4": { - "voku\\helper\\": "src/voku/helper/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "EllisLab Dev Team", - "homepage": "http://ellislab.com/" - }, - { - "name": "Lars Moelleken", - "email": "lars@moelleken.org", - "homepage": "https://www.moelleken.org/" - } - ], - "description": "anti xss-library", - "homepage": "https://github.com/voku/anti-xss", - "keywords": [ - "anti-xss", - "clean", - "security", - "xss" - ], - "support": { - "issues": "https://github.com/voku/anti-xss/issues", - "source": "https://github.com/voku/anti-xss/tree/4.1.42" - }, - "funding": [ - { - "url": "https://www.paypal.me/moelleken", - "type": "custom" - }, - { - "url": "https://github.com/voku", - "type": "github" - }, - { - "url": "https://opencollective.com/anti-xss", - "type": "open_collective" - }, - { - "url": "https://www.patreon.com/voku", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/voku/anti-xss", - "type": "tidelift" - } - ], - "time": "2023-07-03T14:40:46+00:00" - }, - { - "name": "voku/arrayy", - "version": "7.9.6", - "source": { - "type": "git", - "url": "https://github.com/voku/Arrayy.git", - "reference": "0e20b8c6eef7fc46694a2906e0eae2f9fc11cade" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/voku/Arrayy/zipball/0e20b8c6eef7fc46694a2906e0eae2f9fc11cade", - "reference": "0e20b8c6eef7fc46694a2906e0eae2f9fc11cade", - "shasum": "" - }, - "require": { - "ext-json": "*", - "php": ">=7.0.0", - "phpdocumentor/reflection-docblock": "~4.3 || ~5.0", - "symfony/polyfill-mbstring": "~1.0" - }, - "require-dev": { - "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" - }, - "type": "library", - "autoload": { - "files": [ - "src/Create.php" - ], - "psr-4": { - "Arrayy\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Lars Moelleken", - "email": "lars@moelleken.org", - "homepage": "https://www.moelleken.org/", - "role": "Maintainer" - } - ], - "description": "Array manipulation library for PHP, called Arrayy!", - "keywords": [ - "Arrayy", - "array", - "helpers", - "manipulation", - "methods", - "utility", - "utils" - ], - "support": { - "docs": "https://voku.github.io/Arrayy/", - "issues": "https://github.com/voku/Arrayy/issues", - "source": "https://github.com/voku/Arrayy" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.0" }, "funding": [ { - "url": "https://www.paypal.me/moelleken", - "type": "custom" - }, - { - "url": "https://github.com/voku", - "type": "github" - }, - { - "url": "https://opencollective.com/arrayy", - "type": "open_collective" - }, - { - "url": "https://www.patreon.com/voku", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/voku/arrayy", - "type": "tidelift" - } - ], - "time": "2022-12-27T12:58:32+00:00" - }, - { - "name": "voku/email-check", - "version": "3.1.0", - "source": { - "type": "git", - "url": "https://github.com/voku/email-check.git", - "reference": "6ea842920bbef6758b8c1e619fd1710e7a1a2cac" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/voku/email-check/zipball/6ea842920bbef6758b8c1e619fd1710e7a1a2cac", - "reference": "6ea842920bbef6758b8c1e619fd1710e7a1a2cac", - "shasum": "" - }, - "require": { - "php": ">=7.0.0", - "symfony/polyfill-intl-idn": "~1.10" - }, - "require-dev": { - "fzaninotto/faker": "~1.7", - "phpunit/phpunit": "~6.0 || ~7.0" - }, - "suggest": { - "ext-intl": "Use Intl for best performance" - }, - "type": "library", - "autoload": { - "psr-4": { - "voku\\helper\\": "src/voku/helper/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Lars Moelleken", - "homepage": "http://www.moelleken.org/" - } - ], - "description": "email-check (syntax, dns, trash, ...) library", - "homepage": "https://github.com/voku/email-check", - "keywords": [ - "check-email", - "email", - "mail", - "mail-check", - "validate-email", - "validate-email-address", - "validate-mail" - ], - "support": { - "issues": "https://github.com/voku/email-check/issues", - "source": "https://github.com/voku/email-check/tree/3.1.0" - }, - "funding": [ - { - "url": "https://www.paypal.me/moelleken", + "url": "https://symfony.com/sponsor", "type": "custom" }, { - "url": "https://github.com/voku", + "url": "https://github.com/fabpot", "type": "github" }, { - "url": "https://www.patreon.com/voku", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/voku/email-check", - "type": "tidelift" - } - ], - "time": "2021-01-27T14:14:33+00:00" - }, - { - "name": "voku/portable-ascii", - "version": "2.0.3", - "source": { - "type": "git", - "url": "https://github.com/voku/portable-ascii.git", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", - "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", - "shasum": "" - }, - "require": { - "php": ">=7.0.0" - }, - "require-dev": { - "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" - }, - "suggest": { - "ext-intl": "Use Intl for transliterator_transliterate() support" - }, - "type": "library", - "autoload": { - "psr-4": { - "voku\\": "src/voku/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Lars Moelleken", - "homepage": "https://www.moelleken.org/" - } - ], - "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", - "homepage": "https://github.com/voku/portable-ascii", - "keywords": [ - "ascii", - "clean", - "php" - ], - "support": { - "issues": "https://github.com/voku/portable-ascii/issues", - "source": "https://github.com/voku/portable-ascii/tree/2.0.3" - }, - "funding": [ - { - "url": "https://www.paypal.me/moelleken", - "type": "custom" - }, - { - "url": "https://github.com/voku", + "url": "https://github.com/nicolas-grekas", "type": "github" }, { - "url": "https://opencollective.com/portable-ascii", - "type": "open_collective" - }, - { - "url": "https://www.patreon.com/voku", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-21T01:49:47+00:00" + "time": "2025-10-27T20:36:44+00:00" }, { - "name": "voku/portable-utf8", - "version": "6.0.13", + "name": "symfony/yaml", + "version": "v7.4.0", "source": { "type": "git", - "url": "https://github.com/voku/portable-utf8.git", - "reference": "b8ce36bf26593e5c2e81b1850ef0ffb299d2043f" + "url": "https://github.com/symfony/yaml.git", + "reference": "6c84a4b55aee4cd02034d1c528e83f69ddf63810" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/voku/portable-utf8/zipball/b8ce36bf26593e5c2e81b1850ef0ffb299d2043f", - "reference": "b8ce36bf26593e5c2e81b1850ef0ffb299d2043f", + "url": "https://api.github.com/repos/symfony/yaml/zipball/6c84a4b55aee4cd02034d1c528e83f69ddf63810", + "reference": "6c84a4b55aee4cd02034d1c528e83f69ddf63810", "shasum": "" }, "require": { - "php": ">=7.0.0", - "symfony/polyfill-iconv": "~1.0", - "symfony/polyfill-intl-grapheme": "~1.0", - "symfony/polyfill-intl-normalizer": "~1.0", - "symfony/polyfill-mbstring": "~1.0", - "symfony/polyfill-php72": "~1.0", - "voku/portable-ascii": "~2.0.0" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" }, - "require-dev": { - "phpstan/phpstan": "1.9.*@dev", - "phpstan/phpstan-strict-rules": "1.4.*@dev", - "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0", - "thecodingmachine/phpstan-strict-rules": "1.0.*@dev", - "voku/phpstan-rules": "3.1.*@dev" + "conflict": { + "symfony/console": "<6.4" }, - "suggest": { - "ext-ctype": "Use Ctype for e.g. hexadecimal digit detection", - "ext-fileinfo": "Use Fileinfo for better binary file detection", - "ext-iconv": "Use iconv for best performance", - "ext-intl": "Use Intl for best performance", - "ext-json": "Use JSON for string detection", - "ext-mbstring": "Use Mbstring for best performance" + "require-dev": { + "symfony/console": "^6.4|^7.0|^8.0" }, + "bin": [ + "Resources/bin/yaml-lint" + ], "type": "library", "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "voku\\": "src/voku/" - } + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "(Apache-2.0 or GPL-2.0)" + "MIT" ], "authors": [ { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Hamid Sarfraz", - "homepage": "http://pageconfig.com/" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" }, { - "name": "Lars Moelleken", - "homepage": "http://www.moelleken.org/" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Portable UTF-8 library - performance optimized (unicode) string functions for php.", - "homepage": "https://github.com/voku/portable-utf8", - "keywords": [ - "UTF", - "clean", - "php", - "unicode", - "utf-8", - "utf8" - ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", "support": { - "issues": "https://github.com/voku/portable-utf8/issues", - "source": "https://github.com/voku/portable-utf8/tree/6.0.13" + "source": "https://github.com/symfony/yaml/tree/v7.4.0" }, "funding": [ { - "url": "https://www.paypal.me/moelleken", + "url": "https://symfony.com/sponsor", "type": "custom" }, { - "url": "https://github.com/voku", + "url": "https://github.com/fabpot", "type": "github" }, { - "url": "https://opencollective.com/portable-utf8", - "type": "open_collective" - }, - { - "url": "https://www.patreon.com/voku", - "type": "patreon" + "url": "https://github.com/nicolas-grekas", + "type": "github" }, { - "url": "https://tidelift.com/funding/github/packagist/voku/portable-utf8", + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2023-03-08T08:35:38+00:00" + "time": "2025-11-16T10:14:42+00:00" }, { - "name": "voku/stop-words", - "version": "2.0.1", + "name": "theiconic/name-parser", + "version": "v1.2.11", "source": { "type": "git", - "url": "https://github.com/voku/stop-words.git", - "reference": "8e63c0af20f800b1600783764e0ce19e53969f71" + "url": "https://github.com/theiconic/name-parser.git", + "reference": "9a54a713bf5b2e7fd990828147d42de16bf8a253" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/voku/stop-words/zipball/8e63c0af20f800b1600783764e0ce19e53969f71", - "reference": "8e63c0af20f800b1600783764e0ce19e53969f71", + "url": "https://api.github.com/repos/theiconic/name-parser/zipball/9a54a713bf5b2e7fd990828147d42de16bf8a253", + "reference": "9a54a713bf5b2e7fd990828147d42de16bf8a253", "shasum": "" }, "require": { - "php": ">=7.0.0" + "php": ">=7.1" }, "require-dev": { - "phpunit/phpunit": "~6.0" + "php-coveralls/php-coveralls": "^2.1", + "php-mock/php-mock-phpunit": "^2.1", + "phpunit/phpunit": "^7.0" }, "type": "library", "autoload": { "psr-4": { - "voku\\": "src/voku/" + "TheIconic\\NameParser\\": [ + "src/", + "tests/" + ] } }, "notification-url": "https://packagist.org/downloads/", @@ -6524,177 +6609,145 @@ ], "authors": [ { - "name": "Lars Moelleken", - "homepage": "http://www.moelleken.org/" + "name": "The Iconic", + "email": "engineering@theiconic.com.au" } ], - "description": "Stop-Words via PHP", - "keywords": [ - "stop words", - "stop-words" - ], + "description": "PHP library for parsing a string containing a full name into its parts", "support": { - "issues": "https://github.com/voku/stop-words/issues", - "source": "https://github.com/voku/stop-words/tree/master" + "issues": "https://github.com/theiconic/name-parser/issues", + "source": "https://github.com/theiconic/name-parser/tree/v1.2.11" }, - "time": "2018-11-23T01:37:27+00:00" + "time": "2019-11-14T14:08:48+00:00" }, { - "name": "voku/stringy", - "version": "6.5.3", + "name": "twig/twig", + "version": "v3.21.1", "source": { "type": "git", - "url": "https://github.com/voku/Stringy.git", - "reference": "c453c88fbff298f042c836ef44306f8703b2d537" + "url": "https://github.com/twigphp/Twig.git", + "reference": "285123877d4dd97dd7c11842ac5fb7e86e60d81d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/voku/Stringy/zipball/c453c88fbff298f042c836ef44306f8703b2d537", - "reference": "c453c88fbff298f042c836ef44306f8703b2d537", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/285123877d4dd97dd7c11842ac5fb7e86e60d81d", + "reference": "285123877d4dd97dd7c11842ac5fb7e86e60d81d", "shasum": "" }, "require": { - "defuse/php-encryption": "~2.0", - "ext-json": "*", - "php": ">=7.0.0", - "voku/anti-xss": "~4.1", - "voku/arrayy": "~7.8", - "voku/email-check": "~3.1", - "voku/portable-ascii": "~2.0", - "voku/portable-utf8": "~6.0", - "voku/urlify": "~5.0" - }, - "replace": { - "danielstjules/stringy": "~3.0" + "php": ">=8.1.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-mbstring": "^1.3" }, "require-dev": { - "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" + "phpstan/phpstan": "^2.0", + "psr/container": "^1.0|^2.0", + "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" }, "type": "library", "autoload": { "files": [ - "src/Create.php" + "src/Resources/core.php", + "src/Resources/debug.php", + "src/Resources/escaper.php", + "src/Resources/string_loader.php" ], "psr-4": { - "Stringy\\": "src/" + "Twig\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "MIT" + "BSD-3-Clause" ], "authors": [ { - "name": "Daniel St. Jules", - "email": "danielst.jules@gmail.com", - "homepage": "http://www.danielstjules.com", - "role": "Maintainer" + "name": "Fabien Potencier", + "email": "fabien@symfony.com", + "homepage": "http://fabien.potencier.org", + "role": "Lead Developer" }, { - "name": "Lars Moelleken", - "email": "lars@moelleken.org", - "homepage": "https://www.moelleken.org/", - "role": "Fork-Maintainer" + "name": "Twig Team", + "role": "Contributors" + }, + { + "name": "Armin Ronacher", + "email": "armin.ronacher@active-4.com", + "role": "Project Founder" } ], - "description": "A string manipulation library with multibyte support", - "homepage": "https://github.com/danielstjules/Stringy", + "description": "Twig, the flexible, fast, and secure template language for PHP", + "homepage": "https://twig.symfony.com", "keywords": [ - "UTF", - "helpers", - "manipulation", - "methods", - "multibyte", - "string", - "utf-8", - "utility", - "utils" + "templating" ], "support": { - "issues": "https://github.com/voku/Stringy/issues", - "source": "https://github.com/voku/Stringy" + "issues": "https://github.com/twigphp/Twig/issues", + "source": "https://github.com/twigphp/Twig/tree/v3.21.1" }, "funding": [ { - "url": "https://www.paypal.me/moelleken", - "type": "custom" - }, - { - "url": "https://github.com/voku", + "url": "https://github.com/fabpot", "type": "github" }, { - "url": "https://www.patreon.com/voku", - "type": "patreon" - }, - { - "url": "https://tidelift.com/funding/github/packagist/voku/stringy", + "url": "https://tidelift.com/funding/github/packagist/twig/twig", "type": "tidelift" } ], - "time": "2022-03-28T14:52:20+00:00" + "time": "2025-05-03T07:21:55+00:00" }, { - "name": "voku/urlify", - "version": "5.0.7", + "name": "voku/portable-ascii", + "version": "2.0.3", "source": { "type": "git", - "url": "https://github.com/voku/urlify.git", - "reference": "014b2074407b5db5968f836c27d8731934b330e4" + "url": "https://github.com/voku/portable-ascii.git", + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/voku/urlify/zipball/014b2074407b5db5968f836c27d8731934b330e4", - "reference": "014b2074407b5db5968f836c27d8731934b330e4", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", + "reference": "b1d923f88091c6bf09699efcd7c8a1b1bfd7351d", "shasum": "" }, "require": { - "php": ">=7.0.0", - "voku/portable-ascii": "~2.0", - "voku/portable-utf8": "~6.0", - "voku/stop-words": "~2.0" + "php": ">=7.0.0" }, "require-dev": { "phpunit/phpunit": "~6.0 || ~7.0 || ~9.0" }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, "type": "library", "autoload": { "psr-4": { - "voku\\helper\\": "src/voku/helper/" + "voku\\": "src/voku/" } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ - { - "name": "Johnny Broadway", - "email": "johnny@johnnybroadway.com", - "homepage": "http://www.johnnybroadway.com/" - }, { "name": "Lars Moelleken", - "email": "lars@moelleken.org", - "homepage": "https://moelleken.org/" + "homepage": "https://www.moelleken.org/" } ], - "description": "PHP port of URLify.js from the Django project. Transliterates non-ascii characters for use in URLs.", - "homepage": "https://github.com/voku/urlify", + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", "keywords": [ - "encode", - "iconv", - "link", - "slug", - "translit", - "transliterate", - "transliteration", - "url", - "urlify" + "ascii", + "clean", + "php" ], "support": { - "issues": "https://github.com/voku/urlify/issues", - "source": "https://github.com/voku/urlify/tree/5.0.7" + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.0.3" }, "funding": [ { @@ -6705,16 +6758,20 @@ "url": "https://github.com/voku", "type": "github" }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, { "url": "https://www.patreon.com/voku", "type": "patreon" }, { - "url": "https://tidelift.com/funding/github/packagist/voku/urlify", + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", "type": "tidelift" } ], - "time": "2022-01-24T19:08:46+00:00" + "time": "2024-11-21T01:49:47+00:00" }, { "name": "web-auth/cose-lib", @@ -10888,90 +10945,6 @@ ], "time": "2025-11-05T05:42:40+00:00" }, - { - "name": "symfony/polyfill-php80", - "version": "v1.33.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/0cc9dd0f17f61d8131e7df6b84bd344899fe2608", - "reference": "0cc9dd0f17f61d8131e7df6b84bd344899fe2608", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php80\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ion Bazan", - "email": "ion.bazan@gmail.com" - }, - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-01-02T08:10:11+00:00" - }, { "name": "symplify/easy-coding-standard", "version": "10.3.3", diff --git a/packages/craftcms-playwright/php/app.php b/packages/craftcms-playwright/php/app.php index 9d550ad7b93..b63e93f14b6 100644 --- a/packages/craftcms-playwright/php/app.php +++ b/packages/craftcms-playwright/php/app.php @@ -26,7 +26,6 @@ '@' . str_replace('\\', '/', App::env('CODECEPTION_FIXTURES_NAMESPACE')) => '/var/www/repos/repo' . StringHelper::ensureLeft(App::env('CODECEPTION_FIXTURES_PATH'), '/'), ], 'modules' => [ - /** @phpstan-ignore-next-line */ 'db-backup' => \modules\DbBackup::class, ], 'bootstrap' => ['db-backup'], diff --git a/packages/craftcms-vue/admintable/App.vue b/packages/craftcms-vue/admintable/App.vue index d0572dd695e..b4ce6af6670 100644 --- a/packages/craftcms-vue/admintable/App.vue +++ b/packages/craftcms-vue/admintable/App.vue @@ -498,7 +498,8 @@ isEmpty: false, isLoading: true, searchClearTitle: Craft.escapeHtml(Craft.t('app', 'Clear')), - searchTerm: '', + searchTerm: + new URL(window.location.href).searchParams.get('search') || '', selectAll: null, sortable: null, tableBodySelector: '.vuetable-body', @@ -717,6 +718,14 @@ } this.reload(); } + + const url = new URL(window.location.href); + if (this.searchTerm) { + url.searchParams.set('search', this.searchTerm); + } else { + url.searchParams.delete('search'); + } + window.history.replaceState({}, '', url); }, 500), resetSearch: function () { diff --git a/src/base/Element.php b/src/base/Element.php index 3215aa15b90..5266e35c65b 100644 --- a/src/base/Element.php +++ b/src/base/Element.php @@ -785,18 +785,11 @@ 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'; @@ -975,6 +968,14 @@ public static function createCondition(): ElementConditionInterface return Craft::createObject(ElementCondition::class, [static::class]); } + /** + * @inheritdoc + */ + public static function multiPageSources(): bool + { + return false; + } + /** * @inheritdoc */ @@ -1363,8 +1364,8 @@ public static function indexHtml( return ''; } - // See if there are any provisional drafts we should swap these out with - ElementHelper::swapInProvisionalDrafts($elements); + // See if there are any provisional changes we should show + ElementHelper::loadProvisionalChanges($elements); if ($request->getParam('prevalidate')) { foreach ($elements as $element) { @@ -1624,13 +1625,13 @@ protected static function defineDefaultTableAttributes(string $source): array /** * @inheritdoc */ - public static function cardAttributes(): array + public static function cardAttributes(?FieldLayout $fieldLayout = null): array { $cardAttributes = static::defineCardAttributes(); // Fire a 'registerCardAttributes' event if (Event::hasHandlers(static::class, self::EVENT_REGISTER_CARD_ATTRIBUTES)) { - $event = new RegisterElementCardAttributesEvent(['cardAttributes' => $cardAttributes]); + $event = new RegisterElementCardAttributesEvent(['cardAttributes' => $cardAttributes, 'fieldLayout' => $fieldLayout]); Event::trigger(static::class, self::EVENT_REGISTER_CARD_ATTRIBUTES, $event); return $event->cardAttributes; } @@ -2739,6 +2740,7 @@ public function attributes(): array } unset( + $names['applyingDraft'], $names['awaitingFieldValues'], $names['duplicateOf'], $names['elementQueryResult'], @@ -2750,6 +2752,7 @@ public function attributes(): array $names['isNewSite'], $names['previewing'], $names['propagateAll'], + $names['propagateRequired'], $names['propagating'], $names['propagatingFrom'], $names['resaving'], @@ -3533,6 +3536,7 @@ public function getCrumbs(): array 'html' => Cp::elementChipHtml($owner, [ 'showDraftName' => false, 'class' => 'chromeless', + 'hyperlink' => true, ]), ], ]; @@ -3628,21 +3632,12 @@ public function getCardBodyHtml(): ?string { $this->viewMode = 'cards'; $html = ''; + $cardElements = $this->getFieldLayout()?->getCardBodyElements($this) ?? []; - foreach ($this->getFieldLayout()?->getCardBodyElements($this) ?? [] as $item) { - if ($item instanceof BaseField) { - $itemHtml = $item->previewHtml($this); - } elseif (is_array($item) && isset($item['html'])) { - $itemHtml = $item['html']; - } else { - $itemHtml = $this->getAttributeHtml($item['value']); - } - - if ($itemHtml !== '') { - $html .= Html::tag('div', $itemHtml, [ - 'class' => 'card-attribute-preview', - ]); - } + foreach ($cardElements as $item) { + $html .= Html::tag('div', $item['html'], [ + 'class' => 'card-attribute-preview', + ]); } return $html; diff --git a/src/base/ElementInterface.php b/src/base/ElementInterface.php index af2c4633a7c..54846d6e1b6 100644 --- a/src/base/ElementInterface.php +++ b/src/base/ElementInterface.php @@ -261,12 +261,21 @@ public static function findAll(mixed $criteria = null): array; */ public static function createCondition(): ElementConditionInterface; + /** + * Returns whether the element type’s sources can be split into multiple pages. + * + * @return bool + * @since 5.9.0 + */ + public static function multiPageSources(): bool; + /** * Returns the source definitions that elements of this type may belong to. * * This defines what will show up in the source list on element indexes and element selector modals. * * Each item in the array should be set to an array that has the following keys: + * - **`page`** – The source’s page label. (Optional) * - **`key`** – The source’s key. This is the string that will be passed into the $source argument of [[actions()]], * [[indexHtml()]], and [[defaultTableAttributes()]]. * - **`label`** – The human-facing label of the source. @@ -547,10 +556,13 @@ public static function defaultTableAttributes(string $source): array; * This method should return an array whose keys represent element attribute names, and whose values make * up the table’s column headers. * + * @param FieldLayout|null $fieldLayout + * @since 5.9.0 * @return array The card attributes. + * * @since 5.5.0 */ - public static function cardAttributes(): array; + public static function cardAttributes(?FieldLayout $fieldLayout = null): array; /** * Returns the list of card attribute keys that should be shown by default, if the field layout hasn't been customised. @@ -1900,9 +1912,6 @@ 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; @@ -1910,9 +1919,6 @@ 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/ElementTrait.php b/src/base/ElementTrait.php index f6bc9a0fbe4..f042204bba9 100644 --- a/src/base/ElementTrait.php +++ b/src/base/ElementTrait.php @@ -58,6 +58,12 @@ trait ElementTrait */ public bool $isProvisionalDraft = false; + /** + * @var bool Whether provisional changes have been loaded onto this element. + * @since 5.9.0 + */ + public bool $hasProvisionalChanges = false; + /** * @var string|null The element’s UID */ @@ -190,6 +196,13 @@ trait ElementTrait */ public bool $propagateAll = false; + /** + * @var bool Whether all required element attributes should be propagated across all its supported sites, but only if otherwise + * they wouldn’t validate. + * @since 5.9.0 + */ + public bool $propagateRequired = false; + /** * @var int[] The site IDs that the element was just propagated to for the first time. * @since 3.2.9 @@ -225,6 +238,12 @@ trait ElementTrait */ public bool $firstSave = false; + /** + * @var bool Whether the element is a draft that is about to be applied to the canonical element. + * @since 5.9.0 + */ + public bool $applyingDraft = false; + /** * @var bool Whether recent changes to the canonical element are being merged into this element. * @since 3.7.0 diff --git a/src/base/Field.php b/src/base/Field.php index 859826a0708..bce2fb03024 100644 --- a/src/base/Field.php +++ b/src/base/Field.php @@ -165,6 +165,7 @@ abstract class Field extends SavableComponent implements FieldInterface, Iconic, /** @since 5.8.0 */ public const RESERVED_HANDLES = [ 'ancestors', + 'applyingDraft', 'archived', 'attributeLabel', 'attributes', @@ -214,6 +215,7 @@ abstract class Field extends SavableComponent implements FieldInterface, Iconic, 'prevSibling', 'previewing', 'propagateAll', + 'propagateRequired', 'propagating', 'ref', 'relatedToAssets', @@ -562,9 +564,8 @@ public function getActionMenuItems(): array protected function actionMenuItems(): array { $items = []; - $userSessionService = Craft::$app->getUser(); - if ($this->id && $userSessionService->getIsAdmin()) { + if ($this->id && Craft::$app->getUser()->getIsAdmin()) { $view = Craft::$app->getView(); if (Craft::$app->getConfig()->getGeneral()->allowAdminChanges) { @@ -588,29 +589,6 @@ protected function actionMenuItems(): array ['fieldId' => $this->id], ]); } - - // Copy field handle - if (!$userSessionService->getIdentity()->getPreference('showFieldHandles')) { - $copyId = sprintf('action-copy-handle-%s', mt_rand()); - $items[] = [ - 'id' => $copyId, - 'icon' => 'clipboard', - 'label' => Craft::t('app', 'Copy field handle'), - ]; - $view->registerJsWithVars(fn($id, $attribute) => << { - $('#' + $id).on('activate', () => { - Craft.ui.createCopyTextPrompt({ - label: Craft.t('app', 'Field Handle'), - value: $attribute, - }); - }); -})(); -JS, [ - $view->namespaceInputId($copyId), - $this->handle, - ]); - } } return $items; @@ -1367,4 +1345,12 @@ protected function isFresh(?ElementInterface $element = null): bool return true; } + + /** + * @inheritdoc + */ + public function propagateValue(ElementInterface $from, ElementInterface $to): void + { + $to->setFieldValue($this->handle, $from->getFieldValue($this->handle)); + } } diff --git a/src/base/FieldInterface.php b/src/base/FieldInterface.php index 46607f58e74..8f53fd99b76 100644 --- a/src/base/FieldInterface.php +++ b/src/base/FieldInterface.php @@ -500,6 +500,15 @@ public function modifyElementIndexQuery(ElementQueryInterface $query): void; */ public function setIsFresh(?bool $isFresh = null): void; + /** + * Copies the field value from one site to another. + * + * @param ElementInterface $from + * @param ElementInterface $to + * @since 5.9.0 + */ + public function propagateValue(ElementInterface $from, ElementInterface $to): void; + /** * Returns whether the field should be included in the given GraphQL schema. * diff --git a/src/base/conditions/BaseTextConditionRule.php b/src/base/conditions/BaseTextConditionRule.php index a90329bb904..c9793e7f4cb 100644 --- a/src/base/conditions/BaseTextConditionRule.php +++ b/src/base/conditions/BaseTextConditionRule.php @@ -164,9 +164,9 @@ protected function matchValue(mixed $value): bool self::OPERATOR_LTE => $value <= $this->value, self::OPERATOR_GT => $value > $this->value, self::OPERATOR_GTE => $value >= $this->value, - self::OPERATOR_BEGINS_WITH => is_string($value) && StringHelper::startsWith($value, $this->value), - self::OPERATOR_ENDS_WITH => is_string($value) && StringHelper::endsWith($value, $this->value), - self::OPERATOR_CONTAINS => is_string($value) && StringHelper::contains($value, $this->value), + self::OPERATOR_BEGINS_WITH => is_string($value) && StringHelper::startsWith($value, $this->value, false), + self::OPERATOR_ENDS_WITH => is_string($value) && StringHelper::endsWith($value, $this->value, false), + self::OPERATOR_CONTAINS => is_string($value) && StringHelper::contains($value, $this->value, false), default => throw new InvalidConfigException("Invalid operator: $this->operator"), }; } diff --git a/src/config/GeneralConfig.php b/src/config/GeneralConfig.php index c96f9fbb3c0..b51930e3041 100644 --- a/src/config/GeneralConfig.php +++ b/src/config/GeneralConfig.php @@ -1756,7 +1756,7 @@ class GeneralConfig extends BaseConfig public mixed $logoutPath = 'logout'; /** - * @var int The maximum dimension size to use when caching images from external sources to use in transforms. Set to `0` to never cache them. + * @var int The maximum dimension size to use when caching images from external sources to use in transforms. Set to `0` to never cache them. Defaults to `0` as of 5.9.0. Earlier versions default to `2000`. * * ::: code * ```php Static Config @@ -1769,7 +1769,7 @@ class GeneralConfig extends BaseConfig * * @group Image Handling */ - public int $maxCachedCloudImageSize = 2000; + public int $maxCachedCloudImageSize = 0; /** * @var int The maximum allowed GraphQL queries that can be executed in a single batched request. Set to `0` to allow any number of queries. @@ -3215,6 +3215,33 @@ class GeneralConfig extends BaseConfig */ public bool $useEmailAsUsername = false; + /** + * @var bool Whether the [`IDNA_NONTRANSITIONAL_TO_UNICODE`](https://www.php.net/manual/en/intl.constants.php#constant.idna-nontransitional-to-unicode) + * flag should be passed to [idn_to_utf8()](https://www.php.net/manual/en/function.idn-to-utf8.php) when converting + * email addresses from IDNA ASCII to Unicode. + * + * `INTL_IDNA_VARIANT_UTS46` by default, which uses the UTS 46 algorithm, consistent with the requirements of the + * IDNA2008 protocol and mostly compatible with IDNA2003 (deprecated in PHP 7.2). + * + * There are a handful of characters which result in different resolution of IDNs between IDNA2008 and IDNA2003, + * including ß, ς, and joiner characters (ZWJ and ZWNJ). ([More info](https://unicode.org/reports/tr46/#Deviations)) + * + * For example, `ß` will be converted to `ss` by default. Enabling this setting will ensure it gets preserved as `ß`. + * + * ::: code + * ```php Static Config + * ->useIdnaNontransitionalToUnicode(true) + * ``` + * ```shell Environment Override + * CRAFT_USE_IDNA_NONTRANSITIONAL_TO_UNICODE=true + * ``` + * ::: + * + * @group System + * @since 5.9.0 + */ + public bool $useIdnaNontransitionalToUnicode = false; + /** * @var bool Whether [iFrame Resizer options](http://davidjbradshaw.github.io/iframe-resizer/#options) should be used for Live Preview. * @@ -6965,6 +6992,35 @@ public function useEmailAsUsername(bool $value = true): self return $this; } + /** + * Whether the [`IDNA_NONTRANSITIONAL_TO_UNICODE`](https://www.php.net/manual/en/intl.constants.php#constant.idna-nontransitional-to-unicode) + * flag should be passed to [idn_to_utf8()](https://www.php.net/manual/en/function.idn-to-utf8.php) when converting + * email addresses from IDNA ASCII to Unicode. + * + * `INTL_IDNA_VARIANT_UTS46` by default, which uses the UTS 46 algorithm, consistent with the requirements of the + * IDNA2008 protocol and mostly compatible with IDNA2003 (deprecated in PHP 7.2). + * + * There are a handful of characters which result in different resolution of IDNs between IDNA2008 and IDNA2003, + * including ß, ς, and joiner characters (ZWJ and ZWNJ). ([More info](https://unicode.org/reports/tr46/#Deviations)) + * + * For example, `ß` will be converted to `ss` by default. Enabling this setting will ensure it gets preserved as `ß`. + * + * ```php + * ->useIdnaNontransitionalToUnicode(true) + * ``` + * + * @group System + * @param bool $value + * @return self + * @see $useIdnaNontransitionalToUnicode + * @since 5.9.0 + */ + public function useIdnaNontransitionalToUnicode(bool $value = false): self + { + $this->useIdnaNontransitionalToUnicode = $value; + return $this; + } + /** * Whether [iFrame Resizer options](http://davidjbradshaw.github.io/iframe-resizer/#options) should be used for Live Preview. * diff --git a/src/config/app.php b/src/config/app.php index 33c00d2b571..c45001d1dc4 100644 --- a/src/config/app.php +++ b/src/config/app.php @@ -4,7 +4,7 @@ 'id' => 'CraftCMS', 'name' => 'Craft CMS', 'version' => '5.8.21', - 'schemaVersion' => '5.8.0.3', + 'schemaVersion' => '5.9.0.4', 'minVersionRequired' => '4.5.0', 'basePath' => dirname(__DIR__), // Defines the @app alias 'runtimePath' => '@storage/runtime', // Defines the @runtime alias diff --git a/src/config/cproutes/common.php b/src/config/cproutes/common.php index 59bc46f9ea6..5c433fa240f 100644 --- a/src/config/cproutes/common.php +++ b/src/config/cproutes/common.php @@ -17,6 +17,14 @@ 'entries//new' => 'entries/create', 'entries//' => 'elements/edit', 'entries///revisions' => 'elements/revisions', + + 'content' => 'entries', + 'content/' => ['template' => 'entries'], + 'content//' => ['template' => 'entries'], + 'content///new' => 'entries/create', + 'content///' => 'elements/edit', + 'content////revisions' => 'elements/revisions', + 'globals' => 'globals', 'globals/' => 'globals/edit-content', 'graphiql' => 'graphql/graphiql', diff --git a/src/console/controllers/ClearCachesController.php b/src/console/controllers/ClearCachesController.php index 5334b5e8ef6..3f527cb43bc 100644 --- a/src/console/controllers/ClearCachesController.php +++ b/src/console/controllers/ClearCachesController.php @@ -26,10 +26,20 @@ class ClearCachesController extends Controller /** * Lists the caches that can be cleared. * + * @param string|null $which The cache(s) to clear * @return int */ - public function actionIndex(): int + public function actionIndex(?string $which = null): int { + $which = array_filter(func_get_args()); + + if (!empty($which)) { + foreach ($which as $id) { + $this->run($id); + } + return ExitCode::OK; + } + $this->stdout("The following caches can be cleared:\n\n", Console::FG_YELLOW); $lengths = []; diff --git a/src/console/controllers/EntrifyController.php b/src/console/controllers/EntrifyController.php index 0705dbaea88..60ce0a21df6 100644 --- a/src/console/controllers/EntrifyController.php +++ b/src/console/controllers/EntrifyController.php @@ -14,19 +14,24 @@ use craft\db\Table; use craft\elements\Category; use craft\elements\Entry; +use craft\elements\GlobalSet; use craft\elements\Tag; use craft\elements\User; use craft\events\SectionEvent; +use craft\fields\BaseRelationField; use craft\fields\Categories; use craft\fields\Entries; use craft\fields\Tags; use craft\helpers\ArrayHelper; use craft\helpers\Db; +use craft\models\CategoryGroup; use craft\models\EntryType; use craft\models\Section; +use craft\models\TagGroup; use craft\services\Entries as EntriesService; use craft\services\ProjectConfig; use craft\services\Structures; +use Illuminate\Support\Collection; use yii\base\InvalidConfigException; use yii\console\ExitCode; use yii\helpers\Console; @@ -92,17 +97,40 @@ public function beforeAction($action): bool /** * Converts categories to entries. * - * @param string $categoryGroup The category group handle + * @param string|null $categoryGroup The category group handle * @return int */ - public function actionCategories(string $categoryGroup): int + public function actionCategories(?string $categoryGroup = null): int { - $categoryGroupHandle = $categoryGroup; + $categoriesService = Craft::$app->getCategories(); - $categoryGroup = Craft::$app->getCategories()->getGroupByHandle($categoryGroupHandle, true); - if (!$categoryGroup) { - $this->stderr("Invalid category group handle: $categoryGroupHandle\n", Console::FG_RED); - return ExitCode::UNSPECIFIED_ERROR; + if ($categoryGroup) { + $categoryGroupHandle = $categoryGroup; + $categoryGroup = $categoriesService->getGroupByHandle($categoryGroupHandle, true); + + if (!$categoryGroup) { + $this->stderr("Invalid category group handle: $categoryGroupHandle\n", Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + } else { + if (!$this->interactive) { + throw new InvalidConfigException('A category group handle is required when this command is run non-interactively.'); + } + + /** @var Collection $categoryGroups */ + $categoryGroups = Collection::make($categoriesService->getAllGroups()) + ->keyBy(fn(CategoryGroup $group) => $group->handle); + + if (empty($categoryGroups)) { + $this->output('No category groups exist.', Console::FG_YELLOW); + return ExitCode::OK; + } + + $categoryGroupHandle = $this->select( + 'Choose a category group:', + $categoryGroups->map(fn(CategoryGroup $group) => $group->name)->all(), + ); + $categoryGroup = $categoryGroups->get($categoryGroupHandle); } $projectConfigService = Craft::$app->getProjectConfig(); @@ -117,6 +145,10 @@ public function actionCategories(string $categoryGroup): int $this->run('sections/create', [ 'fromCategoryGroup' => $categoryGroup->handle, ]); + + // Add it to a “Categories” page + $this->_addSectionToPage('Categories', 'sitemap'); + $projectConfigChanged = true; $sectionCreated = true; } @@ -296,17 +328,40 @@ public function actionCategories(string $categoryGroup): int /** * Converts tags to entries. * - * @param string $tagGroup The tag group handle + * @param string|null $tagGroup The tag group handle * @return int */ - public function actionTags(string $tagGroup): int + public function actionTags(?string $tagGroup = null): int { - $tagGroupHandle = $tagGroup; + $tagsService = Craft::$app->getTags(); - $tagGroup = Craft::$app->getTags()->getTagGroupByHandle($tagGroupHandle, true); - if (!$tagGroup) { - $this->stderr("Invalid tag group handle: $tagGroupHandle\n", Console::FG_RED); - return ExitCode::UNSPECIFIED_ERROR; + if ($tagGroup) { + $tagGroupHandle = $tagGroup; + + $tagGroup = $tagsService->getTagGroupByHandle($tagGroupHandle, true); + if (!$tagGroup) { + $this->stderr("Invalid tag group handle: $tagGroupHandle\n", Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + } else { + if (!$this->interactive) { + throw new InvalidConfigException('A tag group handle is required when this command is run non-interactively.'); + } + + /** @var Collection $tagGroups */ + $tagGroups = Collection::make($tagsService->getAllTagGroups()) + ->keyBy(fn(TagGroup $group) => $group->handle); + + if (empty($tagGroups)) { + $this->output('No tag groups exist.', Console::FG_YELLOW); + return ExitCode::OK; + } + + $tagGroupHandle = $this->select( + 'Choose a tag group:', + $tagGroups->map(fn(TagGroup $group) => $group->name)->all(), + ); + $tagGroup = $tagGroups->get($tagGroupHandle); } $projectConfigService = Craft::$app->getProjectConfig(); @@ -320,6 +375,10 @@ public function actionTags(string $tagGroup): int $this->run('sections/create', [ 'fromTagGroup' => $tagGroup->handle, ]); + + // Add it to a “Tags” page + $this->_addSectionToPage('Tags', 'tags'); + $projectConfigChanged = true; } @@ -413,6 +472,7 @@ public function actionTags(string $tagGroup): int $this->do(sprintf('Converting %s', ($config['name'] ?? null) ? "“{$config['name']}”" : 'Tags filed'), function() use ($section, $projectConfigService, $path, $config) { $config['type'] = Entries::class; $config['settings']['sources'] = ["section:$section->uid"]; + $config['settings']['viewMode'] = BaseRelationField::VIEW_MODE_LIST_INLINE; unset( $config['settings']['source'], $config['settings']['allowMultipleSources'], @@ -439,17 +499,40 @@ public function actionTags(string $tagGroup): int /** * Converts a global set to a Single section. * - * @param string $globalSet The global set handle + * @param string|null $globalSet The global set handle * @return int */ - public function actionGlobalSet(string $globalSet): int + public function actionGlobalSet(?string $globalSet = null): int { - $globalSetHandle = $globalSet; + $globalsService = Craft::$app->getGlobals(); - $globalSet = Craft::$app->getGlobals()->getSetByHandle($globalSetHandle, withTrashed: true); - if (!$globalSet) { - $this->stderr("Invalid global set handle: $globalSetHandle\n", Console::FG_RED); - return ExitCode::UNSPECIFIED_ERROR; + if ($globalSet) { + $globalSetHandle = $globalSet; + + $globalSet = Craft::$app->getGlobals()->getSetByHandle($globalSetHandle, withTrashed: true); + if (!$globalSet) { + $this->stderr("Invalid global set handle: $globalSetHandle\n", Console::FG_RED); + return ExitCode::UNSPECIFIED_ERROR; + } + } else { + if (!$this->interactive) { + throw new InvalidConfigException('A global set handle is required when this command is run non-interactively.'); + } + + /** @var Collection $globalSets */ + $globalSets = Collection::make($globalsService->getAllSets()) + ->keyBy(fn(GlobalSet $globalSet) => $globalSet->handle); + + if (empty($globalSets)) { + $this->output('No global sets exist.', Console::FG_YELLOW); + return ExitCode::OK; + } + + $globalSetHandle = $this->select( + 'Choose a global set:', + $globalSets->map(fn(GlobalSet $globalSet) => $globalSet->name)->all(), + ); + $globalSet = $globalSets->get($globalSetHandle); } $projectConfigChanged = false; @@ -733,4 +816,26 @@ private function _deployTip(string $action, string $handle): void ``` MD); } + + private function _addSectionToPage(string $name, string $icon): void + { + $projectConfig = Craft::$app->getProjectConfig(); + + $sourceConfigPath = sprintf('%s.%s', ProjectConfig::PATH_ELEMENT_SOURCES, Entry::class); + $sourceConfigs = Collection::make($projectConfig->get($sourceConfigPath)) + ->map(fn(array $config) => $config + ['page' => 'Entries']) + ->all(); + $sourceConfigs[] = [ + 'key' => sprintf('section:%s', $this->_section()->uid), + 'page' => $name, + 'type' => 'native', + ]; + $projectConfig->set($sourceConfigPath, $sourceConfigs); + + $pageSettings = Craft::$app->getElementSources()->getPageSettings(Entry::class); + $pageSettings[$name] = [ + 'icon' => $icon, + ]; + $projectConfig->set(sprintf('%s.%s', ProjectConfig::PATH_ELEMENT_SOURCE_PAGES, Entry::class), $pageSettings); + } } diff --git a/src/console/controllers/MigrateController.php b/src/console/controllers/MigrateController.php index 77cf17bb727..bca9e1f4ef2 100644 --- a/src/console/controllers/MigrateController.php +++ b/src/console/controllers/MigrateController.php @@ -16,8 +16,6 @@ use craft\events\RegisterMigratorEvent; use craft\helpers\ArrayHelper; use craft\helpers\FileHelper; -use yii\base\ErrorException; -use yii\base\InvalidArgumentException; use yii\base\InvalidConfigException; use yii\base\NotSupportedException; use yii\console\controllers\BaseMigrateController; @@ -394,7 +392,6 @@ public function actionAll(): int $this->stdout(PHP_EOL . "$total " . ($total === 1 ? 'migration was' : 'migrations were') . ' applied.' . PHP_EOL, Console::FG_GREEN); $this->stdout(PHP_EOL . 'Migrated up successfully.' . PHP_EOL, Console::FG_GREEN); Craft::$app->disableMaintenanceMode(); - $this->_clearCompiledTemplates(); return ExitCode::OK; } @@ -444,8 +441,6 @@ public function actionUp($limit = 0): int } elseif ($this->plugin) { Craft::$app->getPlugins()->updatePluginVersionInfo($this->plugin); } - - $this->_clearCompiledTemplates(); } return $res; @@ -467,21 +462,6 @@ private function _plugin(string $handle): PluginInterface return $pluginsService->createPlugin($handle); } - /** - * Clears all compiled templates. - */ - private function _clearCompiledTemplates(): void - { - try { - FileHelper::clearDirectory(Craft::$app->getPath()->getCompiledTemplatesPath(false)); - } catch (InvalidArgumentException) { - // the directory doesn't exist - } catch (ErrorException $e) { - Craft::error('Could not delete compiled templates: ' . $e->getMessage()); - Craft::$app->getErrorHandler()->logException($e); - } - } - /** * Returns a migration manager. * diff --git a/src/console/controllers/UpController.php b/src/console/controllers/UpController.php index 49a7f992e43..98179040dc0 100644 --- a/src/console/controllers/UpController.php +++ b/src/console/controllers/UpController.php @@ -92,6 +92,10 @@ public function actionIndex(): int } $this->stdout("\n"); + // Delete compiled templates + $this->run('clear-caches/compiled-templates'); + $this->stdout("\n"); + $this->stdout('Updating license info ... '); Craft::$app->getUpdates()->getUpdates(true); $this->stdout("done\n", Console::FG_GREEN); diff --git a/src/controllers/AppController.php b/src/controllers/AppController.php index 7f8dd9b7873..92e2e6bf93b 100644 --- a/src/controllers/AppController.php +++ b/src/controllers/AppController.php @@ -803,8 +803,8 @@ public function actionRenderElements(): Response $elements = $query->all(); - // See if there are any provisional drafts we should swap these out with - ElementHelper::swapInProvisionalDrafts($elements); + // See if there are any provisional changes we should show + ElementHelper::loadProvisionalChanges($elements); foreach ($elements as $element) { foreach ($instances as $key => $instance) { diff --git a/src/controllers/AssetsController.php b/src/controllers/AssetsController.php index b492827d2cf..0f48569c647 100644 --- a/src/controllers/AssetsController.php +++ b/src/controllers/AssetsController.php @@ -348,6 +348,14 @@ public function actionUpload(): Response } } + // try to get uploaded asset's URL + $url = null; + try { + $url = $asset->getUrl(); + } catch (Throwable) { + // do nothing + } + if ($asset->conflictingFilename !== null) { $conflictingAsset = Asset::findOne(['folderId' => $folder->id, 'filename' => $asset->conflictingFilename]); @@ -358,12 +366,14 @@ public function actionUpload(): Response 'conflictingAssetId' => $conflictingAsset->id ?? null, 'suggestedFilename' => $asset->suggestedFilename, 'conflictingAssetUrl' => ($conflictingAsset && $conflictingAsset->getVolume()->getFs()->hasUrls) ? $conflictingAsset->getUrl() : null, + 'url' => $url, ]); } return $this->asSuccess(data: [ 'filename' => $asset->getFilename(), 'assetId' => $asset->id, + 'url' => $url, ]); } diff --git a/src/controllers/BaseElementsController.php b/src/controllers/BaseElementsController.php index 587a4bc9c62..7990e4a5c18 100644 --- a/src/controllers/BaseElementsController.php +++ b/src/controllers/BaseElementsController.php @@ -7,7 +7,10 @@ namespace craft\controllers; +use Craft; use craft\base\ElementInterface; +use craft\elements\conditions\ElementCondition; +use craft\elements\conditions\ElementConditionInterface; use craft\errors\InvalidTypeException; use craft\services\ElementSources; use craft\web\Controller; @@ -68,4 +71,42 @@ protected function context(): string { return $this->request->getParam('context') ?? ElementSources::CONTEXT_INDEX; } + + /** + * Returns the condition that should be applied to the element query. + * + * @return ElementConditionInterface|null + * @since 5.9.0 + */ + protected function condition(): ?ElementConditionInterface + { + /** @var array|null $conditionConfig */ + /** @phpstan-var array{class:class-string}|null $conditionConfig */ + $conditionConfig = $this->request->getBodyParam('condition'); + + if (!$conditionConfig) { + return null; + } + + $condition = Craft::$app->getConditions()->createCondition($conditionConfig); + + if ($condition instanceof ElementCondition) { + $referenceElementId = $this->request->getBodyParam('referenceElementId'); + if ($referenceElementId) { + $ownerId = $this->request->getBodyParam('referenceElementOwnerId'); + $siteId = $this->request->getBodyParam('referenceElementSiteId'); + $criteria = []; + if ($ownerId) { + $criteria['ownerId'] = $ownerId; + } + $condition->referenceElement = Craft::$app->getElements()->getElementById( + (int)$referenceElementId, + siteId: $siteId, + criteria: $criteria, + ); + } + } + + return $condition; + } } diff --git a/src/controllers/CategoriesController.php b/src/controllers/CategoriesController.php index 67c6ae14a77..1299bc62348 100644 --- a/src/controllers/CategoriesController.php +++ b/src/controllers/CategoriesController.php @@ -488,49 +488,4 @@ private function _populateCategoryModel(Category $category): void $category->setParentId($parentId); } } - - /** - * Returns the HTML for a Categories field input, based on a given list of selected category IDs. - * - * @return Response - * @since 4.0.0 - */ - public function actionInputHtml(): Response - { - $this->requireAcceptsJson(); - - $categoryIds = $this->request->getParam('categoryIds', []); - - $categories = []; - - if (!empty($categoryIds)) { - /** @var Category[] $categories */ - $categories = Category::find() - ->id($categoryIds) - ->siteId($this->request->getParam('siteId')) - ->status(null) - ->all(); - - // Fill in the gaps - $structuresService = Craft::$app->getStructures(); - $structuresService->fillGapsInElements($categories); - - // Enforce the branch limit - if ($branchLimit = $this->request->getParam('branchLimit')) { - $structuresService->applyBranchLimitToElements($categories, $branchLimit); - } - } - - $html = $this->getView()->renderTemplate('_components/fieldtypes/Categories/input.twig', - [ - 'elements' => $categories, - 'id' => $this->request->getParam('id'), - 'name' => $this->request->getParam('name'), - 'selectionLabel' => $this->request->getParam('selectionLabel'), - ]); - - return $this->asJson([ - 'html' => $html, - ]); - } } diff --git a/src/controllers/ElementIndexSettingsController.php b/src/controllers/ElementIndexSettingsController.php index 34542c80164..361b51ada96 100644 --- a/src/controllers/ElementIndexSettingsController.php +++ b/src/controllers/ElementIndexSettingsController.php @@ -68,8 +68,17 @@ public function actionGetCustomizeSourcesModalData(): Response // Get the source info $sourcesService = Craft::$app->getElementSources(); $sources = $sourcesService->getSources($elementType, ElementSources::CONTEXT_INDEX, true); + $multiPage = $elementType::multiPageSources(); foreach ($sources as &$source) { + if ($multiPage) { + // ensure we're using the EN translation here + $language = Craft::$app->language; + Craft::$app->language = Craft::$app->sourceLanguage; + $source['page'] ??= $elementType::pluralDisplayName(); + Craft::$app->language = $language; + } + if ($source['type'] === ElementSources::TYPE_HEADING) { continue; } @@ -215,8 +224,12 @@ public function actionGetCustomizeSourcesModalData(): Response ]) ->all(); + $pageSettings = $sourcesService->getPageSettings($elementType); + return $this->asJson([ + 'multiPage' => $multiPage, 'sources' => $sources, + 'pageSettings' => $pageSettings, 'viewModes' => $viewModes, 'baseSortOptions' => $baseSortOptions, 'defaultSortOptions' => $defaultSortOptions, @@ -239,6 +252,7 @@ public function actionGetCustomizeSourcesModalData(): Response public function actionSaveCustomizeSourcesModalSettings(): Response { $elementType = $this->elementType(); + $multiPage = $elementType::multiPageSources(); // Get the old source configs $projectConfig = Craft::$app->getProjectConfig(); @@ -252,73 +266,97 @@ public function actionSaveCustomizeSourcesModalSettings(): Response $newSourceConfigs = []; $disabledSourceKeys = []; + if ($multiPage) { + $sourcePages = $this->request->getBodyParam('sourcePages', []); + $pageSettings = $this->request->getBodyParam('pageSettings', []); + $sourcePageIndexes = []; + } + // Normalize to the way it's stored in the DB - foreach ($sourceOrder as $source) { - if (isset($source['key'])) { - $type = match (true) { - str_starts_with($source['key'], 'custom:') => ElementSources::TYPE_CUSTOM, - str_starts_with($source['key'], 'heading:') => ElementSources::TYPE_HEADING, - default => ElementSources::TYPE_NATIVE, - }; - - $isCustom = $type === ElementSources::TYPE_CUSTOM; - $sourceConfig = [ - 'type' => $type, - 'key' => $source['key'], - ]; - - // Were new settings posted? - if (isset($sourceSettings[$source['key']])) { - $postedSettings = $sourceSettings[$source['key']]; - - if ($type !== ElementSources::TYPE_HEADING) { - $sourceConfig['tableAttributes'] = array_values(array_filter($postedSettings['tableAttributes'] ?? [])) ?: '-'; - } + foreach ($sourceOrder as $key) { + $type = match (true) { + str_starts_with($key, 'custom:') => ElementSources::TYPE_CUSTOM, + str_starts_with($key, 'heading:') => ElementSources::TYPE_HEADING, + default => ElementSources::TYPE_NATIVE, + }; + + $isCustom = $type === ElementSources::TYPE_CUSTOM; + $sourceConfig = [ + 'type' => $type, + 'key' => $key, + ]; - if (isset($postedSettings['defaultSort'])) { - $sourceConfig['defaultSort'] = $postedSettings['defaultSort']; - } + if (isset($sourcePages[$key])) { + $sourceConfig['page'] = $sourcePages[$key]; + } + + // Were new settings posted? + if (isset($sourceSettings[$key])) { + $postedSettings = $sourceSettings[$key]; + + if ($type !== ElementSources::TYPE_HEADING) { + $sourceConfig['tableAttributes'] = array_values(array_filter($postedSettings['tableAttributes'] ?? [])) ?: '-'; + } + + if (isset($postedSettings['defaultSort'])) { + $sourceConfig['defaultSort'] = $postedSettings['defaultSort']; + } + + if (isset($postedSettings['defaultViewMode'])) { + $sourceConfig['defaultViewMode'] = $postedSettings['defaultViewMode']; + } + + if ($isCustom) { + $sourceConfig += [ + 'label' => $postedSettings['label'], + 'condition' => $conditionsService->createCondition($postedSettings['condition'])->getConfig(), + ]; - if (isset($postedSettings['defaultViewMode'])) { - $sourceConfig['defaultViewMode'] = $postedSettings['defaultViewMode']; + if (isset($postedSettings['sites']) && $postedSettings['sites'] !== '*') { + $sourceConfig['sites'] = is_array($postedSettings['sites']) ? $postedSettings['sites'] : false; } - if ($isCustom) { - $sourceConfig += [ - 'label' => $postedSettings['label'], - '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; - } - } elseif ($type === ElementSources::TYPE_HEADING) { - $sourceConfig['heading'] = $postedSettings['heading']; - } elseif (isset($postedSettings['enabled'])) { - $sourceConfig['disabled'] = !$postedSettings['enabled']; - if ($sourceConfig['disabled']) { - $disabledSourceKeys[] = $source['key']; - } + if (isset($postedSettings['userGroups']) && $postedSettings['userGroups'] !== '*') { + $sourceConfig['userGroups'] = is_array($postedSettings['userGroups']) ? $postedSettings['userGroups'] : false; } - } elseif (isset($oldSourceConfigs[$source['key']])) { - $sourceConfig += $oldSourceConfigs[$source['key']]; - if (!empty($sourceConfig['disabled'])) { - $disabledSourceKeys[] = $source['key']; + } elseif ($type === ElementSources::TYPE_HEADING) { + $sourceConfig['heading'] = $postedSettings['heading']; + } elseif (isset($postedSettings['enabled'])) { + $sourceConfig['disabled'] = !$postedSettings['enabled']; + if ($sourceConfig['disabled']) { + $disabledSourceKeys[] = $key; } - } elseif ($isCustom) { - // Ignore it - continue; } + } elseif (isset($oldSourceConfigs[$key])) { + $sourceConfig += $oldSourceConfigs[$key]; + if (!empty($sourceConfig['disabled'])) { + $disabledSourceKeys[] = $key; + } + } elseif ($isCustom) { + // Ignore it + continue; + } - $newSourceConfigs[] = $sourceConfig; + $newSourceConfigs[] = $sourceConfig; + + if ($multiPage) { + $sourcePageIndexes[] = array_search($sourceConfig['page'], array_keys($pageSettings)); } } - $projectConfig->set(ProjectConfig::PATH_ELEMENT_SOURCES . ".$elementType", $newSourceConfigs); + if ($multiPage) { + /** @phpstan-ignore-next-line */ + array_multisort($sourcePageIndexes, SORT_NUMERIC, range(1, count($newSourceConfigs)), SORT_NUMERIC, $newSourceConfigs); + } + + $projectConfig->set(sprintf('%s.%s', ProjectConfig::PATH_ELEMENT_SOURCES, $elementType), $newSourceConfigs); + + if ($multiPage) { + $projectConfig->set( + sprintf('%s.%s', ProjectConfig::PATH_ELEMENT_SOURCE_PAGES, $elementType), + array_map('array_filter', $pageSettings), + ); + } Craft::$app->getSession()->setSuccess(Craft::t('app', 'Source settings saved')); diff --git a/src/controllers/ElementIndexesController.php b/src/controllers/ElementIndexesController.php index 72ebfe00451..a330e354481 100644 --- a/src/controllers/ElementIndexesController.php +++ b/src/controllers/ElementIndexesController.php @@ -16,7 +16,6 @@ use craft\db\ExcludeDescendantIdsExpression; use craft\elements\actions\DeleteActionInterface; use craft\elements\actions\Restore; -use craft\elements\conditions\ElementCondition; use craft\elements\conditions\ElementConditionInterface; use craft\elements\conditions\ElementConditionRuleInterface; use craft\elements\db\ElementQueryInterface; @@ -29,6 +28,7 @@ use craft\helpers\StringHelper; use craft\models\FieldLayout; use craft\services\ElementSources; +use Illuminate\Support\Collection; use Throwable; use yii\base\InvalidValueException; use yii\web\BadRequestHttpException; @@ -164,6 +164,44 @@ public function actionSourcePath(): Response ]); } + /** + * Returns attribute info for the current source. + * + * @since 5.9.0 + */ + public function actionSourceAttributeInfo(): Response + { + $elementSources = Craft::$app->getElementSources(); + + $sortOptions = Collection::make($elementSources->getSourceSortOptions($this->elementType, $this->sourceKey)) + ->map(fn(array $option) => [ + 'label' => $option['label'], + 'attr' => $option['attribute'] ?? $option['orderBy'], + 'defaultDir' => $option['defaultDir'] ?? 'asc', + ]) + ->values() + ->all(); + + $tableColumns = Collection::make($elementSources->getSourceTableAttributes($this->elementType, $this->sourceKey)) + ->map(fn(array $attribute, string $key) => [ + ...$attribute, + 'attr' => $key, + ]) + ->values() + ->all(); + + $defaultTableColumns = Collection::make($elementSources->getTableAttributes($this->elementType, $this->sourceKey)) + ->map(fn(array $attribute) => $attribute[0]) + ->filter(fn(string $attribute) => $attribute !== 'title') + ->values() + ->all(); + + return $this->asJson(compact( + 'sortOptions', + 'tableColumns', + 'defaultTableColumns', + )); + } /** * Renders and returns an element index container, plus its first batch of elements. @@ -635,44 +673,6 @@ protected function source(): ?array return $source; } - /** - * Returns the condition that should be applied to the element query. - * - * @return ElementConditionInterface|null - * @since 4.0.0 - */ - protected function condition(): ?ElementConditionInterface - { - /** @var array|null $conditionConfig */ - /** @phpstan-var array{class:class-string}|null $conditionConfig */ - $conditionConfig = $this->request->getBodyParam('condition'); - - if (!$conditionConfig) { - return null; - } - - $condition = Craft::$app->getConditions()->createCondition($conditionConfig); - - if ($condition instanceof ElementCondition) { - $referenceElementId = $this->request->getBodyParam('referenceElementId'); - if ($referenceElementId) { - $ownerId = $this->request->getBodyParam('referenceElementOwnerId'); - $siteId = $this->request->getBodyParam('referenceElementSiteId'); - $criteria = []; - if ($ownerId) { - $criteria['ownerId'] = $ownerId; - } - $condition->referenceElement = Craft::$app->getElements()->getElementById( - (int)$referenceElementId, - siteId: $siteId, - criteria: $criteria, - ); - } - } - - return $condition; - } - /** * Returns the current view state. * @@ -874,10 +874,6 @@ protected function elementResponseData(bool $includeContainer, bool $includeActi */ protected function availableActions(): ?array { - if ($this->request->isMobileBrowser()) { - return null; - } - $actions = $this->elementType::actions($this->sourceKey); foreach ($actions as $i => $action) { diff --git a/src/controllers/ElementSelectorModalsController.php b/src/controllers/ElementSelectorModalsController.php index 2456614f83c..97f5309ecda 100644 --- a/src/controllers/ElementSelectorModalsController.php +++ b/src/controllers/ElementSelectorModalsController.php @@ -7,7 +7,9 @@ namespace craft\controllers; +use craft\elements\conditions\StatusConditionRule; use craft\helpers\Cp; +use Illuminate\Support\Collection; use yii\web\Response; /** @@ -27,13 +29,38 @@ public function actionBody(): Response { $this->requireAcceptsJson(); + $elementType = $this->elementType(); + $hasStatuses = $elementType::hasStatuses(); + + if ($hasStatuses) { + $statuses = $elementType::statuses(); + $condition = $this->condition(); + + if ($condition) { + /** @var StatusConditionRule|null $statusRule */ + $statusRule = Collection::make($condition->getConditionRules()) + ->firstWhere(fn($rule) => $rule instanceof StatusConditionRule); + + if ($statusRule) { + $statusValues = $statusRule->getValues(); + $statuses = Collection::make($statuses) + ->filter(function($info, string $status) use ($statusRule, $statusValues) { + $inValues = in_array($status, $statusValues); + return $statusRule->operator === 'in' ? $inValues : !$inValues; + }); + } + } + } + return $this->asJson([ - 'html' => Cp::elementIndexHtml($this->elementType(), [ - 'context' => $this->context(), + 'html' => Cp::elementIndexHtml($elementType, [ 'class' => 'content', - 'sources' => $this->request->getParam('sources'), - 'showSiteMenu' => $this->request->getParam('showSiteMenu', 'auto'), + 'context' => $this->context(), 'registerJs' => false, + 'showSiteMenu' => $this->request->getParam('showSiteMenu', 'auto'), + 'showStatusMenu' => $hasStatuses, + 'sources' => $this->request->getParam('sources'), + 'statuses' => $statuses ?? null, ]), ]); } diff --git a/src/controllers/ElementsController.php b/src/controllers/ElementsController.php index f4791ee9d32..ddf8727d36d 100644 --- a/src/controllers/ElementsController.php +++ b/src/controllers/ElementsController.php @@ -36,6 +36,7 @@ use craft\helpers\Html; use craft\helpers\Json; use craft\helpers\StringHelper; +use craft\helpers\Template; use craft\helpers\UrlHelper; use craft\i18n\Locale; use craft\models\ElementActivity; @@ -361,7 +362,7 @@ public function actionEdit(?ElementInterface $element, ?int $elementId = null): [$docTitle, $title] = $this->_editElementTitles($element); $enabledForSite = $element->getEnabledForSite(); $hasRoute = $element->getRoute() !== null; - $redirectUrl = $this->request->getValidatedQueryParam('returnUrl') ?? ElementHelper::postEditUrl($element); + $redirectUrl = $this->request->getValidatedQueryParam('returnUrl') ?? UrlHelper::cpReferralUrl() ?? ElementHelper::postEditUrl($element); // Site statuses if ($canEditMultipleSites) { @@ -757,6 +758,7 @@ private function _crumbs(ElementInterface $element, bool $current = true): array 'html' => Cp::elementChipHtml($element, [ 'showDraftName' => !$current, 'class' => 'chromeless', + 'hyperlink' => true, ]), 'current' => $current, ], @@ -812,7 +814,7 @@ private function _contextMenuItems( $revisionsPageUrl = $element->getCpRevisionsUrl(); if ($revisionsPageUrl) { - $hasMoreRevisions = ($revisionsQuery->count() - 1) > count($revisions); + $hasMoreRevisions = ($revisionsQuery->count() - 1) > 0; } } else { $revisions = []; @@ -882,17 +884,20 @@ private function _contextMenuItems( /** @var ElementInterface&DraftBehavior $draft */ $creator = $draft->getCreator(); $timestamp = $formatter->asTimestamp($draft->dateUpdated, Locale::LENGTH_SHORT, true); + $timestampWithDate = $formatter->asDatetime($draft->dateUpdated, Locale::LENGTH_SHORT); return [ 'label' => $draft->draftName, 'description' => $creator - ? Craft::t('app', 'Saved {timestamp} by {creator}', [ + ? Template::raw(Craft::t('app', 'Saved by {creator}', [ + 'timestampWithDate' => $timestampWithDate, 'timestamp' => $timestamp, 'creator' => $creator->name, - ]) - : Craft::t('app', 'Last saved {timestamp}', [ + ])) + : Template::raw(Craft::t('app', 'Last saved ', [ + 'timestampWithDate' => $timestampWithDate, 'timestamp' => $timestamp, - ]), + ])), 'url' => UrlHelper::urlWithParams($cpEditUrl, array_merge($baseParams, [ 'draftId' => $draft->draftId, ])), @@ -910,17 +915,20 @@ private function _contextMenuItems( /** @var ElementInterface&RevisionBehavior $revision */ $creator = $revision->getCreator(); $timestamp = $formatter->asTimestamp($revision->dateCreated, Locale::LENGTH_SHORT, true); + $timestampWithDate = $formatter->asDatetime($revision->dateCreated, Locale::LENGTH_SHORT); return [ 'label' => $revision->getRevisionLabel(), 'description' => $creator - ? Craft::t('app', 'Saved {timestamp} by {creator}', [ + ? Template::raw(Craft::t('app', 'Saved by {creator}', [ + 'timestampWithDate' => $timestampWithDate, 'timestamp' => $timestamp, 'creator' => $creator->name, - ]) - : Craft::t('app', 'Saved {timestamp}', [ + ])) + : Template::raw(Craft::t('app', 'Saved ', [ + 'timestampWithDate' => $timestampWithDate, 'timestamp' => $timestamp, - ]), + ])), 'url' => UrlHelper::urlWithParams($cpEditUrl, array_merge($baseParams, [ 'revisionId' => $revision->revisionId, ])), @@ -1639,6 +1647,15 @@ public function actionDuplicate(): ?Response 'draftId' => null, ]; + if ($asUnpublishedDraft && + ($element->getIsCanonical() || $element->isProvisionalDraft) && + $element->slug === $element->getCanonical()->slug + ) { + $newAttributes += [ + 'slug' => null, + ]; + } + if ($element instanceof NestedElementInterface) { $newAttributes += [ 'primaryOwnerId' => $element->getOwnerId(), @@ -2122,11 +2139,20 @@ public function actionApplyDraft(): ?Response $element->setScenario(Element::SCENARIO_LIVE); } + // if we're about to apply an unpublished draft, set propagateRequired to true + if ($isUnpublishedDraft) { + $element->propagateRequired = true; + } + + $element->applyingDraft = true; + $namespace = $this->request->getHeaders()->get('X-Craft-Namespace'); if (!$elementsService->saveElement($element, crossSiteValidate: ($namespace === null && Craft::$app->getIsMultiSite()))) { return $this->_asAppyDraftFailure($element); } + $element->applyingDraft = false; + if (!$isUnpublishedDraft) { $lockKey = "element:$element->canonicalId"; $mutex = Craft::$app->getMutex(); @@ -2141,6 +2167,7 @@ public function actionApplyDraft(): ?Response } try { + $element->propagateRequired = false; $canonical = Craft::$app->getDrafts()->applyDraft($element, $attributes); } catch (InvalidElementException) { return $this->_asAppyDraftFailure($element); diff --git a/src/controllers/EntriesController.php b/src/controllers/EntriesController.php index 915021f3c6d..3d6309f4858 100644 --- a/src/controllers/EntriesController.php +++ b/src/controllers/EntriesController.php @@ -43,6 +43,16 @@ */ class EntriesController extends BaseEntriesController { + /** + * @since 5.9.0 + */ + public function actionIndex(): Response + { + $firstPage = Craft::$app->getElementSources()->getFirstPage(Entry::class); + $slug = $firstPage ? StringHelper::toKebabCase($firstPage) : 'entries'; + return $this->redirect("content/$slug"); + } + /** * Creates a new unpublished draft and redirects to its edit page. * @@ -87,7 +97,7 @@ public function actionCreate(?string $section = null): ?Response if (count($editableSiteIds) > 1 && $section->propagationMethod !== PropagationMethod::All) { return $this->renderTemplate('_special/sitepicker.twig', [ 'siteIds' => $editableSiteIds, - 'baseUrl' => "entries/$section->handle/new", + 'baseUrl' => sprintf('%s/new', $section->getCpIndexUri()), ]); } diff --git a/src/controllers/EntryTypesController.php b/src/controllers/EntryTypesController.php index e1ce35b33e1..69ae917ed53 100644 --- a/src/controllers/EntryTypesController.php +++ b/src/controllers/EntryTypesController.php @@ -18,6 +18,7 @@ use craft\helpers\Cp; use craft\helpers\Html; use craft\helpers\StringHelper; +use craft\helpers\UrlHelper; use craft\models\EntryType; use craft\models\Section; use craft\web\Controller; @@ -122,7 +123,7 @@ public function actionEdit(?int $entryTypeId = null, ?EntryType $entryType = nul if (!$this->readOnly) { $response ->action('entry-types/save') - ->redirectUrl('settings/entry-types') + ->redirectUrl(UrlHelper::cpReferralUrl() ?? 'settings/entry-types') ->addAltAction(Craft::t('app', 'Save and continue editing'), [ 'redirect' => 'settings/entry-types/{id}', 'shortcut' => true, @@ -221,6 +222,7 @@ public function actionSave(): ?Response $entryType->icon = $this->request->getBodyParam('icon', $entryType->icon); $color = $this->request->getBodyParam('color', $entryType->color?->value); $entryType->color = $color && $color !== '__blank__' ? Color::from($color) : null; + $entryType->uiLabelFormat = $this->request->getBodyParam('uiLabelFormat', $entryType->uiLabelFormat); $entryType->titleTranslationMethod = $this->request->getBodyParam('titleTranslationMethod', $entryType->titleTranslationMethod); $entryType->titleTranslationKeyFormat = $this->request->getBodyParam('titleTranslationKeyFormat', $entryType->titleTranslationKeyFormat); $entryType->titleFormat = $this->request->getBodyParam('titleFormat', $entryType->titleFormat); diff --git a/src/controllers/FieldsController.php b/src/controllers/FieldsController.php index 38ab93e03d0..1d5cae309ca 100644 --- a/src/controllers/FieldsController.php +++ b/src/controllers/FieldsController.php @@ -205,7 +205,7 @@ public function actionEditField(?int $fieldId = null, ?FieldInterface $field = n if (!$this->readOnly) { $response ->action('fields/save-field') - ->redirectUrl('settings/fields') + ->redirectUrl(UrlHelper::cpReferralUrl() ?? 'settings/fields') ->addAltAction(Craft::t('app', 'Save and continue editing'), [ 'redirect' => 'settings/fields/edit/{id}', 'shortcut' => true, @@ -610,32 +610,10 @@ public function actionRenderCardPreview() $this->requireAcceptsJson(); $fieldLayoutConfig = $this->request->getRequiredBodyParam('fieldLayoutConfig'); - $cardElements = $this->request->getRequiredBodyParam('cardElements'); - $showThumb = $this->request->getBodyParam('showThumb', false); - $thumbAlignment = $this->request->getBodyParam('thumbAlignment', false); - - if (!isset($fieldLayoutConfig['id'])) { - $fieldLayout = Craft::createObject([ - 'class' => FieldLayout::class, - ...Component::cleanseConfig($fieldLayoutConfig), - ]); - $fieldLayout->type = $fieldLayoutConfig['type']; - } else { - $fieldLayout = Craft::$app->getFields()->getLayoutById($fieldLayoutConfig['id']); - } - - if (!$fieldLayout) { - throw new BadRequestHttpException('Invalid field layout'); - } - - $fieldLayout->setCardView( - array_column($cardElements, 'value') - ); // this fully takes care of attributes, but not fields - - $fieldLayout->setCardThumbAlignment($thumbAlignment); + $fieldLayout = Craft::$app->getFields()->createLayout($fieldLayoutConfig); return $this->asJson([ - 'previewHtml' => Cp::cardPreviewHtml($fieldLayout, $cardElements, $showThumb), + 'previewHtml' => Cp::cardPreviewHtml($fieldLayout), ]); } diff --git a/src/controllers/GraphqlController.php b/src/controllers/GraphqlController.php index cb216c60550..61e4ffea570 100644 --- a/src/controllers/GraphqlController.php +++ b/src/controllers/GraphqlController.php @@ -23,6 +23,7 @@ use craft\web\assets\graphiql\GraphiqlAsset; use craft\web\Controller; use craft\web\ErrorHandler; +use craft\web\Response; use DateTimeZone; use Throwable; use yii\base\Exception; @@ -32,7 +33,7 @@ use yii\web\BadRequestHttpException; use yii\web\ForbiddenHttpException; use yii\web\NotFoundHttpException; -use yii\web\Response; +use yii\web\Response as YiiResponse; /** * The GqlController class is a controller that handles various GraphQL related tasks. @@ -76,12 +77,12 @@ public function beforeAction($action): bool /** * Performs a GraphQL query. * - * @return Response + * @return YiiResponse * @throws BadRequestHttpException * @throws GqlException * @throws ForbiddenHttpException */ - public function actionApi(): Response + public function actionApi(): YiiResponse { // Add CORS headers $headers = $this->response->getHeaders(); @@ -105,12 +106,12 @@ public function actionApi(): Response if ($this->request->getIsOptions()) { // This is just a preflight request, no need to run the actual query yet - $this->response->format = Response::FORMAT_RAW; + $this->response->format = YiiResponse::FORMAT_RAW; $this->response->data = ''; return $this->response; } - $this->response->format = Response::FORMAT_JSON; + $this->response->format = YiiResponse::FORMAT_JSON; $gqlService = Craft::$app->getGql(); $schema = $this->_schema($gqlService); @@ -179,15 +180,17 @@ public function actionApi(): Response $generalConfig->generateTransformsBeforePageLoad = true; // Check for the cache-bust header - $noCache = $this->request->getHeaders()->get('x-craft-gql-cache', null, true) === 'no-cache'; - if ($noCache) { + $cacheHeader = $this->request->getHeaders()->get('x-craft-gql-cache'); + if ($cacheHeader === 'no-cache') { $cacheSetting = $generalConfig->enableGraphqlCaching; $generalConfig->enableGraphqlCaching = false; } $result = []; + $hasMutations = false; foreach ($queries as $key => [$query, $variables, $operationName]) { + $query = trim($query); try { if (empty($query)) { throw new InvalidValueException('No GraphQL query was supplied'); @@ -213,13 +216,28 @@ public function actionApi(): Response ], ]; } + + if (str_starts_with($query, 'mutation')) { + $hasMutations = true; + } } - if ($noCache) { + if (isset($cacheSetting)) { $generalConfig->enableGraphqlCaching = $cacheSetting; } - return $this->asJson($singleQuery ? reset($result) : $result); + $this->response->format = Response::FORMAT_GQL; + $this->response->data = $singleQuery ? reset($result) : $result; + + // send cache headers + $cache = isset($cacheHeader) ? $cacheHeader === 'cache' : !$hasMutations; + if ($cache) { + $this->response->setCacheHeaders(); + } else { + $this->response->setNoCacheHeaders(); + } + + return $this->response; } /** @@ -352,12 +370,12 @@ private function _enforceSiteAccess(GqlSchema $schema): void } /** - * @return Response + * @return YiiResponse * @throws ForbiddenHttpException * @throws InvalidConfigException * @throws BadRequestHttpException */ - public function actionGraphiql(): Response + public function actionGraphiql(): YiiResponse { $this->requireAdmin(false); $this->getView()->registerAssetBundle(GraphiqlAsset::class); @@ -403,12 +421,12 @@ public function actionGraphiql(): Response /** * Redirects to the GraphQL Schemas/Tokens page in the control panel. * - * @return Response + * @return YiiResponse * @throws NotFoundHttpException if this isn't a control panel request * @throws ForbiddenHttpException if the logged-in user isn't an admin * @since 3.5.0 */ - public function actionCpIndex(): Response + public function actionCpIndex(): YiiResponse { $generalConfig = Craft::$app->getConfig()->getGeneral(); if (!$this->request->getIsCpRequest() || !$generalConfig->enableGql) { @@ -425,11 +443,11 @@ public function actionCpIndex(): Response } /** - * @return Response + * @return YiiResponse * @throws ForbiddenHttpException * @since 3.4.0 */ - public function actionViewSchemas(): Response + public function actionViewSchemas(): YiiResponse { $this->requireAdmin(); @@ -442,12 +460,12 @@ public function actionViewSchemas(): Response /** * @param int|null $tokenId * @param GqlToken|null $token - * @return Response + * @return YiiResponse * @throws ForbiddenHttpException * @throws NotFoundHttpException * @since 3.4.0 */ - public function actionEditToken(?int $tokenId = null, ?GqlToken $token = null): Response + public function actionEditToken(?int $tokenId = null, ?GqlToken $token = null): YiiResponse { $this->requireAdmin(false); @@ -502,7 +520,7 @@ public function actionEditToken(?int $tokenId = null, ?GqlToken $token = null): } /** - * @return Response|null + * @return YiiResponse|null * @throws BadRequestHttpException * @throws ForbiddenHttpException * @throws NotFoundHttpException @@ -510,7 +528,7 @@ public function actionEditToken(?int $tokenId = null, ?GqlToken $token = null): * @throws Exception * @since 3.4.0 */ - public function actionSaveToken(): ?Response + public function actionSaveToken(): ?YiiResponse { $this->requirePostRequest(); $this->requireAdmin(false); @@ -551,11 +569,11 @@ public function actionSaveToken(): ?Response } /** - * @return Response + * @return YiiResponse * @throws BadRequestHttpException * @since 3.4.0 */ - public function actionDeleteToken(): Response + public function actionDeleteToken(): YiiResponse { $this->requirePostRequest(); $this->requireAcceptsJson(); @@ -570,11 +588,11 @@ public function actionDeleteToken(): Response /** - * @return Response + * @return YiiResponse * @throws ForbiddenHttpException * @since 3.4.0 */ - public function actionViewTokens(): Response + public function actionViewTokens(): YiiResponse { $this->requireAdmin(false); return $this->renderTemplate('graphql/tokens/_index.twig'); @@ -583,12 +601,12 @@ public function actionViewTokens(): Response /** * @param int|null $schemaId * @param GqlSchema|null $schema - * @return Response + * @return YiiResponse * @throws ForbiddenHttpException * @throws NotFoundHttpException * @since 3.4.0 */ - public function actionEditSchema(?int $schemaId = null, ?GqlSchema $schema = null): Response + public function actionEditSchema(?int $schemaId = null, ?GqlSchema $schema = null): YiiResponse { $this->requireAdmin(); @@ -617,12 +635,12 @@ public function actionEditSchema(?int $schemaId = null, ?GqlSchema $schema = nul /** * @param GqlSchema|null $schema - * @return Response + * @return YiiResponse * @throws ForbiddenHttpException * @throws NotFoundHttpException * @since 3.4.0 */ - public function actionEditPublicSchema(?GqlSchema $schema = null): Response + public function actionEditPublicSchema(?GqlSchema $schema = null): YiiResponse { $this->requireAdmin(); @@ -643,12 +661,12 @@ public function actionEditPublicSchema(?GqlSchema $schema = null): Response } /** - * @return Response|null + * @return YiiResponse|null * @throws ForbiddenHttpException * @throws NotFoundHttpException * @since 3.4.0 */ - public function actionSavePublicSchema(): ?Response + public function actionSavePublicSchema(): ?YiiResponse { $this->requirePostRequest(); $this->requireAdmin(); @@ -687,7 +705,7 @@ public function actionSavePublicSchema(): ?Response } /** - * @return Response|null + * @return YiiResponse|null * @throws BadRequestHttpException * @throws ForbiddenHttpException * @throws NotFoundHttpException @@ -695,7 +713,7 @@ public function actionSavePublicSchema(): ?Response * @throws Exception * @since 3.4.0 */ - public function actionSaveSchema(): ?Response + public function actionSaveSchema(): ?YiiResponse { $this->requirePostRequest(); $this->requireAdmin(); @@ -733,11 +751,11 @@ public function actionSaveSchema(): ?Response } /** - * @return Response + * @return YiiResponse * @throws BadRequestHttpException * @since 3.4.0 */ - public function actionDeleteSchema(): Response + public function actionDeleteSchema(): YiiResponse { $this->requirePostRequest(); $this->requireAcceptsJson(); @@ -751,10 +769,10 @@ public function actionDeleteSchema(): Response } /** - * @return Response + * @return YiiResponse * @throws BadRequestHttpException */ - public function actionFetchToken(): Response + public function actionFetchToken(): YiiResponse { $this->requirePostRequest(); $this->requireAcceptsJson(); @@ -775,9 +793,9 @@ public function actionFetchToken(): Response } /** - * @return Response + * @return YiiResponse */ - public function actionGenerateToken(): Response + public function actionGenerateToken(): YiiResponse { $this->requirePostRequest(); $this->requireAcceptsJson(); diff --git a/src/controllers/ImageTransformsController.php b/src/controllers/ImageTransformsController.php index 1439dab389c..08440b132cd 100644 --- a/src/controllers/ImageTransformsController.php +++ b/src/controllers/ImageTransformsController.php @@ -94,7 +94,7 @@ public function actionEdit(?string $transformHandle = null, ?ImageTransform $tra } } - $this->getView()->registerAssetBundle(EditTransformAsset::class); + $bundle = $this->getView()->registerAssetBundle(EditTransformAsset::class); if ($transform->id) { $title = trim($transform->name) ?: Craft::t('app', 'Edit Image Transform'); @@ -132,6 +132,7 @@ public function actionEdit(?string $transformHandle = null, ?ImageTransform $tra 'qualityPickerOptions' => $qualityPickerOptions, 'qualityPickerValue' => $qualityPickerValue, 'readOnly' => $this->readOnly, + 'baseIconsUrl' => $bundle->baseUrl, ]); } diff --git a/src/controllers/RelationalFieldsController.php b/src/controllers/RelationalFieldsController.php index 23c6fe45012..d54e7aae641 100644 --- a/src/controllers/RelationalFieldsController.php +++ b/src/controllers/RelationalFieldsController.php @@ -59,7 +59,7 @@ public function actionStructuredInputHtml(): Response } } - ElementHelper::swapInProvisionalDrafts($elements); + ElementHelper::loadProvisionalChanges($elements); $html = $this->getView()->renderTemplate('_includes/forms/elementSelect.twig', [ 'elements' => $elements, diff --git a/src/controllers/UserSettingsController.php b/src/controllers/UserSettingsController.php index c063b1e6782..5162fec8432 100644 --- a/src/controllers/UserSettingsController.php +++ b/src/controllers/UserSettingsController.php @@ -9,6 +9,7 @@ use Craft; use craft\enums\CmsEdition; +use craft\helpers\Cp; use craft\models\UserGroup; use craft\web\Controller; use yii\web\BadRequestHttpException; @@ -95,28 +96,44 @@ public function actionEditGroup(?int $groupId = null, ?UserGroup $group = null): ['label' => Craft::t('app', 'User Groups'), 'url' => 'settings/users'], ]; - $formActions = [ - [ - 'label' => Craft::t('app', 'Save and continue editing'), - 'redirect' => Craft::$app->getSecurity()->hashData('settings/users/groups/{id}'), - 'shortcut' => true, - 'retainScroll' => true, - ], - ]; - if ($group->id) { $title = trim($group->name) ?: Craft::t('app', 'Edit User Group'); } else { $title = Craft::t('app', 'Create a new user group'); } - return $this->renderTemplate('settings/users/groups/_edit.twig', [ - 'group' => $group, - 'crumbs' => $crumbs, - 'formActions' => $formActions, - 'title' => $title, - 'readOnly' => $this->readOnly, - ]); + $response = $this->asCpScreen() + ->editUrl($group->getCpEditUrl()) + ->title($title) + ->crumbs($crumbs) + ->addAltAction(Craft::t('app', 'Save and continue editing'), [ + 'redirect' => 'settings/users/groups/{id}', + 'shortcut' => true, + 'retainScroll' => true, + ]) + ->action('user-settings/save-group') + ->redirectUrl('settings/users') + ->contentTemplate('settings/users/groups/_edit.twig', [ + 'group' => $group, + 'readOnly' => $this->readOnly, + ]) + ->prepareScreen(function(Response $response, string $containerId) use ($group) { + if ($group->id) { + $this->view->registerJsWithVars(fn($containerId) => <<readOnly) { + $response->noticeHtml(Cp::readOnlyNoticeHtml()); + } + + return $response; } /** @@ -152,14 +169,7 @@ public function actionSaveGroup(): ?Response // Did it save? if (!Craft::$app->getUserGroups()->saveGroup($group)) { - $this->setFailFlash(Craft::t('app', 'Couldn’t save group.')); - - // Send the group back to the template - Craft::$app->getUrlManager()->setRouteParams([ - 'group' => $group, - ]); - - return null; + return $this->asModelFailure($group, Craft::t('app', 'Couldn’t save group.'), 'group'); } // Save the new permissions @@ -188,10 +198,11 @@ public function actionSaveGroup(): ?Response Craft::$app->getUserPermissions()->saveGroupPermissions($group->id, $permissions); - $this->setSuccessFlash(Craft::$app->edition === CmsEdition::Team + $message = Craft::$app->edition === CmsEdition::Team ? Craft::t('app', 'Permissions saved.') - : Craft::t('app', 'Group saved.')); - return $this->redirectToPostedUrl($group); + : Craft::t('app', 'Group saved.'); + + return $this->asModelSuccess($group, $message, 'group'); } /** diff --git a/src/elements/Address.php b/src/elements/Address.php index 0a3d3124643..3e30b5ae85e 100644 --- a/src/elements/Address.php +++ b/src/elements/Address.php @@ -116,6 +116,20 @@ protected static function defineActions(string $source): array ]; } + /** + * @inheritdoc + */ + protected static function defineCardAttributes(): array + { + return [ + ...parent::defineCardAttributes(), + 'address' => [ + 'label' => Craft::t('app', 'Address'), + 'placeholder' => fn() => '123 Acme Ln.', + ], + ]; + } + /** * @inheritdoc */ @@ -126,6 +140,16 @@ protected static function defineTableAttributes(): array ]); } + /** + * @inheritdoc + */ + protected static function defineDefaultCardAttributes(): array + { + return [ + 'address', + ]; + } + /** * @inheritdoc */ @@ -581,6 +605,16 @@ public function getGqlTypeName(): string return self::GQL_TYPE_NAME; } + /** + * Returns whether the element’s `title` attribute should be validated + * @return bool + */ + protected function shouldValidateTitle(): bool + { + $titleField = $this->getFieldLayout()?->getField('title'); + return $titleField->required && $titleField->showInForm($this); + } + /** * @inheritdoc */ @@ -700,6 +734,14 @@ public function defineRules(): array return $rules; } + /** + * @inheritdoc + */ + public function getUiLabel(): string + { + return $this->title ?? ''; + } + /** * @inheritdoc */ diff --git a/src/elements/Entry.php b/src/elements/Entry.php index b57eb4447d5..7d32786bc6c 100644 --- a/src/elements/Entry.php +++ b/src/elements/Entry.php @@ -55,6 +55,7 @@ use craft\helpers\Db; use craft\helpers\ElementHelper; use craft\helpers\Html; +use craft\helpers\StringHelper; use craft\helpers\UrlHelper; use craft\models\EntryType; use craft\models\FieldLayout; @@ -236,6 +237,14 @@ public static function createCondition(): ElementConditionInterface return Craft::createObject(EntryCondition::class, [static::class]); } + /** + * @inheritdoc + */ + public static function multiPageSources(): bool + { + return true; + } + /** * @inheritdoc */ @@ -439,6 +448,7 @@ protected static function defineActions(string $source): array $user->can('createEntries:' . $section->uid) ) { $newEntryUrl = 'entries/' . $section->handle . '/new'; + //$newEntryUrl = sprintf('%s/new', $section->getCpIndexUri()); if (Craft::$app->getIsMultiSite()) { $newEntryUrl .= '?site=' . $site->handle; @@ -1273,34 +1283,51 @@ protected function crumbs(): array return []; } + $page = $section->getPage(); + $crumbs = [ [ - 'label' => Craft::t('app', 'Entries'), - 'url' => 'entries', + 'label' => $page && $page !== 'Entries' ? Craft::t('site', $page) : Craft::t('app', 'Entries'), + 'url' => sprintf('content/%s', $page ? StringHelper::toKebabCase($page) : 'entries'), ], ]; // If the section’s source is disabled, just show its name w/o a link $sourceKey = $section->type === Section::TYPE_SINGLE ? 'singles' : "section:$section->uid"; - if (Craft::$app->getElementSources()->sourceExists(Entry::class, $sourceKey)) { + if (Craft::$app->getElementSources()->sourceExists(Entry::class, $sourceKey, withDisabled: true)) { $sections = Collection::make(Craft::$app->getEntries()->getEditableSections()); + $requestedSite = Cp::requestedSite(); if ($requestedSite) { $sections = $sections->filter(fn(Section $s) => in_array($requestedSite->id, $s->getSiteIds())); } + + if ($page) { + // Filter out any sections that don’t belong in this page + $pageSources = Craft::$app->getElementSources()->getSources(Entry::class, withDisabled: true, page: $page); + $pageSourceKeys = array_flip(array_filter(array_map(fn(array $source) => $source['key'] ?? null, $pageSources))); + $sections = $sections->filter(function(Section $s) use ($pageSourceKeys) { + $key = $s->type === Section::TYPE_SINGLE ? 'singles' : "section:$s->uid"; + return isset($pageSourceKeys[$key]); + }); + } + /** @var Collection $sectionOptions */ $sectionOptions = $sections ->filter(fn(Section $s) => $s->type !== Section::TYPE_SINGLE) ->map(fn(Section $s) => [ 'label' => Craft::t('site', $s->name), 'url' => "entries/$s->handle", + //'url' => $s->getCpIndexUri(), 'selected' => $s->id === $section->id, ]); - if ($sections->contains(fn(Section $s) => $s->type === Section::TYPE_SINGLE)) { + /** @var Section|null $firstSingle */ + $firstSingle = $sections->first(fn(Section $s) => $s->type === Section::TYPE_SINGLE); + if ($firstSingle) { $sectionOptions->prepend([ 'label' => Craft::t('app', 'Singles'), - 'url' => 'entries/singles', + 'url' => $firstSingle->getCpIndexUri(), 'selected' => $section->type === Section::TYPE_SINGLE, ]); } @@ -1358,6 +1385,13 @@ public function getUiLabel(): string */ protected function uiLabel(): ?string { + if ($this->getType()->uiLabelFormat !== '{title}') { + $uiLabel = Craft::$app->getView()->renderObjectTemplate($this->getType()->uiLabelFormat, $this); + if ($uiLabel !== '') { + return $uiLabel; + } + } + if (!$this->fieldId && (!isset($this->title) || trim($this->title) === '')) { $section = $this->getSection(); if ($section?->type === Section::TYPE_SINGLE) { @@ -2132,7 +2166,7 @@ protected function cpEditUrl(): ?string return ElementHelper::elementEditorUrl($this, false); } - $path = sprintf('entries/%s/%s', $section->handle, $this->getCanonicalId()); + $path = sprintf('%s/%s', $section->getCpIndexUri(), $this->getCanonicalId()); // Ignore homepage/temp slugs if ($this->slug && !str_starts_with($this->slug, '__')) { diff --git a/src/elements/NestedElementManager.php b/src/elements/NestedElementManager.php index d6daf906085..c989f8bcead 100644 --- a/src/elements/NestedElementManager.php +++ b/src/elements/NestedElementManager.php @@ -420,8 +420,8 @@ function(string $id, array $config, $attribute, &$settings) use ($owner) { ->all(); } - // See if there are any provisional drafts we should swap these out with - ElementHelper::swapInProvisionalDrafts($elements); + // See if there are any provisional changes we should show + ElementHelper::loadProvisionalChanges($elements); if ($this->hasErrors($owner)) { foreach ($elements as $element) { @@ -441,7 +441,6 @@ function(string $id, array $config, $attribute, &$settings) use ($owner) { 'showActionMenu' => true, 'sortable' => $config['sortable'], 'showInGrid' => $config['showInGrid'] ?? false, - 'hyperlink' => false, ]), $elements, ), [ @@ -556,17 +555,17 @@ function(string $id, array $config, string $attribute, array &$settings) use ($o } return Cp::elementIndexHtml($this->elementType, [ + 'class' => [$config['prevalidate'] ? 'prevalidate' : ''], 'context' => 'embedded-index', - 'id' => $id, - 'showSiteMenu' => false, - 'sources' => false, - 'fieldLayouts' => $config['fieldLayouts'], 'defaultSort' => $config['defaultSort'], 'defaultTableColumns' => $config['defaultTableColumns'], 'defaultViewMode' => $config['defaultViewMode'], - 'registerJs' => false, - 'class' => [$config['prevalidate'] ? 'prevalidate' : ''], + 'fieldLayouts' => $config['fieldLayouts'], + 'id' => $id, 'prevalidate' => $config['prevalidate'] ?? false, + 'registerJs' => false, + 'showSiteMenu' => false, + 'sources' => false, ]); }, ); @@ -696,7 +695,11 @@ public function maintainNestedElements(ElementInterface $owner, bool $isNew): vo $this->duplicateNestedElements($owner->duplicateOf, $owner, true, !$isNew); } $resetValue = true; - } elseif ($this->isDirty($owner) || !empty($owner->newSiteIds)) { + } elseif ( + $this->isDirty($owner) || + $this->propagateRequired($owner) || + !empty($owner->newSiteIds) + ) { $this->saveNestedElements($owner); } elseif ($owner->mergingCanonicalChanges) { $this->mergeCanonicalChanges($owner); @@ -778,6 +781,23 @@ private function fieldInstances(ElementInterface $owner): Generator } } + private function propagateRequired(ElementInterface $owner, ?ElementInterface $localizedOwner = null): bool + { + foreach ($this->fieldInstances($owner) as $instance) { + if ( + $instance->layoutElement->required && + ( + !$localizedOwner || + $instance->isValueEmpty($localizedOwner->getFieldValue($instance->handle), $localizedOwner) + ) + ) { + return true; + } + } + + return false; + } + private function saveNestedElements(ElementInterface $owner): void { $elementsService = Craft::$app->getElements(); @@ -814,6 +834,11 @@ private function saveNestedElements(ElementInterface $owner): void $elementsService->restoreElement($element); } + // if the owner is propagating required fields and attributes, so should the nested elements + if ($owner->propagateRequired) { + $element->propagateRequired = true; + } + $sortOrder++; if ($saveAll || !$element->id || $element->forceSave) { $element->setOwner($owner); @@ -867,7 +892,11 @@ private function saveNestedElements(ElementInterface $owner): void // Should we duplicate the elements to other sites? if ( $this->propagationMethod !== PropagationMethod::All && - ($owner->propagateAll || !empty($owner->newSiteIds)) + ( + $owner->propagateAll || + $this->propagateRequired($owner) || + !empty($owner->newSiteIds) + ) ) { // Find the owner's site IDs that *aren't* supported by this site's nested elements $ownerSiteIds = array_map( @@ -877,8 +906,8 @@ private function saveNestedElements(ElementInterface $owner): void $fieldSiteIds = $this->getSupportedSiteIds($owner); $otherSiteIds = array_diff($ownerSiteIds, $fieldSiteIds); - // If propagateAll isn't set, only deal with sites that the element was just propagated to for the first time - if (!$owner->propagateAll) { + // If propagateAll & propagateRequired aren't set, only deal with sites that the element was just propagated to for the first time + if (!$owner->propagateAll && !$this->propagateRequired($owner)) { $preexistingOtherSiteIds = array_diff($otherSiteIds, $owner->newSiteIds); $otherSiteIds = array_intersect($otherSiteIds, $owner->newSiteIds); } else { @@ -932,7 +961,9 @@ private function saveNestedElements(ElementInterface $owner): void } else { // Duplicate the elements, but **don't track** the duplications, so the edit page doesn’t think // its elements have been replaced by the other sites’ nested elements - $this->duplicateNestedElements($owner, $localizedOwner, force: true); + if ($owner->propagateAll || $this->propagateRequired($owner, $localizedOwner)) { + $this->duplicateNestedElements($owner, $localizedOwner, force: true); + } } // Make sure we don't duplicate elements for any of the sites that were just propagated to @@ -1010,7 +1041,7 @@ private function deleteOtherNestedElements(ElementInterface $owner, array $excep * which weren’t included in the duplication * @param bool $force Whether to force duplication, even if it looks like only the nested element ownership was duplicated */ - private function duplicateNestedElements( + public function duplicateNestedElements( ElementInterface $source, ElementInterface $target, bool $checkOtherSites = false, diff --git a/src/elements/User.php b/src/elements/User.php index 7cae9ea623c..242e844fef4 100644 --- a/src/elements/User.php +++ b/src/elements/User.php @@ -993,7 +993,7 @@ protected function defineRules(): array $rules[] = [['lastLoginDate', 'lastInvalidLoginDate', 'lockoutDate', 'lastPasswordChangeDate', 'verificationCodeIssuedDate'], DateTimeValidator::class]; $rules[] = [['invalidLoginCount', 'photoId', 'affiliatedSiteId'], 'number', 'integerOnly' => true]; $rules[] = [['username', 'email', 'unverifiedEmail', 'fullName', 'firstName', 'lastName'], 'trim', 'skipOnEmpty' => true]; - $rules[] = [['email', 'unverifiedEmail'], 'email', 'enableIDN' => App::supportsIdn(), 'enableLocalIDN' => false]; + $rules[] = [['email', 'unverifiedEmail'], 'email', 'enableIDN' => App::supportsIdn(), 'enableLocalIDN' => App::supportsIdn()]; $rules[] = [['email', 'username', 'fullName', 'firstName', 'lastName', 'password', 'unverifiedEmail'], 'string', 'max' => 255]; $rules[] = [['verificationCode'], 'string', 'max' => 100]; $rules[] = [['email'], 'required', 'when' => fn() => !$this->getIsDraft()]; @@ -1511,6 +1511,36 @@ public function isInGroup(UserGroup|int|string $group): bool return ArrayHelper::contains($this->getGroups(), fn(UserGroup $g) => $g->handle === $group); } + /** + * Returns whether the user is in any/all the given user groups. + * + * By default, `true` will be returned if the user is in *any* of the groups. To change that so `true` is only + * returned if the user is in *all* of the groups, pass `true` to the second argument. + * + * @param array $groups The user groups, handles, or IDs + * @param bool $all Whether to only return `true` if the user is in *all* of the provided groups + * @return bool + * @since 5.9.0 + */ + public function isInGroups(array $groups, bool $all = false): bool + { + if (!$all) { + foreach ($groups as $group) { + if ($this->isInGroup($group)) { + return true; + } + } + return false; + } + + foreach ($groups as $group) { + if (!$this->isInGroup($group)) { + return false; + } + } + return true; + } + /** * Returns the user’s full name. * @@ -1843,11 +1873,17 @@ final public function canRegisterUsers(): bool */ public function canAssignUserGroups(): bool { - if (Craft::$app->edition->value >= CmsEdition::Pro->value) { - foreach (Craft::$app->getUserGroups()->getAllGroups() as $group) { - if ($this->can("assignUserGroup:$group->uid")) { - return true; - } + if (Craft::$app->edition->value < CmsEdition::Pro->value) { + return false; + } + + if ($this->admin) { + return true; + } + + foreach (Craft::$app->getUserGroups()->getAllGroups() as $group) { + if ($this->can("assignUserGroup:$group->uid")) { + return true; } } @@ -2510,7 +2546,10 @@ public function getGqlTypeName(): string */ final public function beforeSave(bool $isNew): bool { - if ($isNew && !Craft::$app->getUsers()->canCreateUsers()) { + if ( + ($isNew || $this->applyingDraft) && + !Craft::$app->getUsers()->canCreateUsers() + ) { return false; } diff --git a/src/elements/conditions/HintableConditionRuleTrait.php b/src/elements/conditions/HintableConditionRuleTrait.php new file mode 100644 index 00000000000..7b202b370ca --- /dev/null +++ b/src/elements/conditions/HintableConditionRuleTrait.php @@ -0,0 +1,27 @@ + + * @since 5.9.0 + */ +trait HintableConditionRuleTrait +{ + /** + * @inheritdoc + */ + public function showLabelHint(): bool + { + return Craft::$app->getUser()->getIdentity()?->getPreference('showFieldHandles') ?? false; + } +} diff --git a/src/elements/conditions/addresses/FieldConditionRule.php b/src/elements/conditions/addresses/FieldConditionRule.php index 2d9ae4fcce3..7fecb407ae3 100644 --- a/src/elements/conditions/addresses/FieldConditionRule.php +++ b/src/elements/conditions/addresses/FieldConditionRule.php @@ -7,6 +7,7 @@ use craft\base\ElementInterface; use craft\elements\Address; use craft\elements\conditions\ElementConditionRuleInterface; +use craft\elements\conditions\HintableConditionRuleTrait; use craft\elements\db\AddressQuery; use craft\elements\db\ElementQueryInterface; use craft\fields\Addresses; @@ -20,6 +21,8 @@ */ class FieldConditionRule extends BaseMultiSelectConditionRule implements ElementConditionRuleInterface { + use HintableConditionRuleTrait; + /** * @inheritdoc */ @@ -48,7 +51,10 @@ protected function options(): array { return Collection::make($this->addressFields()) ->keyBy(fn(Addresses $field) => $field->uid) - ->map(fn(Addresses $field) => $field->getUiLabel()) + ->map( + fn(Addresses $field) => + $field->getUiLabel() . ($this->showLabelHint() ? " ($field->handle)" : '') + ) ->all(); } diff --git a/src/elements/conditions/entries/FieldConditionRule.php b/src/elements/conditions/entries/FieldConditionRule.php index d424b789181..c08f14e80bd 100644 --- a/src/elements/conditions/entries/FieldConditionRule.php +++ b/src/elements/conditions/entries/FieldConditionRule.php @@ -7,6 +7,7 @@ use craft\base\ElementContainerFieldInterface; use craft\base\ElementInterface; use craft\elements\conditions\ElementConditionRuleInterface; +use craft\elements\conditions\HintableConditionRuleTrait; use craft\elements\db\ElementQueryInterface; use craft\elements\db\EntryQuery; use craft\elements\Entry; @@ -20,6 +21,8 @@ */ class FieldConditionRule extends BaseMultiSelectConditionRule implements ElementConditionRuleInterface { + use HintableConditionRuleTrait; + /** * @inheritdoc */ @@ -48,7 +51,10 @@ protected function options(): array { return $this->nestedEntryFields() ->keyBy(fn(ElementContainerFieldInterface $field) => $field->uid) - ->map(fn(ElementContainerFieldInterface $field) => $field->getUiLabel()) + ->map( + fn(ElementContainerFieldInterface $field) => + $field->getUiLabel() . ($this->showLabelHint() ? " ($field->handle)" : '') + ) ->all(); } diff --git a/src/elements/conditions/entries/SectionConditionRule.php b/src/elements/conditions/entries/SectionConditionRule.php index fde48610925..68b24fe8428 100644 --- a/src/elements/conditions/entries/SectionConditionRule.php +++ b/src/elements/conditions/entries/SectionConditionRule.php @@ -6,10 +6,12 @@ use craft\base\conditions\BaseMultiSelectConditionRule; use craft\base\ElementInterface; use craft\elements\conditions\ElementConditionRuleInterface; +use craft\elements\conditions\HintableConditionRuleTrait; use craft\elements\db\ElementQueryInterface; use craft\elements\db\EntryQuery; use craft\elements\Entry; -use craft\helpers\ArrayHelper; +use craft\models\Section; +use Illuminate\Support\Collection; /** * Entry section condition rule. @@ -19,6 +21,8 @@ */ class SectionConditionRule extends BaseMultiSelectConditionRule implements ElementConditionRuleInterface { + use HintableConditionRuleTrait; + /** * @inheritdoc */ @@ -56,8 +60,12 @@ protected function operators(): array */ protected function options(): array { - $sections = Craft::$app->getEntries()->getAllSections(); - return ArrayHelper::map($sections, 'uid', 'name'); + $sections = new Collection(Craft::$app->getEntries()->getAllSections()); + + return $sections + ->keyBy('uid') + ->map(fn(Section $section) => $section->name . ($this->showLabelHint() ? " ($section->handle)" : '')) + ->all(); } /** diff --git a/src/elements/db/GlobalSetQuery.php b/src/elements/db/GlobalSetQuery.php index cd74a84f902..a2ac37201a3 100644 --- a/src/elements/db/GlobalSetQuery.php +++ b/src/elements/db/GlobalSetQuery.php @@ -157,4 +157,10 @@ private function _applyEditableParam(): void $this->subQuery->andWhere(['elements.id' => $editableSetIds]); } } + + public function getCacheTags(): array + { + // no need to register cache tags for global set queries + return []; + } } diff --git a/src/elements/db/NestedElementQueryTrait.php b/src/elements/db/NestedElementQueryTrait.php index fb44a86c08e..53a677fe391 100644 --- a/src/elements/db/NestedElementQueryTrait.php +++ b/src/elements/db/NestedElementQueryTrait.php @@ -51,6 +51,7 @@ trait NestedElementQueryTrait /** * @var ElementInterface|null The owner element specified by [[owner()]]. * @used-by owner() + * @used-by primaryOwner() */ private ?ElementInterface $_owner = null; @@ -128,6 +129,7 @@ public function fieldId(mixed $value): static public function primaryOwnerId(mixed $value): static { $this->primaryOwnerId = $value; + $this->_owner = null; return $this; } @@ -140,6 +142,7 @@ public function primaryOwner(ElementInterface $primaryOwner): static { $this->primaryOwnerId = [$primaryOwner->id]; $this->siteId = $primaryOwner->siteId; + $this->_owner = $primaryOwner; return $this; } @@ -304,6 +307,10 @@ public function createElement(array $row): ElementInterface { if (isset($this->_owner)) { $row['owner'] = $this->_owner; + + if (isset($row['primaryOwnerId']) && $row['primaryOwnerId'] == $this->_owner->id) { + $row['primaryOwner'] = $this->_owner; + } } return parent::createElement($row); diff --git a/src/events/DefineFieldActionsEvent.php b/src/events/DefineFieldActionsEvent.php new file mode 100644 index 00000000000..2518bd7f5ed --- /dev/null +++ b/src/events/DefineFieldActionsEvent.php @@ -0,0 +1,30 @@ + + * @since 5.9.0 + */ +class DefineFieldActionsEvent extends DefineMenuItemsEvent +{ + /** + * @var ElementInterface|null $element The element the form is being rendered for + */ + public ?ElementInterface $element = null; + + /** + * @var bool $static Whether the form should be static (non-interactive) + */ + public bool $static; +} diff --git a/src/events/DefineGqlArgumentsEvent.php b/src/events/DefineGqlArgumentsEvent.php new file mode 100644 index 00000000000..24d76864d13 --- /dev/null +++ b/src/events/DefineGqlArgumentsEvent.php @@ -0,0 +1,24 @@ + + * @since 5.9.0 + */ +class DefineGqlArgumentsEvent extends Event +{ + /** + * @var array List of arguments + */ + public array $arguments = []; +} diff --git a/src/events/ElementStructureEvent.php b/src/events/ElementStructureEvent.php index 4c3cb2cca24..4073b408ec2 100644 --- a/src/events/ElementStructureEvent.php +++ b/src/events/ElementStructureEvent.php @@ -12,10 +12,6 @@ * * @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/RegisterElementCardAttributesEvent.php b/src/events/RegisterElementCardAttributesEvent.php index 2519d5e96ad..7b0bc84782b 100644 --- a/src/events/RegisterElementCardAttributesEvent.php +++ b/src/events/RegisterElementCardAttributesEvent.php @@ -7,6 +7,7 @@ namespace craft\events; +use craft\models\FieldLayout; use yii\base\Event; /** @@ -21,4 +22,10 @@ class RegisterElementCardAttributesEvent extends Event * @var array List of registered card attributes for the element type. */ public array $cardAttributes = []; + + /** + * @var FieldLayout|null The field layout associated with the card designer + * @since 5.9.0 + */ + public ?FieldLayout $fieldLayout = null; } diff --git a/src/fieldlayoutelements/BaseField.php b/src/fieldlayoutelements/BaseField.php index fdafd38428a..ff1f8b7cb90 100644 --- a/src/fieldlayoutelements/BaseField.php +++ b/src/fieldlayoutelements/BaseField.php @@ -10,6 +10,7 @@ use Craft; use craft\base\ElementInterface; use craft\base\FieldLayoutElement; +use craft\events\DefineFieldActionsEvent; use craft\helpers\ArrayHelper; use craft\helpers\Cp; use craft\helpers\ElementHelper; @@ -24,6 +25,13 @@ */ abstract class BaseField extends FieldLayoutElement { + /** + * @event DefineFieldActionsEvent The event that is triggered when defining action menu items. + * @see actionMenuItems() + * @since 5.9.0 + */ + public const EVENT_DEFINE_ACTION_MENU_ITEMS = 'defineActionMenuItems'; + /** * @var string|null The field’s label */ @@ -52,12 +60,14 @@ abstract class BaseField extends FieldLayoutElement /** * @var bool Whether this field should be used to define element thumbnails. * @since 5.0.0 + * @deprecated in 5.9.0 */ public bool $providesThumbs = false; /** * @var bool Whether this field’s contents should be included in element cards. * @since 5.0.0 + * @deprecated in 5.9.0 */ public bool $includeInCards = false; @@ -73,6 +83,16 @@ public function __construct($config = []) parent::__construct($config); } + /** + * @inheritdoc + */ + public function fields(): array + { + $fields = parent::fields(); + unset($fields['includeInCards'], $fields['providesThumbs']); + return $fields; + } + /** * Returns the element attribute this field is for. * @@ -80,6 +100,17 @@ public function __construct($config = []) */ abstract public function attribute(): string; + /** + * Returns the key for this field. + * + * @return string + * @since 5.9.0 + */ + public function key(): string + { + return $this->attribute(); + } + /** * Returns whether the attribute should be shown for admin users with “Show field handles in edit forms” enabled. * @@ -158,6 +189,26 @@ public function previewable(): bool return false; } + /** + * Returns the card preview options supplied by this field. + * + * @return array|null + * @since 5.9.0 + */ + public function getPreviewOptions(): ?array + { + if (!$this->previewable()) { + return null; + } + + return [ + [ + 'label' => $this->selectorLabel() ?? $this->attribute(), + 'value' => $this->attribute(), + ], + ]; + } + /** * @inheritdoc */ @@ -236,7 +287,7 @@ protected function selectorAttributes(): array 'mandatory' => $this->mandatory(), 'requirable' => $this->requirable(), 'thumbable' => $this->thumbable(), - 'previewable' => $this->previewable(), + 'preview-options' => $this->getPreviewOptions(), ], ]; } @@ -308,22 +359,6 @@ protected function selectorIndicators(): array ]; } - if ($this->thumbable() && $this->providesThumbs) { - $indicators[] = [ - 'label' => Craft::t('app', 'This field provides thumbnails for elements'), - 'icon' => 'image', - 'iconColor' => 'violet', - ]; - } - - if ($this->previewable() && $this->includeInCards) { - $indicators[] = [ - 'label' => Craft::t('app', 'This field is included in element cards'), - 'icon' => 'eye', - 'iconColor' => 'blue', - ]; - } - return $indicators; } @@ -375,6 +410,16 @@ public function formHtml(?ElementInterface $element = null, bool $static = false $translatable = $this->translatable($element, $static); $actionMenuItems = $this->actionMenuItems($element, $static); + if ($this->hasEventHandlers(self::EVENT_DEFINE_ACTION_MENU_ITEMS)) { + $event = new DefineFieldActionsEvent([ + 'element' => $element, + 'static' => $static, + 'items' => $actionMenuItems, + ]); + $this->trigger(self::EVENT_DEFINE_ACTION_MENU_ITEMS, $event); + $actionMenuItems = $event->items; + } + if ( $this->uid && $element?->id && @@ -854,6 +899,47 @@ protected function actionMenuItems(?ElementInterface $element = null, bool $stat return []; } + /** + * Returns a “Copy field handle” action menu item definition for [[actionMenuItems()]]. + * + * @param array $config + * @return array + * @since 5.9.0 + */ + protected function copyAttributeAction(array $config = []): array + { + $config += [ + 'id' => sprintf('action-copy-handle-%s', mt_rand()), + 'icon' => 'clipboard', + 'label' => Craft::t('app', 'Copy attribute name'), + 'promptLabel' => Craft::t('app', 'Attribute Name'), + 'attribute' => $this->attribute(), + ]; + + $view = Craft::$app->getView(); + + $view->registerJsWithVars(fn($id, $promptLabel, $attribute) => << { + $('#' + $id).on('activate', () => { + Craft.ui.createCopyTextPrompt({ + label: $promptLabel, + value: $attribute, + }); + }); +})(); +JS, [ + $view->namespaceInputId($config['id']), + $config['promptLabel'], + $config['attribute'], + ]); + + return [ + 'id' => $config['id'], + 'icon' => $config['icon'], + 'label' => $config['label'], + ]; + } + /** * Return the HTML that should be shown for the native field in the card preview. * It can be used outside an element context, e.g. in a card view designer. diff --git a/src/fieldlayoutelements/CustomField.php b/src/fieldlayoutelements/CustomField.php index fdbf8666e2e..6ae59757fb9 100644 --- a/src/fieldlayoutelements/CustomField.php +++ b/src/fieldlayoutelements/CustomField.php @@ -14,9 +14,11 @@ use craft\base\FieldInterface; use craft\base\PreviewableFieldInterface; use craft\base\ThumbableFieldInterface; +use craft\elements\conditions\ElementConditionInterface; use craft\elements\conditions\users\UserCondition; use craft\elements\User; use craft\errors\FieldNotFoundException; +use craft\fields\ContentBlock; use craft\helpers\ArrayHelper; use craft\helpers\Cp; use craft\helpers\Html; @@ -41,6 +43,11 @@ class CustomField extends BaseField */ private static UserCondition $defaultEditCondition; + /** + * @var ElementConditionInterface[] + */ + private static array $defaultElementEditConditions = []; + /** * @return UserCondition */ @@ -49,6 +56,15 @@ private static function defaultEditCondition(): UserCondition return self::$defaultEditCondition ??= User::createCondition(); } + /** + * @param class-string $elementType + * @return ElementConditionInterface + */ + private static function defaultElementEditCondition(string $elementType): ElementConditionInterface + { + return self::$defaultElementEditConditions[$elementType] ??= $elementType::createCondition(); + } + /** * @var string|null The field handle override. * @since 5.0.0 @@ -69,6 +85,14 @@ private static function defaultEditCondition(): UserCondition */ private mixed $_editCondition = null; + /** + * @var ElementConditionInterface|class-string|array|null + * @phpstan-var ElementConditionInterface|class-string|array{class:class-string}|null + * @see getElementEditCondition() + * @see setElementEditCondition() + */ + private mixed $_elementEditCondition = null; + /** * @inheritdoc * @param FieldInterface|null $field @@ -120,6 +144,22 @@ public function attribute(): string return $field->handle; } + /** + * @inheritdoc + */ + public function key(): string + { + try { + $field = $this->getField(); + } catch (FieldNotFoundException) { + $field = null; + } + + $prefix = $field instanceof ContentBlock ? 'contentBlock' : 'layoutElement'; + $uid = $this->uid ?? '{uid}'; + return "$prefix:$uid"; + } + /** * @inheritdoc */ @@ -189,6 +229,42 @@ public function previewable(): bool return $field instanceof PreviewableFieldInterface; } + /** + * @inheritdoc + */ + public function getPreviewOptions(): ?array + { + try { + $field = $this->getField(); + } catch (FieldNotFoundException) { + return null; + } + + if ($field instanceof ContentBlock) { + $options = []; + $label = $this->selectorLabel(); + $nestedOptions = Cp::cardPreviewOptions($field->getFieldLayout(), false); + foreach ($nestedOptions as $key => $option) { + $options[] = [ + 'label' => "$label - {$option['label']}", + 'value' => "contentBlock:{uid}.$key", + ]; + } + return $options; + } + + if (!$this->previewable()) { + return null; + } + + return [ + [ + 'label' => $this->selectorLabel() ?? $this->attribute(), + 'value' => 'layoutElement:{uid}', + ], + ]; + } + /** * @inheritdoc */ @@ -324,7 +400,7 @@ public function getOriginalHandle(): string */ public function hasConditions(): bool { - return parent::hasConditions() || $this->getEditCondition(); + return parent::hasConditions() || $this->getEditCondition() || $this->getElementEditCondition(); } /** @@ -354,6 +430,40 @@ public function setEditCondition(mixed $editCondition): void $this->_editCondition = $editCondition; } + /** + * Returns the element edit condition for this layout element. + * + * @return ElementConditionInterface|null + * @since 5.9.0 + */ + public function getElementEditCondition(): ?ElementConditionInterface + { + if (isset($this->_elementEditCondition) && !$this->_elementEditCondition instanceof ElementConditionInterface) { + if (is_string($this->_elementEditCondition)) { + $this->_elementEditCondition = ['class' => $this->_elementEditCondition]; + } + $this->_elementEditCondition = array_merge( + ['fieldLayouts' => [$this->getLayout()]], + $this->_elementEditCondition, + ); + $this->_elementEditCondition = $this->normalizeCondition($this->_elementEditCondition); + } + + return $this->_elementEditCondition; + } + + /** + * Sets the element edit condition for this layout element. + * + * @param ElementConditionInterface|class-string|array|null $elementEditCondition + * @phpstan-param ElementConditionInterface|class-string|array{class:class-string}|null $elementEditCondition + * @since 5.9.0 + */ + public function setElementEditCondition(mixed $elementEditCondition): void + { + $this->_elementEditCondition = $elementEditCondition; + } + /** * @inheritdoc */ @@ -363,6 +473,7 @@ public function fields(): array ...parent::fields(), 'fieldUid' => 'fieldUid', 'editCondition' => fn() => $this->getEditCondition()?->getConfig(), + 'elementEditCondition' => fn() => $this->getElementEditCondition()?->getConfig(), ]; } @@ -561,26 +672,50 @@ protected function conditionalSettingsHtml(): string $editCondition->name = 'editCondition'; $editCondition->forProjectConfig = true; - $html .= Html::beginTag('fieldset', ['class' => 'pane']) . + $editConditionsHtml = Cp::fieldHtml($editCondition->getBuilderHtml(), [ + 'label' => Craft::t('app', 'Current User Condition'), + 'instructions' => Craft::t('app', 'Only make editable for users who match the following rules:'), + ]); + + // Do we know the element type? + /** @var class-string|string|null $elementType */ + $elementType = $this->elementType ?? $this->getLayout()->type; + + if ($elementType && is_subclass_of($elementType, ElementInterface::class)) { + $elementEditCondition = $this->getElementEditCondition(); + if (!$elementEditCondition) { + $elementEditCondition = clone self::defaultElementEditCondition($elementType); + $elementEditCondition->setFieldLayouts([$this->getLayout()]); + } + $elementEditCondition->mainTag = 'div'; + $elementEditCondition->id = 'element-edit-condition'; + $elementEditCondition->name = 'elementEditCondition'; + $elementEditCondition->forProjectConfig = true; + + $editConditionsHtml .= Cp::fieldHtml($elementEditCondition->getBuilderHtml(), [ + 'label' => Craft::t('app', '{type} Condition', [ + 'type' => $elementType::displayName(), + ]), + 'instructions' => Craft::t('app', 'Only make editable when editing {type} that match the following rules:', [ + 'type' => $elementType::pluralLowerDisplayName(), + ]), + ]); + } + + return $html . Html::beginTag('fieldset', ['class' => 'pane']) . Html::tag('legend', Craft::t('app', 'Editability Conditions')) . - Html::beginTag('div') . - Cp::fieldHtml($editCondition->getBuilderHtml(), [ - 'label' => Craft::t('app', 'Current User Condition'), - 'instructions' => Craft::t('app', 'Only make editable for users who match the following rules:'), - ]) . - Html::endTag('div') . + Html::tag('div', $editConditionsHtml) . Html::endTag('fieldset'); - - return $html; } /** * Returns whether the field can be edited by the current user. * + * @param ElementInterface|null $element * @return bool * @since 5.7.0 */ - public function editable(): bool + public function editable(?ElementInterface $element): bool { $editCondition = $this->getEditCondition(); @@ -591,6 +726,12 @@ public function editable(): bool } } + $elementEditCondition = $this->getElementEditCondition(); + + if ($elementEditCondition && $element && !$elementEditCondition->matchElement($element)) { + return false; + } + return true; } @@ -599,7 +740,7 @@ public function editable(): bool */ public function formHtml(?ElementInterface $element = null, bool $static = false): ?string { - $static = $static || !$this->editable(); + $static = $static || !$this->editable($element); $view = Craft::$app->getView(); $isDeltaRegistrationActive = $view->getIsDeltaRegistrationActive(); @@ -752,14 +893,24 @@ protected function actionMenuItems(?ElementInterface $element = null, bool $stat try { $field = $this->getField(); } catch (FieldNotFoundException) { - return []; + $field = null; } - if (!$field instanceof Actionable) { - return []; + if ($field instanceof Actionable) { + $field->static = $static; + $items = $field->getActionMenuItems(); + } else { + $items = []; } - $field->static = $static; - return $field->getActionMenuItems(); + $user = Craft::$app->getUser()->getIdentity(); + if ($user?->admin && !$user->getPreference('showFieldHandles')) { + $items[] = $this->copyAttributeAction([ + 'label' => Craft::t('app', 'Copy field handle'), + 'promptLabel' => Craft::t('app', 'Field Handle'), + ]); + } + + return $items; } } diff --git a/src/fieldlayoutelements/FullNameField.php b/src/fieldlayoutelements/FullNameField.php index d7464ab2eaa..0c495e8a99d 100644 --- a/src/fieldlayoutelements/FullNameField.php +++ b/src/fieldlayoutelements/FullNameField.php @@ -81,6 +81,7 @@ private function firstAndLastNameFields(?ElementInterface $element, bool $static $statusClass = $this->statusClass($element); $status = $statusClass ? [$statusClass, $this->statusLabel($element, $static) ?? ucfirst($statusClass)] : null; $required = !$static && $this->required; + $isAdmin = Craft::$app->getUser()->getIsAdmin(); return HtmlHelper::beginTag('div', ['class' => ['flex', 'flex-nowrap', 'fullwidth']]) . Cp::textFieldHtml([ @@ -99,6 +100,9 @@ private function firstAndLastNameFields(?ElementInterface $element, bool $static 'data' => [ 'error-key' => 'firstName', ], + 'actionMenuItems' => array_filter([ + $isAdmin ? $this->copyAttributeAction(['attribute' => 'firstName']) : null, + ]), ]) . Cp::textFieldHtml([ 'id' => 'lastName', @@ -116,6 +120,9 @@ private function firstAndLastNameFields(?ElementInterface $element, bool $static 'data' => [ 'error-key' => 'lastName', ], + 'actionMenuItems' => array_filter([ + $isAdmin ? $this->copyAttributeAction(['attribute' => 'lastName']) : null, + ]), ]) . HtmlHelper::endTag('div'); } diff --git a/src/fieldlayoutelements/TextField.php b/src/fieldlayoutelements/TextField.php index d48b7d54cb1..97fc3980d09 100644 --- a/src/fieldlayoutelements/TextField.php +++ b/src/fieldlayoutelements/TextField.php @@ -182,4 +182,18 @@ protected function errorKey(): string { return $this->name ?? parent::errorKey(); } + + /** + * @inheritdoc + */ + protected function actionMenuItems(?ElementInterface $element = null, bool $static = false): array + { + $items = []; + + if (Craft::$app->getUser()->getIsAdmin()) { + $items[] = $this->copyAttributeAction(); + } + + return $items; + } } diff --git a/src/fieldlayoutelements/TextareaField.php b/src/fieldlayoutelements/TextareaField.php index ca7b4616494..e481b49776b 100644 --- a/src/fieldlayoutelements/TextareaField.php +++ b/src/fieldlayoutelements/TextareaField.php @@ -138,4 +138,18 @@ protected function errorKey(): string { return $this->name ?? parent::errorKey(); } + + /** + * @inheritdoc + */ + protected function actionMenuItems(?ElementInterface $element = null, bool $static = false): array + { + $items = []; + + if (Craft::$app->getUser()->getIsAdmin()) { + $items[] = $this->copyAttributeAction(); + } + + return $items; + } } diff --git a/src/fieldlayoutelements/TitleField.php b/src/fieldlayoutelements/TitleField.php index bf3bfab997b..76a6244deae 100644 --- a/src/fieldlayoutelements/TitleField.php +++ b/src/fieldlayoutelements/TitleField.php @@ -9,6 +9,7 @@ use Craft; use craft\base\ElementInterface; +use craft\helpers\ElementHelper; use craft\helpers\StringHelper; /** @@ -97,7 +98,11 @@ public function defaultLabel(?ElementInterface $element = null, bool $static = f */ public function formHtml(?ElementInterface $element = null, bool $static = false): ?string { - if ($element?->getIsFresh() && !$static) { + if ( + $element && + !$static && + (!isset($element->slug) || ElementHelper::isTempSlug($element->slug)) + ) { $view = Craft::$app->getView(); $language = $element->getSite()->language; @@ -131,4 +136,18 @@ public function isCrossSiteCopyable(ElementInterface $element): bool { return true; } + + /** + * @inheritdoc + */ + protected function actionMenuItems(?ElementInterface $element = null, bool $static = false): array + { + $items = []; + + if (Craft::$app->getUser()->getIsAdmin()) { + $items[] = $this->copyAttributeAction(); + } + + return $items; + } } diff --git a/src/fieldlayoutelements/addresses/AddressField.php b/src/fieldlayoutelements/addresses/AddressField.php index 4f14e836c0d..91a1a10745e 100644 --- a/src/fieldlayoutelements/addresses/AddressField.php +++ b/src/fieldlayoutelements/addresses/AddressField.php @@ -23,11 +23,6 @@ */ class AddressField extends BaseField { - /** - * @inheritdoc - */ - public bool $includeInCards = true; - /** * @inheritdoc */ diff --git a/src/fieldlayoutelements/addresses/CountryCodeField.php b/src/fieldlayoutelements/addresses/CountryCodeField.php index 3a8263f9f9f..9ee4ed692a7 100644 --- a/src/fieldlayoutelements/addresses/CountryCodeField.php +++ b/src/fieldlayoutelements/addresses/CountryCodeField.php @@ -131,4 +131,18 @@ public function previewPlaceholderHtml(mixed $value, ?ElementInterface $element) return $value; } + + /** + * @inheritdoc + */ + protected function actionMenuItems(?ElementInterface $element = null, bool $static = false): array + { + $items = []; + + if (Craft::$app->getUser()->getIsAdmin()) { + $items[] = $this->copyAttributeAction(); + } + + return $items; + } } diff --git a/src/fieldlayoutelements/addresses/LabelField.php b/src/fieldlayoutelements/addresses/LabelField.php index f6cdecaf53e..8f2a4f15219 100644 --- a/src/fieldlayoutelements/addresses/LabelField.php +++ b/src/fieldlayoutelements/addresses/LabelField.php @@ -10,6 +10,7 @@ use Craft; use craft\base\ElementInterface; use craft\fieldlayoutelements\TitleField; +use craft\helpers\ArrayHelper; /** * Class LabelField. @@ -19,11 +20,37 @@ */ class LabelField extends TitleField { + /** + * @inheritdoc + */ + public bool $requirable = true; + /** * @inheritdoc */ public bool $translatable = false; + /** + * @inheritdoc + */ + public function __construct($config = []) + { + $this->required = ArrayHelper::remove($config, 'required', $this->required); + unset($config['requirable']); + parent::__construct($config); + } + + /** + * @inheritdoc + */ + public function fields(): array + { + $fields = parent::fields(); + unset($fields['requirable']); + $fields['required'] = 'required'; + return $fields; + } + /** * @inheritdoc */ diff --git a/src/fieldlayoutelements/addresses/LatLongField.php b/src/fieldlayoutelements/addresses/LatLongField.php index 597b2f13ca0..fc4e2d764b9 100644 --- a/src/fieldlayoutelements/addresses/LatLongField.php +++ b/src/fieldlayoutelements/addresses/LatLongField.php @@ -122,6 +122,8 @@ protected function inputHtml(ElementInterface $element = null, bool $static = fa throw new InvalidArgumentException(sprintf('%s can only be used in address field layouts.', self::class)); } + $isAdmin = Craft::$app->getUser()->getIsAdmin(); + return Html::beginTag('div', ['class' => 'flex-fields']) . Cp::textFieldHtml([ @@ -134,6 +136,9 @@ protected function inputHtml(ElementInterface $element = null, bool $static = fa 'data' => [ 'error-key' => 'latitude', ], + 'actionMenuItems' => array_filter([ + $isAdmin ? $this->copyAttributeAction(['attribute' => 'latitude']) : null, + ]), ]) . Cp::textFieldHtml([ 'fieldClass' => 'width-50', @@ -145,6 +150,9 @@ protected function inputHtml(ElementInterface $element = null, bool $static = fa 'data' => [ 'error-key' => 'longitude', ], + 'actionMenuItems' => array_filter([ + $isAdmin ? $this->copyAttributeAction(['attribute' => 'longitude']) : null, + ]), ]) . Html::endTag('div'); } diff --git a/src/fieldlayoutelements/users/AffiliatedSiteField.php b/src/fieldlayoutelements/users/AffiliatedSiteField.php index 89441dcb2e7..5aad525cb64 100644 --- a/src/fieldlayoutelements/users/AffiliatedSiteField.php +++ b/src/fieldlayoutelements/users/AffiliatedSiteField.php @@ -103,4 +103,20 @@ protected function inputHtml(?ElementInterface $element = null, bool $static = f 'value' => $element?->affiliatedSiteId, ]); } + + /** + * @inheritdoc + */ + protected function actionMenuItems(?ElementInterface $element = null, bool $static = false): array + { + $items = []; + + if (Craft::$app->getUser()->getIsAdmin()) { + $items[] = $this->copyAttributeAction([ + 'attribute' => 'affiliatedSite', + ]); + } + + return $items; + } } diff --git a/src/fieldlayoutelements/users/PhotoField.php b/src/fieldlayoutelements/users/PhotoField.php index eaa20c9d9e4..bdcec26be43 100644 --- a/src/fieldlayoutelements/users/PhotoField.php +++ b/src/fieldlayoutelements/users/PhotoField.php @@ -110,4 +110,18 @@ protected function inputHtml(?ElementInterface $element = null, bool $static = f 'user' => $element, ]); } + + /** + * @inheritdoc + */ + protected function actionMenuItems(?ElementInterface $element = null, bool $static = false): array + { + $items = []; + + if (Craft::$app->getUser()->getIsAdmin()) { + $items[] = $this->copyAttributeAction(); + } + + return $items; + } } diff --git a/src/fields/Addresses.php b/src/fields/Addresses.php index 7b8d5f44349..c28aab4f717 100644 --- a/src/fields/Addresses.php +++ b/src/fields/Addresses.php @@ -39,6 +39,7 @@ use craft\helpers\StringHelper; use craft\services\Elements; use craft\validators\ArrayValidator; +use craft\web\assets\cp\CpAsset; use GraphQL\Type\Definition\Type; use yii\base\InvalidConfigException; use yii\db\Expression; @@ -364,9 +365,12 @@ public function getReadOnlySettingsHtml(): ?string private function settingsHtml(bool $readOnly): string { + $bundle = Craft::$app->getView()->registerAssetBundle(CpAsset::class); + return Craft::$app->getView()->renderTemplate('_components/fieldtypes/Addresses/settings.twig', [ 'field' => $this, 'readOnly' => $readOnly, + 'baseIconsUrl' => "$bundle->baseUrl/images/view-modes", ]); } @@ -418,8 +422,7 @@ private function createAddressesFromSerializedData(array $value, ElementInterfac /** @var Address[] $oldAddressesById */ $oldAddressesById = Address::find() ->fieldId($this->id) - ->ownerId($element->id) - ->siteId($element->siteId) + ->owner($element) ->drafts(null) ->revisions(null) ->status(null) @@ -540,7 +543,7 @@ private function createAddressQuery(?ElementInterface $owner = null): AddressQue CancelableEvent $event, AddressQuery $query, ) use ($owner) { - $query->ownerId = $owner->id; + $query->owner($owner); // Clear out id=false if this query was populated previously if ($query->id === false) { diff --git a/src/fields/Assets.php b/src/fields/Assets.php index ad36f6e04e7..37473d40e3b 100644 --- a/src/fields/Assets.php +++ b/src/fields/Assets.php @@ -37,9 +37,6 @@ use craft\services\Gql as GqlService; use craft\web\UploadedFile; use GraphQL\Type\Definition\Type; -use Illuminate\Support\Collection; -use Twig\Error\RuntimeError; -use yii\base\InvalidConfigException; /** * Assets represents an Assets field. @@ -940,52 +937,7 @@ private function _findFolder(string $sourceKey, ?string $subpath, ?ElementInterf throw new InvalidFsException("Invalid source key: $sourceKey"); } - $assetsService = Craft::$app->getAssets(); - $rootFolder = $assetsService->getRootFolderByVolumeId($volume->id); - - // Are we looking for the root folder? - $subpath = trim($subpath ?? '', '/'); - if ($subpath === '') { - return $rootFolder; - } - - $isDynamic = preg_match('/\{|\}/', $subpath); - - if ($isDynamic) { - // Prepare the path by parsing tokens and normalizing slashes. - try { - if ($element?->duplicateOf) { - $element = $element->duplicateOf->getCanonical(); - } - $renderedSubpath = Craft::$app->getView()->renderObjectTemplate($subpath, $element); - } catch (InvalidConfigException|RuntimeError $e) { - throw new InvalidSubpathException($subpath, null, 0, $e); - } - - // Did any of the tokens return null? - if ( - $renderedSubpath === '' || - trim($renderedSubpath, '/') != $renderedSubpath || - str_contains($renderedSubpath, '//') || - Collection::make(explode('/', $renderedSubpath)) - ->contains(fn(string $segment) => ElementHelper::isTempSlug($segment)) - ) { - throw new InvalidSubpathException($subpath); - } - - // Sanitize the subpath - $segments = array_filter(explode('/', $renderedSubpath), fn(string $segment): bool => $segment !== ':ignore:'); - $generalConfig = Craft::$app->getConfig()->getGeneral(); - $segments = array_map(fn(string $segment): string => FileHelper::sanitizeFilename($segment, [ - 'asciiOnly' => $generalConfig->convertFilenamesToAscii, - ]), $segments); - $subpath = implode('/', $segments); - } - - $folder = $assetsService->findFolder([ - 'volumeId' => $volume->id, - 'path' => $subpath . '/', - ]); + [$subpath, $folder] = AssetsHelper::resolveSubpath($volume, $subpath, $element); // Ensure that the folder exists if (!$folder) { @@ -993,7 +945,7 @@ private function _findFolder(string $sourceKey, ?string $subpath, ?ElementInterf throw new InvalidSubpathException($subpath); } - $folder = $assetsService->ensureFolderByFullPathAndVolume($subpath, $volume); + $folder = Craft::$app->getAssets()->ensureFolderByFullPathAndVolume($subpath, $volume); } return $folder; diff --git a/src/fields/BaseRelationField.php b/src/fields/BaseRelationField.php index 160bb4b1644..3e751e2b57c 100644 --- a/src/fields/BaseRelationField.php +++ b/src/fields/BaseRelationField.php @@ -47,6 +47,7 @@ use craft\queue\jobs\LocalizeRelations; use craft\services\Elements; use craft\services\ElementSources; +use craft\web\assets\cp\CpAsset; use DateTime; use GraphQL\Type\Definition\Type; use Illuminate\Support\Collection; @@ -76,6 +77,17 @@ abstract class BaseRelationField extends Field implements */ public const EVENT_DEFINE_SELECTION_CRITERIA = 'defineSelectionCriteria'; + /** @since 5.9.0 */ + public const VIEW_MODE_LIST = 'list'; + /** @since 5.9.0 */ + public const VIEW_MODE_LIST_INLINE = 'list-inline'; + /** @since 5.9.0 */ + public const VIEW_MODE_THUMBS = 'thumbs'; + /** @since 5.9.0 */ + public const VIEW_MODE_CARDS = 'cards'; + /** @since 5.9.0 */ + public const VIEW_MODE_CARDS_GRID = 'cards-grid'; + /** @since 5.7.0 */ public const DEFAULT_PLACEMENT_BEGINNING = 'beginning'; /** @since 5.7.0 */ @@ -316,6 +328,7 @@ public static function existsQueryCondition(self $field, bool $enabledOnly = tru /** * @var bool Whether cards should be shown in a multi-column grid * @since 5.0.0 + * @deprecated in 5.9.0. */ public bool $showCardsInGrid = false; @@ -457,6 +470,17 @@ public function __construct(array $config = []) $config['localizeRelations'] = ($config['translationMethod'] ?? self::TRANSLATION_METHOD_NONE) !== self::TRANSLATION_METHOD_NONE; } + $config['viewMode'] ??= self::VIEW_MODE_LIST; + + if (!empty($config['showCardsInGrid']) && $config['viewMode'] === self::VIEW_MODE_CARDS) { + $config['viewMode'] = self::VIEW_MODE_CARDS_GRID; + } + $config['showCardsInGrid'] = $config['viewMode'] === self::VIEW_MODE_CARDS_GRID; + + if ($config['viewMode'] === 'large') { + $config['viewMode'] = self::VIEW_MODE_THUMBS; + } + parent::__construct($config); } @@ -527,7 +551,6 @@ public function settingsAttributes(): array $attributes[] = 'maxRelations'; $attributes[] = 'minRelations'; $attributes[] = 'selectionLabel'; - $attributes[] = 'showCardsInGrid'; $attributes[] = 'showSearchInput'; $attributes[] = 'showSiteMenu'; $attributes[] = 'source'; @@ -949,10 +972,9 @@ private function _inputHtml(mixed $value, ?ElementInterface $element, bool $inli /** @var ElementInterface[] $value */ $variables = $this->inputTemplateVariables($value, $element); - $variables['inline'] = $inline || $variables['viewMode'] === 'large'; - if ($inline) { - $variables['viewMode'] = 'list'; + if ($inline && !in_array($variables['viewMode'], [self::VIEW_MODE_LIST_INLINE, self::VIEW_MODE_THUMBS])) { + $variables['viewMode'] = self::VIEW_MODE_LIST_INLINE; } if ($static) { @@ -1421,21 +1443,52 @@ public function getViewModeFieldHtml(): ?string return null; } - $viewModeOptions = []; + if (empty(array_diff(array_keys($supportedViewModes), [ + self::VIEW_MODE_LIST, + self::VIEW_MODE_LIST_INLINE, + self::VIEW_MODE_THUMBS, + self::VIEW_MODE_CARDS, + self::VIEW_MODE_CARDS_GRID, + ]))) { + $html = Html::beginTag('div', ['class' => ['flex', 'items-start', 'gap-l']]); + $bundle = Craft::$app->getView()->registerAssetBundle(CpAsset::class); + $baseIconsUrl = "$bundle->baseUrl/images/view-modes"; + + foreach ($supportedViewModes as $key => $label) { + $html .= Html::beginTag('label', ['class' => 'nowrap']) . + Html::img("$baseIconsUrl/$key.svg", [ + 'class' => 'mb-xs', + 'width' => $key === self::VIEW_MODE_LIST ? 48 : 80, + 'height' => 60, + 'alt' => '', + ]) . + Html::radio('viewMode', $key === $this->viewMode, [ + 'value' => $key, + ]) . + ' ' . $label . + Html::endTag('label'); + } + + $html .= Html::endTag('div'); + } else { + $viewModeOptions = []; - foreach ($supportedViewModes as $key => $label) { - $viewModeOptions[] = ['label' => $label, 'value' => $key]; + foreach ($supportedViewModes as $key => $label) { + $viewModeOptions[] = ['label' => $label, 'value' => $key]; + } + + $html = Cp::selectHtml([ + 'id' => 'viewMode', + 'name' => 'viewMode', + 'options' => $viewModeOptions, + 'value' => $this->viewMode, + ]); } - return Cp::selectFieldHtml([ + return Cp::fieldHtml($html, [ 'label' => Craft::t('app', 'View Mode'), 'instructions' => Craft::t('app', 'Choose how the field should look for authors.'), 'id' => 'viewMode', - 'name' => 'viewMode', - 'options' => $viewModeOptions, - 'value' => $this->viewMode, - 'toggle' => true, - 'targetPrefix' => 'view-mode--', ]); } @@ -1464,7 +1517,6 @@ protected function settingsTemplateVariables(): array $selectionCondition->name = 'selectionCondition'; $selectionCondition->forProjectConfig = true; $selectionCondition->queryParams[] = 'site'; - $selectionCondition->queryParams[] = 'status'; $selectionConditionHtml = Cp::fieldHtml($selectionCondition->getBuilderHtml(), [ 'label' => Craft::t('app', 'Selectable {type} Condition', [ @@ -1500,7 +1552,7 @@ protected function inputTemplateVariables(array|ElementQueryInterface $value = n $value = []; } - ElementHelper::swapInProvisionalDrafts($value); + ElementHelper::loadProvisionalChanges($value); if ($this->validateRelatedElements && $element !== null) { // Pre-validate related elements @@ -1573,7 +1625,6 @@ protected function inputTemplateVariables(array|ElementQueryInterface $value = n 'limit' => $this->allowLimit ? $this->maxRelations : null, 'defaultPlacement' => $this->defaultPlacement, 'viewMode' => $this->viewMode(), - 'showCardsInGrid' => $this->showCardsInGrid, 'selectionLabel' => $this->selectionLabel ? Craft::t('site', $this->selectionLabel) : static::defaultSelectionLabel(), 'sortable' => $this->sortable && !$this->maintainHierarchy, 'prevalidate' => $this->validateRelatedElements, @@ -1745,14 +1796,16 @@ private function _targetSiteId(): ?int protected function supportedViewModes(): array { $viewModes = [ - 'list' => Craft::t('app', 'List'), + self::VIEW_MODE_LIST => Craft::t('app', 'List'), + self::VIEW_MODE_LIST_INLINE => Craft::t('app', 'Inline list'), ]; if ($this->allowLargeThumbsView) { - $viewModes['large'] = Craft::t('app', 'Large Thumbnails'); + $viewModes[self::VIEW_MODE_THUMBS] = Craft::t('app', 'Thumbs'); } - $viewModes['cards'] = Craft::t('app', 'Cards'); + $viewModes[self::VIEW_MODE_CARDS] = Craft::t('app', 'Cards'); + $viewModes[self::VIEW_MODE_CARDS_GRID] = Craft::t('app', 'Card grid'); return $viewModes; } @@ -1771,7 +1824,7 @@ protected function viewMode(): string return $viewMode; } - return 'list'; + return self::VIEW_MODE_LIST; } /** diff --git a/src/fields/ContentBlock.php b/src/fields/ContentBlock.php index cd1666e0145..d28f61d9d1d 100644 --- a/src/fields/ContentBlock.php +++ b/src/fields/ContentBlock.php @@ -35,6 +35,7 @@ use craft\helpers\Html; use craft\helpers\Json as JsonHelper; use craft\models\FieldLayout; +use craft\web\assets\cp\CpAsset; use DateTime; use GraphQL\Type\Definition\Type; use Illuminate\Support\Collection; @@ -408,9 +409,12 @@ public function getReadOnlySettingsHtml(): ?string private function settingsHtml(bool $readOnly): string { + $bundle = Craft::$app->getView()->registerAssetBundle(CpAsset::class); + return Craft::$app->getView()->renderTemplate('_components/fieldtypes/ContentBlock/settings.twig', [ 'field' => $this, 'readOnly' => $readOnly, + 'baseIconsUrl' => "$bundle->baseUrl/images/content-block", ]); } @@ -553,7 +557,7 @@ private function createContentBlockQuery(?ElementInterface $owner): ContentBlock CancelableEvent $event, ContentBlockQuery $query, ) use ($owner) { - $query->ownerId = $owner->id; + $query->owner($owner); // Clear out id=false if this query was populated previously if ($query->id === false) { @@ -875,9 +879,8 @@ public function beforeElementDeleteForSite(ElementInterface $element): bool /** @var ContentBlockElement[] $contentBlocks */ $contentBlocks = ContentBlockElement::find() - ->primaryOwnerId($element->id) + ->primaryOwner($element) ->status(null) - ->siteId($element->siteId) ->all(); foreach ($contentBlocks as $contentBlock) { diff --git a/src/fields/Email.php b/src/fields/Email.php index 3713c2baf71..efde450951c 100644 --- a/src/fields/Email.php +++ b/src/fields/Email.php @@ -145,7 +145,7 @@ public function getElementValidationRules(): array { return [ ['trim'], - ['email', 'enableIDN' => App::supportsIdn(), 'enableLocalIDN' => false], + ['email', 'enableIDN' => App::supportsIdn(), 'enableLocalIDN' => App::supportsIdn()], ]; } diff --git a/src/fields/Lightswitch.php b/src/fields/Lightswitch.php index 2e109e1e2ad..511366a6aaa 100644 --- a/src/fields/Lightswitch.php +++ b/src/fields/Lightswitch.php @@ -106,6 +106,12 @@ public static function queryCondition(array $instances, mixed $value, array &$pa */ public ?string $offLabel = null; + /** + * @var bool Whether card views which include this field should show the custom ON/OFF labels, rather than the field name. + * @since 5.9.0 + */ + public bool $showLabelsInCards = false; + /** * @inheritdoc */ @@ -160,6 +166,14 @@ private function settingsHtml(bool $readOnly): string 'name' => 'onLabel', 'value' => $this->onLabel, 'disabled' => $readOnly, + ]) . + Cp::lightswitchFieldHtml([ + 'label' => Craft::t('app', 'Show ON/OFF labels in cards'), + 'instructions' => Craft::t('app', 'Whether card views which include this field should show the custom ON/OFF labels, rather than the field name.'), + 'id' => 'show-labels-in-cards', + 'name' => 'showLabelsInCards', + 'on' => $this->showLabelsInCards, + 'disabled' => $readOnly, ]); } @@ -261,7 +275,12 @@ public function getContentGqlQueryArgumentType(): Type|array */ public function getPreviewHtml(mixed $value, ElementInterface $element): string { - if ($element->viewMode === 'cards') { + $canShowLabel = ($value && $this->onLabel) || (!$value && $this->offLabel); + + if ( + $element->viewMode === 'cards' && + (!$this->showLabelsInCards || !$canShowLabel) + ) { return Cp::statusLabelHtml([ 'color' => $value ? ColorEnum::Teal : ColorEnum::Gray, 'label' => $this->getUiLabel(), diff --git a/src/fields/Matrix.php b/src/fields/Matrix.php index dc278d81926..f1bee28ea9d 100644 --- a/src/fields/Matrix.php +++ b/src/fields/Matrix.php @@ -54,6 +54,7 @@ use craft\validators\ArrayValidator; use craft\validators\StringValidator; use craft\validators\UriFormatValidator; +use craft\web\assets\cp\CpAsset; use craft\web\assets\matrix\MatrixAsset; use craft\web\View; use GraphQL\Type\Definition\Type; @@ -84,6 +85,8 @@ class Matrix extends Field implements /** @since 5.0.0 */ public const VIEW_MODE_CARDS = 'cards'; /** @since 5.0.0 */ + public const VIEW_MODE_CARDS_GRID = 'cards-grid'; + /** @since 5.0.0 */ public const VIEW_MODE_BLOCKS = 'blocks'; /** @since 5.0.0 */ public const VIEW_MODE_INDEX = 'index'; @@ -224,6 +227,7 @@ public static function defaultTableColumnOptions(array $entryTypes): array /** * @var bool Whether cards should be shown in a multi-column grid * @since 5.0.0 + * @deprecated in 5.9.0 */ public bool $showCardsInGrid = false; @@ -321,6 +325,11 @@ public function __construct($config = []) $config['maxEntries'] = ArrayHelper::remove($config, 'maxBlocks'); } + if (!empty($config['showCardsInGrid']) && ($config['viewMode'] ?? self::VIEW_MODE_CARDS) === self::VIEW_MODE_CARDS) { + $config['viewMode'] = self::VIEW_MODE_CARDS_GRID; + } + $config['showCardsInGrid'] = ($config['viewMode'] ?? self::VIEW_MODE_CARDS) === self::VIEW_MODE_CARDS_GRID; + parent::__construct($config); } @@ -389,6 +398,7 @@ protected function defineRules(): array $rules[] = [['minEntries', 'maxEntries'], 'integer', 'min' => 0]; $rules[] = [['viewMode'], 'in', 'range' => [ self::VIEW_MODE_CARDS, + self::VIEW_MODE_CARDS_GRID, self::VIEW_MODE_INDEX, self::VIEW_MODE_BLOCKS, ]]; @@ -693,6 +703,8 @@ private function settingsHtml(bool $readOnly): string $entryTypeSelectJs = $view->clearJsBuffer(); } + $bundle = Craft::$app->getView()->registerAssetBundle(CpAsset::class); + return $view->renderTemplate('_components/fieldtypes/Matrix/settings.twig', [ 'field' => $this, 'entryTypes' => $entryTypes, @@ -705,6 +717,7 @@ private function settingsHtml(bool $readOnly): string Entry::indexViewModes(), fn(array $viewMode) => !($viewMode['structuresOnly'] ?? false), ), + 'baseIconsUrl' => "$bundle->baseUrl/images/view-modes", 'readOnly' => $readOnly, ]); } @@ -761,7 +774,7 @@ private function createEntryQuery(?ElementInterface $owner): EntryQuery CancelableEvent $event, EntryQuery $query, ) use ($owner) { - $query->ownerId = $owner->id; + $query->owner($owner); // Clear out id=false if this query was populated previously if ($query->id === false) { @@ -868,67 +881,184 @@ public function getIsTranslatable(?ElementInterface $element): bool * @inheritdoc */ protected function actionMenuItems(): array + { + $items = match ($this->viewMode) { + self::VIEW_MODE_BLOCKS => $this->blockViewActionMenuItems(), + self::VIEW_MODE_CARDS, self::VIEW_MODE_CARDS_GRID => $this->cardViewActionMenuItems(), + default => [], + }; + + $parentItems = parent::actionMenuItems(); + + if (!empty($items) && !empty($parentItems)) { + return [ + ...$items, + ['type' => 'hr'], + ...$parentItems, + ]; + } + + return [...$items, ...$parentItems]; + } + + private function blockViewActionMenuItems(): array { $items = []; $view = Craft::$app->getView(); - if ($this->viewMode === self::VIEW_MODE_BLOCKS) { - // Expand/Collapse all - $expandAllId = sprintf('expand-all-%s', mt_rand()); - $collapseAllId = sprintf('collapse-all-%s', mt_rand()); - $items[] = [ - 'id' => $expandAllId, - 'icon' => 'expand', - 'label' => StringHelper::upperCaseFirst(Craft::t('app', 'Expand all blocks', [ - 'type' => Entry::pluralLowerDisplayName(), - ])), - ]; - $items[] = [ - 'id' => $collapseAllId, - 'icon' => 'collapse', - 'label' => StringHelper::upperCaseFirst(Craft::t('app', 'Collapse all blocks', [ - 'type' => Entry::pluralLowerDisplayName(), - ])), - ]; - $view->registerJsWithVars(fn($expandAllId, $collapseAllId, $fieldId) => << $expandAllId, + 'icon' => 'expand', + 'label' => StringHelper::upperCaseFirst(Craft::t('app', 'Expand all blocks', [ + 'type' => Entry::pluralLowerDisplayName(), + ])), + ]; + $items[] = [ + 'id' => $collapseAllId, + 'icon' => 'collapse', + 'label' => StringHelper::upperCaseFirst(Craft::t('app', 'Collapse all blocks', [ + 'type' => Entry::pluralLowerDisplayName(), + ])), + ]; + $view->registerJsWithVars(fn($expandAllId, $collapseAllId, $fieldId) => << { - const expandAllBtn = $('#' + $expandAllId); - const collapseAllBtn = $('#' + $collapseAllId); - const getBlocks = () => $('#' + $fieldId + ' > .blocks > .matrixblock'); - - expandAllBtn.on('activate', () => { + const field = $('#' + $fieldId); + const expandBtn = $('#' + $expandAllId); + const collapseBtn = $('#' + $collapseAllId); + const menu = expandBtn.closest('.menu'); + const getBlocks = () => { + const blocks = field.find(' > .blocks > .matrixblock'); + const selectedBlocks = blocks.filter('.sel'); + return selectedBlocks.length ? selectedBlocks : blocks; + }; + + expandBtn.on('activate', () => { getBlocks().each((i, block) => { $(block).data('entry').expand(); }); }); - collapseAllBtn.on('activate', () => { + collapseBtn.on('activate', () => { getBlocks().each((i, block) => { $(block).data('entry').collapse(); }); }); setTimeout(() => { - const menu = expandAllBtn.closest('.menu').data('disclosureMenu'); - menu.on('show', () => { - const blocks = getBlocks(); - menu.toggleItem(expandAllBtn[0], blocks.is('.collapsed')); - menu.toggleItem(collapseAllBtn[0], blocks.is(':not(.collapsed)')); + const disclosureMenu = menu.data('disclosureMenu'); + disclosureMenu.on('show', () => { + let blocks = getBlocks(); + let expandLabel, collapseLabel; + if (blocks.is('.sel')) { + expandLabel = Craft.t('app', 'Expand selected blocks'); + collapseLabel = Craft.t('app', 'Collapse selected blocks'); + } else { + expandLabel = Craft.t('app', 'Expand all blocks'); + collapseLabel = Craft.t('app', 'Collapse all blocks'); + } + expandBtn.find('.menu-item-label').text(expandLabel); + collapseBtn.find('.menu-item-label').text(collapseLabel); + disclosureMenu.toggleItem(expandBtn[0], !!blocks.filter('.collapsed').length); + disclosureMenu.toggleItem(collapseBtn[0], !!blocks.filter(':not(.collapsed)').length); }); }, 1); })(); JS, [ - $view->namespaceInputId($expandAllId), - $view->namespaceInputId($collapseAllId), + $view->namespaceInputId($expandAllId), + $view->namespaceInputId($collapseAllId), + $view->namespaceInputId($this->getInputId()), + ]); + + // Copy + if ($this->maxEntries !== 1) { + $items[] = ['type' => 'hr']; + + $copyAllId = sprintf('action-copy-all-%s', mt_rand()); + $items[] = [ + 'id' => $copyAllId, + 'icon' => 'clone-dashed', + 'color' => \craft\enums\Color::Fuchsia, + 'label' => StringHelper::upperCaseFirst(Craft::t('app', 'Copy all {type}', [ + 'type' => Entry::pluralLowerDisplayName(), + ])), + ]; + + $baseInfo = Json::encode([ + 'type' => Entry::class, + 'fieldId' => $this->id, + ]); + + $view->registerJsWithVars(fn($copyAllId, $fieldId, $type) => << { + const copyBtn = $('#' + $copyAllId); + const field = $('#' + $fieldId); + const menu = copyBtn.closest('.menu'); + const getBlocks = () => { + const blocks = field.find(' > .blocks > .matrixblock'); + const selectedBlocks = blocks.filter('.sel'); + return selectedBlocks.length ? selectedBlocks : blocks; + }; + + if (field.length) { + copyBtn.on('activate', () => { + const elementInfo = []; + getBlocks().each((i, element) => { + element = $(element); + elementInfo.push(Object.assign({ + id: element.data('id'), + draftId: element.data('draftId'), + revisionId: element.data('revisionId'), + ownerId: element.data('ownerId'), + siteId: element.data('siteId'), + }, $baseInfo)); + }); + Craft.cp.copyElements(elementInfo); + }); + } else { + setTimeout(() => { + menu.data('disclosureMenu').removeItem(copyBtn[0]); + }, 1); + } + + setTimeout(() => { + const disclosureMenu = menu.data('disclosureMenu'); + disclosureMenu.on('show', () => { + let blocks = getBlocks(); + let copyLabel; + if (blocks.is('.sel')) { + copyLabel = Craft.t('app', 'Copy selected {type}', { + type: $type, + }); + } else { + copyLabel = Craft.t('app', 'Copy all {type}', { + type: $type, + }); + } + copyBtn.find('.menu-item-label').text(copyLabel); + disclosureMenu.toggleItem(copyBtn[0], !!blocks.length); + }); + }, 1); +})(); +JS, [ + $view->namespaceInputId($copyAllId), $view->namespaceInputId($this->getInputId()), + Entry::pluralLowerDisplayName(), ]); } + return $items; + } + + private function cardViewActionMenuItems(): array + { + $items = []; + $view = Craft::$app->getView(); + // Copy all - if ($this->maxEntries !== 1 && $this->viewMode !== self::VIEW_MODE_INDEX) { - if (!empty($items)) { - $items[] = ['type' => 'hr']; - } + if ($this->maxEntries !== 1) { $copyAllId = sprintf('action-copy-all-%s', mt_rand()); $items[] = [ 'id' => $copyAllId, @@ -939,45 +1069,19 @@ protected function actionMenuItems(): array ])), ]; - if ($this->viewMode === self::VIEW_MODE_CARDS) { - $copyAllJs = << { - Craft.cp.copyElements(field.find('> .nested-element-cards > .elements > li > .element')); -}); -JS; - } else { - $baseInfo = Json::encode([ - 'type' => Entry::class, - 'fieldId' => $this->id, - ]); - $copyAllJs = << { - const elementInfo = []; - field.find('> .blocks > .matrixblock').each((i, element) => { - element = $(element); - elementInfo.push(Object.assign({ - id: element.data('id'), - draftId: element.data('draftId'), - revisionId: element.data('revisionId'), - ownerId: element.data('ownerId'), - siteId: element.data('siteId'), - }, $baseInfo)); - }); - Craft.cp.copyElements(elementInfo); -}); -JS; - } $view->registerJsWithVars(fn($copyAllId, $fieldId) => << { - const copyAllBtn = $('#' + $copyAllId); + const copyBtn = $('#' + $copyAllId); const field = $('#' + $fieldId); if (field.length) { - $copyAllJs + copyBtn.on('activate', () => { + Craft.cp.copyElements(field.find('> .nested-element-cards > .elements > li > .element')); + }); } else { setTimeout(() => { - const menu = copyAllBtn.closest('.menu').data('disclosureMenu'); - menu.removeItem(copyAllBtn[0]); + const menu = copyBtn.closest('.menu').data('disclosureMenu'); + menu.removeItem(copyBtn[0]); }, 1); } })(); @@ -987,17 +1091,7 @@ protected function actionMenuItems(): array ]); } - $parentItems = parent::actionMenuItems(); - - if (!empty($items) && !empty($parentItems)) { - return [ - ...$items, - ['type' => 'hr'], - ...$parentItems, - ]; - } - - return [...$items, ...$parentItems]; + return $items; } /** @@ -1013,16 +1107,29 @@ public function getTranslationDescription(?ElementInterface $element): ?string * @throws InvalidConfigException */ protected function inputHtml(mixed $value, ?ElementInterface $element, bool $inline): string + { + return $this->inputHtmlInternal($value, $element, false); + } + + /** + * @inheritdoc + */ + public function getStaticHtml(mixed $value, ElementInterface $element): string + { + return $this->inputHtmlInternal($value, $element, true); + } + + private function inputHtmlInternal(mixed $value, ?ElementInterface $element, bool $static): string { return match ($this->viewMode) { - self::VIEW_MODE_BLOCKS => $this->blockInputHtml($value, $element), - default => Html::tag('div', $this->nestedElementManagerHtml($element), [ + self::VIEW_MODE_BLOCKS => $this->blockInputHtml($value, $element, $static), + default => Html::tag('div', $this->nestedElementManagerHtml($element, $static), [ 'id' => $this->getInputId(), ]), }; } - private function blockInputHtml(EntryQuery|ElementCollection|null $value, ?ElementInterface $element): string + private function blockInputHtml(EntryQuery|ElementCollection|null $value, ?ElementInterface $element, bool $static): string { if (!$element?->id) { $message = Craft::t('app', '{nestedType} can only be created after the {ownerType} has been saved.', [ @@ -1045,6 +1152,10 @@ private function blockInputHtml(EntryQuery|ElementCollection|null $value, ?Eleme ->all(); } + if ($static && empty($value)) { + return '

' . Craft::t('app', 'No entries.') . '

'; + } + $view = Craft::$app->getView(); $id = $this->getInputId(); /** @var Entry[] $value */ @@ -1062,9 +1173,12 @@ private function blockInputHtml(EntryQuery|ElementCollection|null $value, ?Eleme !$element->hasErrors($this->handle) ); $staticEntries = ( - $createDefaultEntries && - $this->minEntries == $this->maxEntries && - $this->maxEntries >= count($value) + $static || + ( + $createDefaultEntries && + $this->minEntries == $this->maxEntries && + $this->maxEntries >= count($value) + ) ); $view->registerAssetBundle(MatrixAsset::class); @@ -1077,6 +1191,7 @@ private function blockInputHtml(EntryQuery|ElementCollection|null $value, ?Eleme 'ownerElementType' => $element::class, 'ownerId' => $element->id, 'siteId' => $element->siteId, + 'static' => $static, 'staticEntries' => $staticEntries, ]; @@ -1128,7 +1243,7 @@ private function blockInputHtml(EntryQuery|ElementCollection|null $value, ?Eleme 'name' => $this->handle, 'entryTypes' => $entryTypes, 'entries' => $value, - 'static' => false, + 'static' => $static, 'staticEntries' => $staticEntries, 'createButtonLabel' => $this->createButtonLabel(), 'labelId' => $this->getLabelId(), @@ -1139,7 +1254,7 @@ private function nestedElementManagerHtml(?ElementInterface $owner, bool $static { $entryTypes = $this->getEntryTypes(); $config = [ - 'showInGrid' => $this->showCardsInGrid, + 'showInGrid' => $this->viewMode === self::VIEW_MODE_CARDS_GRID, 'prevalidate' => false, ]; @@ -1178,7 +1293,7 @@ private function nestedElementManagerHtml(?ElementInterface $owner, bool $static } } - if ($this->viewMode === self::VIEW_MODE_CARDS) { + if (in_array($this->viewMode, [self::VIEW_MODE_CARDS, self::VIEW_MODE_CARDS_GRID])) { return $this->entryManager()->getCardsHtml($owner, $config); } @@ -1333,46 +1448,6 @@ protected function searchKeywords(mixed $value, ElementInterface $element): stri return $this->entryManager()->getSearchKeywords($element); } - /** - * @inheritdoc - */ - public function getStaticHtml(mixed $value, ElementInterface $element): string - { - if ($this->viewMode !== self::VIEW_MODE_BLOCKS) { - return $this->nestedElementManagerHtml($element, true); - } - - /** @var EntryQuery|ElementCollection $value */ - $entries = $value->status(null)->all(); - - if (empty($entries)) { - return '

' . Craft::t('app', 'No entries.') . '

'; - } - - $view = Craft::$app->getView(); - $view->registerAssetBundle(MatrixAsset::class); - - $id = StringHelper::randomString(); - $js = ''; - - foreach ($entries as $entry) { - $js .= << .titlebar .matrixblock-tabs')); -JS; - } - - $view->registerJs("(() => {\n$js\n})();"); - - return $view->renderTemplate('_components/fieldtypes/Matrix/input.twig', [ - 'id' => $id, - 'name' => $id, - 'entryTypes' => $this->getEntryTypes(), - 'entries' => $entries, - 'static' => true, - 'staticEntries' => true, - ]); - } - /** * @inheritdoc * @return EagerLoadingMap|null|false @@ -1619,9 +1694,8 @@ public function beforeElementDeleteForSite(ElementInterface $element): bool /** @var Entry[] $entries */ $entries = Entry::find() - ->primaryOwnerId($element->id) + ->primaryOwner($element) ->status(null) - ->siteId($element->siteId) ->all(); foreach ($entries as $entry) { @@ -1677,8 +1751,7 @@ private function _createEntriesFromSerializedData(array $value, ElementInterface /** @var Entry[] $oldEntriesById */ $oldEntriesById = Entry::find() ->fieldId($this->id) - ->ownerId($element->id) - ->siteId($element->siteId) + ->owner($element) ->drafts(null) ->status(null) ->indexBy($uids ? 'uid' : 'id') diff --git a/src/fields/Table.php b/src/fields/Table.php index 2058666cf95..61bbfe3c3e0 100644 --- a/src/fields/Table.php +++ b/src/fields/Table.php @@ -15,6 +15,7 @@ use craft\gql\GqlEntityRegistry; use craft\gql\types\generators\TableRowType; use craft\gql\types\TableRow; +use craft\helpers\ArrayHelper; use craft\helpers\Cp; use craft\helpers\DateTimeHelper; use craft\helpers\Db; @@ -357,7 +358,8 @@ private function settingsHtml(bool $readOnly): string Json::encode($this->defaults ?? []) . ', ' . Json::encode($columnSettings) . ', ' . Json::encode($dropdownSettingsHtml) . ', ' . - Json::encode($dropdownSettingsCols) . + Json::encode($dropdownSettingsCols) . ', ' . + Json::encode($this->staticRows) . ', ' . ');'); $columnsField = $view->renderTemplate('_components/fieldtypes/Table/columntable.twig', [ @@ -379,6 +381,7 @@ private function settingsHtml(bool $readOnly): string 'rows' => $this->defaults, 'initJs' => false, 'static' => $readOnly, + 'includeRowId' => true, ]); return $view->renderTemplate('_components/fieldtypes/Table/settings.twig', [ @@ -389,6 +392,25 @@ private function settingsHtml(bool $readOnly): string ]); } + /** + * @inheritdoc + */ + public function beforeSave(bool $isNew): bool + { + if (!parent::beforeSave($isNew)) { + return false; + } + + if ($this->staticRows && !empty($this->defaults)) { + // make sure the default rows have IDs assigned + foreach ($this->defaults as &$row) { + $row['rowId'] ??= StringHelper::UUID(); + } + } + + return true; + } + /** * @inheritdoc */ @@ -486,11 +508,60 @@ private function _normalizeValueInternal(mixed $value, ?ElementInterface $elemen $value = array_values($value); if ($this->staticRows) { + // get the order of the default rows + $order = ArrayHelper::getColumn($this->defaults, 'rowId'); + $missingValueRowIds = null; + + if (!empty($order)) { + // if there's no rowIds, add them + if (ArrayHelper::containsRecursive($value, 'rowId') === false) { + foreach ($value as $key => &$row) { + $row['rowId'] = $order[$key]; + } + } + + // the rowIds present in the $value array + $usedValueRowIds = ArrayHelper::getColumn($value, 'rowId'); + + // if the field has a set order + $missingValueRowIds = array_values(array_diff($order, $usedValueRowIds)); + $leftoverValueRowIds = array_diff($usedValueRowIds, $order); + + // if the rowId is missing from the defaults - remove it from the $value array + if (!empty($leftoverValueRowIds)) { + foreach ($leftoverValueRowIds as $key => $rowId) { + unset($value[$key]); + } + } + } + $valueRows = count($value); $totalRows = count($defaults); + + // if we have too few rows if ($valueRows < $totalRows) { - $value = array_pad($value, $totalRows, []); - } elseif ($valueRows > $totalRows) { + if ($missingValueRowIds === null) { + $value = array_pad($value, $totalRows, []); + } else { + // if we have the missing value rowIds - add them in places where settings rowId doesn't exist in the $value array + while (count($value) < $totalRows) { + $value[] = ['rowId' => reset($missingValueRowIds)]; + array_shift($missingValueRowIds); + } + } + } + + if (!empty($order)) { + // sort as per the field's settings + usort($value, function($a, $b) use ($order) { + $posA = array_search($a['rowId'], $order); + $posB = array_search($b['rowId'], $order); + return $posA - $posB; + }); + } + + // now that we've sorted the rows, if we have too many rows - splice + if ($valueRows > $totalRows) { array_splice($value, $totalRows); } } @@ -591,6 +662,14 @@ public function serializeValueForDb(mixed $value, ElementInterface $element): mi $serializedRow[$colId] = parent::serializeValue($value, $element); } } + + // if the table has static rows, store the rowId too + if ($this->staticRows) { + if (isset($row['rowId'])) { + $serializedRow['rowId'] = $row['rowId']; + } + } + $serialized[] = $serializedRow; } @@ -804,6 +883,7 @@ private function _getInputHtml(mixed $value, ?ElementInterface $element, bool $s 'allowReorder' => true, 'addRowLabel' => Craft::t('site', $this->addRowLabel), 'describedBy' => $this->describedBy, + 'includeRowId' => $this->staticRows, ]); } } diff --git a/src/fields/Users.php b/src/fields/Users.php index 7ded0636a6d..d9bcabf9c54 100644 --- a/src/fields/Users.php +++ b/src/fields/Users.php @@ -101,7 +101,7 @@ public function getEagerLoadingGqlConditions(): ?array $allowedEntities = Gql::extractAllowedEntitiesFromSchema(); $userGroupUids = $allowedEntities['usergroups'] ?? []; - if (in_array('everyone', $userGroupUids, false)) { + if (in_array('everyone', $userGroupUids, false) || in_array('solo', $userGroupUids, false)) { return []; } diff --git a/src/fields/conditions/TextFieldConditionRule.php b/src/fields/conditions/TextFieldConditionRule.php index e9e4e7aac71..ccbc3979769 100644 --- a/src/fields/conditions/TextFieldConditionRule.php +++ b/src/fields/conditions/TextFieldConditionRule.php @@ -17,9 +17,17 @@ class TextFieldConditionRule extends BaseTextConditionRule implements FieldCondi /** * @inheritdoc */ - protected function elementQueryParam(): ?string + protected function elementQueryParam(): ?array { - return $this->paramValue(); + $value = $this->paramValue(); + if ($value === null) { + return null; + } + + return [ + 'value' => $this->paramValue(), + 'caseInsensitive' => true, + ]; } /** diff --git a/src/fields/data/LinkData.php b/src/fields/data/LinkData.php index 53e25e836ad..d8806d8308e 100644 --- a/src/fields/data/LinkData.php +++ b/src/fields/data/LinkData.php @@ -22,7 +22,8 @@ * * @property-read ElementInterface|null $element The element linked by the field, if there is one * @property-read ElementQueryInterface|null $elementQuery An element query that will fetch the element linked by the field, if there is one - * @property-read Markup|null $link An anchor tag for this link + * @property-read Markup $link An anchor tag for this link + * @property-read array|null $attributes The attributes that should be added to `` tags for this link * @property-read string $label The link label * @property-read string $type The link type ID * @property-read string $url The full link URL, including the suffix @@ -185,27 +186,49 @@ public function setFilename(?string $filename): void */ public function getLink(): Markup { - $url = $this->getUrl(); - if ($url === '') { + $attributes = $this->getAttributes(); + + if ($attributes === null) { $html = ''; } else { $label = $this->getLabel(); - $html = Html::a(Html::encode($label !== '' ? $label : $url), $url, [ - 'target' => $this->target, - 'title' => $this->title, - 'class' => $this->class, - 'id' => $this->id, - 'rel' => $this->rel, - 'aria' => [ - 'label' => $this->ariaLabel, - ], - 'download' => $this->download ? ($this->filename ?? true) : false, - ]); + if ($label === '') { + $label = $this->getUrl(); + } + $html = Html::a(Html::encode($label), options: $attributes); } return Template::raw($html); } + /** + * Returns the attributes that should be added to `` tags for this link. + * + * @return array|null + * @since 5.9.0 + */ + public function getAttributes(): ?array + { + $url = $this->getUrl(); + + if ($url === '') { + return null; + } + + return [ + 'href' => $url, + 'target' => $this->target, + 'title' => $this->title, + 'class' => $this->class, + 'id' => $this->id, + 'rel' => $this->rel, + 'aria' => [ + 'label' => $this->ariaLabel, + ], + 'download' => $this->download ? ($this->filename ?? true) : false, + ]; + } + /** * Returns an element query that will fetch the element linked by the field, if there is one. * diff --git a/src/gql/arguments/mutations/Draft.php b/src/gql/arguments/mutations/Draft.php index cd8a54d1338..374c8e2c8f0 100644 --- a/src/gql/arguments/mutations/Draft.php +++ b/src/gql/arguments/mutations/Draft.php @@ -28,14 +28,19 @@ public static function getArguments(): array return array_merge($parentArguments, [ 'draftId' => [ 'name' => 'draftId', - 'type' => Type::nonNull(Type::id()), - 'description' => 'The ID of the draft.', + 'type' => Type::id(), + 'description' => 'The ID of the draft. Can only be omitted if creating an unpublished draft', ], 'provisional' => [ 'name' => 'provisional', 'type' => Type::boolean(), 'description' => 'Whether a provisional draft should be looked up.', ], + 'asUnpublishedDraft' => [ + 'name' => 'asUnpublishedDraft', + 'type' => Type::boolean(), + 'description' => 'Whether an unpublished draft should be created.', + ], 'draftName' => [ 'name' => 'draftName', 'type' => Type::string(), diff --git a/src/gql/base/ElementArguments.php b/src/gql/base/ElementArguments.php index f810a9bf32b..cfbdc2161a8 100644 --- a/src/gql/base/ElementArguments.php +++ b/src/gql/base/ElementArguments.php @@ -7,6 +7,8 @@ namespace craft\gql\base; +use craft\base\Event; +use craft\events\DefineGqlArgumentsEvent; use craft\gql\GqlEntityRegistry; use craft\gql\types\input\criteria\AssetRelation; use craft\gql\types\input\criteria\CategoryRelation; @@ -26,12 +28,18 @@ */ abstract class ElementArguments extends Arguments { + /** + * @event DefineGqlArgumentsEvent The event that is triggered when arguments are being defined. + * @since 5.9.0 + */ + public const EVENT_DEFINE_ARGUMENTS = 'defineArguments'; + /** * @inheritdoc */ public static function getArguments(): array { - return array_merge(parent::getArguments(), static::getDraftArguments(), static::getRevisionArguments(), static::getStatusArguments(), [ + $arguments = array_merge(parent::getArguments(), static::getDraftArguments(), static::getRevisionArguments(), static::getStatusArguments(), [ 'site' => [ 'name' => 'site', 'type' => Type::listOf(Type::string()), @@ -193,6 +201,14 @@ public static function getArguments(): array 'description' => 'Narrows the query results based on the unique identifier for an element-site relation.', ], ]); + + if (Event::hasHandlers(self::class, self::EVENT_DEFINE_ARGUMENTS)) { + $event = new DefineGqlArgumentsEvent(compact('arguments')); + Event::trigger(self::class, self::EVENT_DEFINE_ARGUMENTS, $event); + return $event->arguments; + } + + return $arguments; } /** diff --git a/src/gql/resolvers/mutations/Entry.php b/src/gql/resolvers/mutations/Entry.php index 67f42261895..975f51bf2b6 100644 --- a/src/gql/resolvers/mutations/Entry.php +++ b/src/gql/resolvers/mutations/Entry.php @@ -94,7 +94,14 @@ public function saveEntry(mixed $source, array $arguments, mixed $context, Resol $canIdentify = !empty($arguments['id']) || !empty($arguments['uid']) || !empty($arguments['draftId']); $entry = $this->populateElementWithData($entry, $arguments, $resolveInfo); - $entry = $this->saveElement($entry); + + if (array_key_exists('asUnpublishedDraft', $arguments) && $arguments['asUnpublishedDraft']) { + $entry->setScenario(Element::SCENARIO_ESSENTIALS); + Craft::$app->getDrafts()->saveElementAsDraft($entry); + } else { + $entry = $this->saveElement($entry); + } + $this->performStructureOperations($entry, $arguments); /** @var EntryQuery $query */ @@ -106,6 +113,9 @@ public function saveEntry(mixed $source, array $arguments, mixed $context, Resol if ($canIdentify) { $query = $this->identifyEntry($query, $arguments); } else { + if (array_key_exists('asUnpublishedDraft', $arguments) && $arguments['asUnpublishedDraft']) { + $query->drafts(null); + } $query->id($entry->id); } diff --git a/src/helpers/App.php b/src/helpers/App.php index f58e9750d3a..8982e6b9505 100644 --- a/src/helpers/App.php +++ b/src/helpers/App.php @@ -133,7 +133,12 @@ public static function env(string $name): mixed } if (($env = getenv($name)) !== false) { - return static::normalizeValue($env); + $value = static::normalizeValue($env); + if (is_string($value)) { + // parse nested variables + $value = self::parseNestedEnv($value); + } + return $value; } if (defined($name)) { @@ -143,6 +148,11 @@ public static function env(string $name): mixed return null; } + private static function parseNestedEnv(string $value): string + { + return preg_replace_callback('/\$\{(\w+)}/', fn(array $m) => static::env($m[1]), $value); + } + /** * Returns a config array for a given class, based on any environment variables or PHP constants named based on its * public properties. @@ -217,19 +227,18 @@ public static function envConfig(string $class, ?string $envPrefix = null): arra */ public static function parseEnv(?string $value): bool|string|null { - if ($value === null) { - return null; + if ($value === null || $value === '') { + return $value; } - if (preg_match('/^\$(\w+)(\/.*)?/', $value, $matches)) { - $env = static::env($matches[1]); + // …${VAR}… + $value = self::parseNestedEnv($value); - if ($env === null) { - // No env var or constant is defined here by that name - return null; - } + // …/$VAR/… + $value = preg_replace_callback('/(?<=^|\/)\$(\w+)(?=$|\/)?/', fn($m) => static::env($m[1]), $value); - $value = $env . ($matches[2] ?? ''); + if ($value === '') { + return null; } if (str_starts_with($value, '@')) { @@ -263,7 +272,7 @@ public static function parseBooleanEnv(mixed $value): ?bool return (bool)$value; } - if (!is_string($value)) { + if (!is_string($value) || $value === '') { return null; } diff --git a/src/helpers/Assets.php b/src/helpers/Assets.php index 022e2d0f5fa..127890ce3f8 100644 --- a/src/helpers/Assets.php +++ b/src/helpers/Assets.php @@ -9,17 +9,22 @@ use Craft; use craft\base\BaseFsInterface; +use craft\base\ElementInterface; use craft\base\FsInterface; use craft\base\LocalFsInterface; use craft\elements\Asset; use craft\enums\TimePeriod; use craft\errors\FsException; +use craft\errors\InvalidSubpathException; use craft\events\RegisterAssetFileKindsEvent; use craft\events\SetAssetFilenameEvent; use craft\fs\Temp; use craft\helpers\ImageTransforms as TransformHelper; +use craft\models\Volume; use craft\models\VolumeFolder; use DateTime; +use Illuminate\Support\Collection; +use Twig\Error\RuntimeError; use yii\base\Event; use yii\base\Exception; use yii\base\InvalidArgumentException; @@ -973,4 +978,66 @@ public static function isTempUploadFs(FsInterface $fs): bool $handle = App::parseEnv(Craft::$app->getConfig()->getGeneral()->tempAssetUploadFs); return $fs->handle === $handle; } + + /** + * Resolves a possibly dynamic subpath for a given element, and returns the rendered subpath and + * matching volume folder (if one exists). + * + * @param Volume $volume + * @param string|null $subpath + * @param ElementInterface|null $element + * @return array{0:string,1:VolumeFolder|null} + * @throws Exception + * @throws InvalidSubpathException + * @since 5.9.0 + */ + public static function resolveSubpath(Volume $volume, ?string $subpath, ?ElementInterface $element = null): array + { + $assetsService = Craft::$app->getAssets(); + $rootFolder = $assetsService->getRootFolderByVolumeId($volume->id); + + // Are we looking for the root folder? + $subpath = trim($subpath ?? '', '/'); + if ($subpath === '') { + return [$subpath, $rootFolder]; + } + + if (str_contains($subpath, '{')) { + // Prepare the path by parsing tokens and normalizing slashes. + try { + if ($element?->duplicateOf) { + $element = $element->duplicateOf->getCanonical(); + } + $renderedSubpath = Craft::$app->getView()->renderObjectTemplate($subpath, $element); + } catch (InvalidConfigException|RuntimeError $e) { + throw new InvalidSubpathException($subpath, null, 0, $e); + } + + // Did any of the tokens return null? + if ( + $renderedSubpath === '' || + trim($renderedSubpath, '/') != $renderedSubpath || + str_contains($renderedSubpath, '//') || + Collection::make(explode('/', $renderedSubpath)) + ->contains(fn(string $segment) => ElementHelper::isTempSlug($segment)) + ) { + throw new InvalidSubpathException($subpath); + } + + // Sanitize the subpath + $segments = array_filter(explode('/', $renderedSubpath), fn(string $segment): bool => $segment !== ':ignore:'); + $generalConfig = Craft::$app->getConfig()->getGeneral(); + $segments = array_map(fn(string $segment): string => FileHelper::sanitizeFilename($segment, [ + 'asciiOnly' => $generalConfig->convertFilenamesToAscii, + ]), $segments); + $subpath = implode('/', $segments); + } + + $folder = $assetsService->findFolder([ + 'volumeId' => $volume->id, + 'path' => $subpath . '/', + ]); + + return [$subpath, $folder]; + } } diff --git a/src/helpers/Cp.php b/src/helpers/Cp.php index 8787a296c9f..b312c5e998b 100644 --- a/src/helpers/Cp.php +++ b/src/helpers/Cp.php @@ -37,6 +37,7 @@ use craft\events\RegisterCpAlertsEvent; use craft\fieldlayoutelements\BaseField; use craft\fieldlayoutelements\CustomField; +use craft\fields\ContentBlock; use craft\models\FieldLayout; use craft\models\FieldLayoutTab; use craft\models\Site; @@ -312,17 +313,21 @@ private static function alertTagHtml(array $tagInfo): string * * - `attributes` – Any custom HTML attributes that should be set on the chip * - `autoReload` – Whether the chip should auto-reload itself when it’s saved + * - `class` – Class name(s) that should be added to the container element + * - `hyperlink` – Whether the chip label should be hyperlinked to the component’s URL (only applies if the component implements [[CpEditable]]) * - `id` – The chip’s `id` attribute * - `inputName` – The `name` attribute that should be set on a hidden input, if set - * - `inputValue` – The `value` attribute that should be set on the hidden input, if `inputName` is set. - * Defaults to [[\craft\base\Identifiable::getId()`]]. + * - `inputValue` – The `value` attribute that should be set on the hidden input, if `inputName` is set. Defaults to [[\craft\base\Identifiable::getId()`]]. * - `labelHtml` – The label HTML, if it should be different from [[Chippable::getUiLabel()]] + * - `overrides` – Any config overrides that should persist when the chip is re-rendered * - `selectable` – Whether the chip should include a checkbox input - * - `showActionMenu` – Whether the chip should include an action menu - * - `showLabel` – Whether the component’s label should be shown + * - `showActionMenu` – Whether the chip should include an action menu (only applies if the component implements [[Actionable]]) + * - `showDescription` – Whether the chip should include the component’s description (only applies if the component implements [[Describable]]) * - `showHandle` – Whether the component’s handle should be show (only applies if the component implements [[Grippable]]) - * - `showStatus` – Whether the component’s status should be shown (if it has statuses) - * - `showThumb` – Whether the component’s thumbnail should be shown (if it has one) + * - `showIndicators` – Whether the component’s indicators should be shown (only applies if the component implements [[Indicative]]) + * - `showLabel` – Whether the component’s label should be shown + * - `showStatus` – Whether the component’s status should be shown (only applies if the component implements [[Statusable]]) + * - `showThumb` – Whether the component’s thumbnail should be shown (only applies if the component implements [[Thumbable]] or [[Iconic]]) * - `size` – The size of the chip (`small` or `large`) * - `sortable` – Whether the chip should include a drag handle * @@ -336,23 +341,23 @@ public static function chipHtml(Chippable $component, array $config = []): strin $config += [ 'attributes' => [], 'autoReload' => true, - 'id' => sprintf('chip-%s', mt_rand()), 'class' => null, - 'hyperlink' => true, + 'hyperlink' => false, + 'id' => sprintf('chip-%s', mt_rand()), 'inputName' => null, 'inputValue' => null, 'labelHtml' => null, + 'overrides' => [], 'selectable' => false, 'showActionMenu' => false, - 'showLabel' => true, + 'showDescription' => false, 'showHandle' => false, + 'showIndicators' => false, + 'showLabel' => true, 'showStatus' => true, 'showThumb' => true, - 'showIndicators' => false, - 'showDescription' => false, 'size' => self::CHIP_SIZE_SMALL, 'sortable' => false, - 'overrides' => [], ]; $config['showActionMenu'] = $config['showActionMenu'] && $component instanceof Actionable; @@ -520,15 +525,24 @@ public static function chipHtml(Chippable $component, array $config = []): strin * * - `attributes` – Any custom HTML attributes that should be set on the chip * - `autoReload` – Whether the chip should auto-reload itself when it’s saved + * - `class` – Class name(s) that should be added to the container element * - `context` – The context the chip is going to be shown in (`index`, `field`, etc.) + * - `hyperlink` – Whether the chip label should be hyperlinked to the element’s URL * - `id` – The chip’s `id` attribute - * - `inputName` – The `name` attribute that should be set on the hidden input, if `context` is set to `field` + * - `inputName` – The `name` attribute that should be set on a hidden input, if set + * - `inputValue` – The `value` attribute that should be set on the hidden input, if `inputName` is set. Defaults to [[\craft\base\Identifiable::getId()`]]. + * - `labelHtml` – The label HTML, if it should be different from [[Chippable::getUiLabel()]] + * - `overrides` – Any config overrides that should persist when the chip is re-rendered * - `selectable` – Whether the chip should include a checkbox input * - `showActionMenu` – Whether the chip should include an action menu + * - `showDescription` – Whether the chip should include the element’s description * - `showDraftName` – Whether to show the draft name beside the label if the element is a draft of a published element + * - `showHandle` – Whether the element’s handle should be show (only applies if the element implements [[Grippable]]) + * - `showIndicators` – Whether the element’s indicators should be shown (only applies if the element implements [[Indicative]]) * - `showLabel` – Whether the element’s label should be shown - * - `showStatus` – Whether the element’s status should be shown (if the element type has statuses) - * - `showThumb` – Whether the element’s thumbnail should be shown (if the element has one) + * - `showProvisionalDraftLabel` – Whether an “Edited” badge should be added to the label if the element is a provisional draft + * - `showStatus` – Whether the element’s status should be shown + * - `showThumb` – Whether the element’s thumbnail should be shown * - `size` – The size of the chip (`small` or `large`) * - `sortable` – Whether the chip should include a drag handle * @@ -581,7 +595,10 @@ public static function elementChipHtml(ElementInterface $element, array $config ); } - if ($element->isProvisionalDraft && ($config['showProvisionalDraftLabel'] ?? $config['showLabel'])) { + if ( + ($config['showProvisionalDraftLabel'] ?? $config['showLabel']) && + ($element->isProvisionalDraft || $element->hasProvisionalChanges) + ) { $config['labelHtml'] = ($config['labelHtml'] ?? '') . self::changeStatusLabelHtml(); } @@ -612,11 +629,13 @@ public static function elementChipHtml(ElementInterface $element, array $config * * - `attributes` – Any custom HTML attributes that should be set on the card * - `autoReload` – Whether the card should auto-reload itself when it’s saved - * - `context` – The context the chip is going to be shown in (`index`, `field`, etc.) + * - `context` – The context the card is going to be shown in (`index`, `field`, etc.) + * - `hyperlink` – Whether the card label should be hyperlinked to the element’s URL * - `id` – The card’s `id` attribute * - `inputName` – The `name` attribute that should be set on the hidden input, if `context` is set to `field` * - `selectable` – Whether the card should include a checkbox input * - `showActionMenu` – Whether the card should include an action menu + * - `showEditButton` – Whether the card should include an edit button * - `sortable` – Whether the card should include a drag handle * * @param ElementInterface $element The element to be rendered @@ -630,12 +649,12 @@ public static function elementCardHtml(ElementInterface $element, array $config 'attributes' => [], 'autoReload' => true, 'context' => 'index', + 'hyperlink' => false, 'id' => sprintf('card-%s', mt_rand()), - 'hyperlink' => true, 'inputName' => null, 'selectable' => false, - 'showEditButton' => true, 'showActionMenu' => false, + 'showEditButton' => true, 'sortable' => false, ]; @@ -726,7 +745,7 @@ public static function elementCardHtml(ElementInterface $element, array $config $labels = array_filter([ $element->showStatusIndicator() ? static::componentStatusLabelHtml($element) : null, - $element->isProvisionalDraft ? self::changeStatusLabelHtml() : null, + $element->isProvisionalDraft || $element->hasProvisionalChanges ? self::changeStatusLabelHtml() : null, ]); if (!empty($labels)) { @@ -1131,7 +1150,7 @@ private static function elementLabelHtml(ElementInterface $element, array $confi // the inner span is needed for `text-overflow: ellipsis` (e.g. within breadcrumbs) if ($content !== '') { if ( - ($config['hyperlink'] ?? true) && + ($config['hyperlink'] ?? false) && !$element->trashed && $config['context'] !== 'modal' && ($url = $attributes['data']['cp-url'] ?? null) @@ -1379,18 +1398,19 @@ public static function componentPreviewHtml(array $components, array $chipConfig public static function elementIndexHtml(string $elementType, array $config = []): string { $config += [ - 'context' => 'index', - 'id' => sprintf('element-index-%s', mt_rand()), 'class' => null, - 'sources' => null, - 'showStatusMenu' => 'auto', - 'showSiteMenu' => 'auto', - 'fieldLayouts' => [], + 'context' => 'index', 'defaultSort' => null, 'defaultTableColumns' => null, - 'registerJs' => true, - 'jsSettings' => [], 'defaultViewMode' => 'table', + 'fieldLayouts' => [], + 'id' => sprintf('element-index-%s', mt_rand()), + 'jsSettings' => [], + 'registerJs' => true, + 'showSiteMenu' => 'auto', + 'showStatusMenu' => 'auto', + 'statuses' => null, + 'sources' => null, ]; if ($config['showStatusMenu'] !== 'auto') { @@ -1573,6 +1593,7 @@ public static function elementIndexHtml(string $elementType, array $config = []) 'elementType' => $elementType, 'context' => $config['context'], 'showStatusMenu' => $config['showStatusMenu'], + 'elementStatuses' => $config['statuses'], 'showSiteMenu' => $config['showSiteMenu'], 'siteIds' => $siteIds, 'canHaveDrafts' => $elementType::hasDrafts(), @@ -2746,30 +2767,7 @@ public static function cardViewDesignerHtml(FieldLayout $fieldLayout, array $con 'disabled' => false, ]; - $allOptions = $fieldLayout->type::cardAttributes(); - - foreach ($fieldLayout->getAllElements() as $layoutElement) { - if ($layoutElement instanceof BaseField && $layoutElement->previewable()) { - $allOptions["layoutElement:$layoutElement->uid"] = [ - 'label' => $layoutElement->label(), - ]; - } - } - - foreach ($fieldLayout->getGeneratedFields() as $field) { - if (($field['name'] ?? '') !== '') { - $allOptions["generatedField:{$field['uid']}"] = [ - 'label' => $field['name'], - ]; - } - } - - foreach ($allOptions as $key => &$option) { - if (!isset($option['value'])) { - $option['value'] = $key; - } - } - + $allOptions = self::cardPreviewOptions($fieldLayout); $selectedOptions = []; $remainingOptions = [...$allOptions]; @@ -2786,7 +2784,6 @@ public static function cardViewDesignerHtml(FieldLayout $fieldLayout, array $con $checkboxSelect = self::checkboxSelectFieldHtml([ 'label' => Craft::t('app', 'Card Attributes'), 'id' => $config['id'], - 'name' => 'cardView', 'options' => [...$selectedOptions, ...$remainingOptions], 'values' => array_keys($selectedOptions), 'sortable' => true, @@ -2822,6 +2819,77 @@ public static function cardViewDesignerHtml(FieldLayout $fieldLayout, array $con Html::endTag('div'); // .card-view-designer } + /** + * Returns an array of available card preview options for the given field layout. + * + * @param FieldLayout $fieldLayout + * @return array{label:string,value:string}[] + * @since 5.9.0 + */ + public static function cardPreviewOptions(FieldLayout $fieldLayout, bool $withAttributes = true): array + { + return self::cardPreviewOptionsInternal($fieldLayout, '', '', $withAttributes); + } + + private static function cardPreviewOptionsInternal( + FieldLayout $fieldLayout, + string $keyPrefix, + string $labelPrefix, + bool $withAttributes, + ): array { + $allOptions = []; + + if ($withAttributes) { + foreach ($fieldLayout->type::cardAttributes($fieldLayout) as $key => $attribute) { + $allOptions[$keyPrefix . $key] = [ + 'label' => $labelPrefix . $attribute['label'], + 'placeholder' => $attribute['placeholder'], + ]; + } + } + + foreach ($fieldLayout->getAllElements() as $layoutElement) { + if ($layoutElement instanceof CustomField) { + try { + $field = $layoutElement->getField(); + } catch (FieldNotFoundException) { + continue; + } + if ($field instanceof ContentBlock) { + $allOptions += self::cardPreviewOptionsInternal( + $field->getFieldLayout(), + "{$keyPrefix}contentBlock:$layoutElement->uid.", + sprintf('%s%s - ', $labelPrefix, $layoutElement->label()), + false, + ); + continue; + } + } + + if ($layoutElement instanceof BaseField && $layoutElement->previewable()) { + $allOptions[$keyPrefix . $layoutElement->key()] = [ + 'label' => sprintf('%s%s', $labelPrefix, $layoutElement->label()), + ]; + } + } + + foreach ($fieldLayout->getGeneratedFields() as $field) { + if (($field['name'] ?? '') !== '') { + $allOptions["generatedField:{$field['uid']}"] = [ + 'label' => $field['name'], + ]; + } + } + + foreach ($allOptions as $key => &$option) { + if (!isset($option['value'])) { + $option['value'] = $key; + } + } + + return $allOptions; + } + /** * Return HTML for managing thumbnail provider and position. * @@ -2845,16 +2913,20 @@ private static function _thumbManagementHtml(FieldLayout $fieldLayout, array $co ['label' => Craft::t('app', 'None'), 'value' => '__none__'], ]; } - $elementThumbnail = $fieldLayout->getThumbField()?->uid; + $thumbnailAlignment = $fieldLayout->getCardThumbAlignment(); + /** @var BaseField[] $thumbableElements */ $thumbableElements = array_filter( $fieldLayout->getAllElements(), fn($element) => $element instanceof BaseField && $element->thumbable() ); foreach ($thumbableElements as $thumbableElement) { - $options[] = ['label' => $thumbableElement->label(), 'value' => $thumbableElement->uid]; + $options[] = [ + 'label' => $thumbableElement->label(), + 'value' => $thumbableElement->key(), + ]; } $thumbHtml = Html::beginTag('div', ['class' => 'thumb-management']) . @@ -2865,9 +2937,8 @@ private static function _thumbManagementHtml(FieldLayout $fieldLayout, array $co $thumbHtml .= self::selectFieldHtml([ 'label' => Craft::t('app', 'Thumbnail Source'), 'id' => 'thumb-source', - 'name' => 'thumbSource', 'options' => $options, - 'value' => $elementThumbnail, + 'value' => $fieldLayout->thumbFieldKey, 'disabled' => $config['disabled'], ]); @@ -2876,8 +2947,7 @@ private static function _thumbManagementHtml(FieldLayout $fieldLayout, array $co $thumbHtml .= self::buttonGroupFieldHtml([ 'label' => Craft::t('app', 'Thumbnail Alignment'), 'id' => 'thumb-alignment', - 'fieldClass' => $elementThumbnail === null ? 'hidden' : false, - 'name' => 'thumbAlignment', + 'fieldClass' => $fieldLayout->getThumbField() === null ? 'hidden' : false, 'options' => [ [ 'icon' => $orientation == 'ltr' ? 'slideout-left' : 'slideout-right', @@ -2915,13 +2985,14 @@ private static function _thumbManagementHtml(FieldLayout $fieldLayout, array $co * Returns HTML for the card preview based on selected fields and attributes. * * @param FieldLayout $fieldLayout - * @param array $cardElements + * @param array $cardElements (deprecated) + * @param bool|null $showThumb * @return string * @throws \Throwable */ - public static function cardPreviewHtml(FieldLayout $fieldLayout, array $cardElements = [], $showThumb = false): string + public static function cardPreviewHtml(FieldLayout $fieldLayout, array $cardElements = [], ?bool $showThumb = null): string { - $hasThumb = $showThumb ?? ($fieldLayout->getThumbField() !== null || $fieldLayout->type::hasThumbs()); + $showThumb ??= $fieldLayout->getThumbField() !== null || $fieldLayout->type::hasThumbs(); $thumbAlignment = $fieldLayout->getCardThumbAlignment(); // get heading @@ -2944,7 +3015,7 @@ public static function cardPreviewHtml(FieldLayout $fieldLayout, array $cardElem 'class' => array_filter([ 'element', 'card', - $hasThumb ? "thumb-$thumbAlignment" : null, + $showThumb ? "thumb-$thumbAlignment" : null, ]), ]); @@ -2956,27 +3027,12 @@ public static function cardPreviewHtml(FieldLayout $fieldLayout, array $cardElem Html::beginTag('div', ['class' => 'card-body']); // get body elements (fields and attributes) - $cardElements = $fieldLayout->getCardBodyElements(null, $cardElements); + $cardElements = $fieldLayout->getCardBodyElements(); foreach ($cardElements as $cardElement) { - if ($cardElement instanceof CustomField) { - try { - $field = $cardElement->getField(); - } catch (FieldNotFoundException) { - continue; - } - $previewHtml .= Html::tag('div', $field->previewPlaceholderHtml(null, null)); - } elseif ($cardElement instanceof BaseField) { - $previewHtml .= Html::tag('div', $cardElement->previewPlaceholderHtml(null, null)); - } elseif (is_array($cardElement) && isset($cardElement['html'])) { - $previewHtml .= Html::tag('div', $cardElement['html']); - } else { - $html = $fieldLayout->type::attributePreviewHtml($cardElement); - if (is_callable($html)) { - $html = $html(); - } - $previewHtml .= Html::tag('div', $html); - } + $previewHtml .= Html::tag('div', $cardElement['html'], [ + 'class' => 'card-attribute-preview', + ]); } if (!empty(array_filter($labels))) { @@ -2991,7 +3047,7 @@ public static function cardPreviewHtml(FieldLayout $fieldLayout, array $cardElem Html::endTag('div'); // .card-content // get thumb placeholder - if ($hasThumb) { + if ($showThumb) { $previewThumb = Html::tag('div', Html::tag('div', Cp::iconSvg('image'), ['class' => 'cp-icon']), ['class' => 'cvd-thumbnail'] @@ -3614,6 +3670,8 @@ public static function normalizeMenuItems(array $items): array /** * Returns a menu item array for the given sites, possibly grouping them by site group. * + * If only one site is meant to be shown, an empty array will be returned. + * * @param array $sites * @param Site|null $selectedSite * @param array $config @@ -3651,6 +3709,8 @@ public static function siteMenuItems( $params = $request->getQueryParamsWithoutPath(); unset($params['fresh']); + $totalSites = 0; + foreach ($siteGroups as $siteGroup) { $groupSites = $siteGroup->getSites(); if (!$config['includeOmittedSites']) { @@ -3661,6 +3721,8 @@ public static function siteMenuItems( continue; } + $totalSites += count($groupSites); + $groupSiteItems = array_map(fn(Site $site) => [ 'status' => $sites[$site->id]['status'] ?? null, 'label' => Craft::t('site', $site->name), @@ -3685,7 +3747,7 @@ public static function siteMenuItems( } } - return $items; + return $totalSites > 1 ? $items : []; } /** diff --git a/src/helpers/ElementHelper.php b/src/helpers/ElementHelper.php index bf3cc2439a7..5f72d0b25fd 100644 --- a/src/helpers/ElementHelper.php +++ b/src/helpers/ElementHelper.php @@ -669,12 +669,19 @@ public static function rootSourceKey(string $sourceKey): string * @param class-string $elementType The element type class * @param string $sourceKey The source key/path * @param string $context The context + * @param bool $withDisabled Whether disabled sources should be included + * @param string|null $page The page to fetch sources for * @return array|null The source definition, or null if it cannot be found */ - public static function findSource(string $elementType, string $sourceKey, string $context = ElementSources::CONTEXT_INDEX): ?array - { + public static function findSource( + string $elementType, + string $sourceKey, + string $context = ElementSources::CONTEXT_INDEX, + bool $withDisabled = false, + ?string $page = null, + ): ?array { $path = explode('/', $sourceKey); - $sources = Craft::$app->getElementSources()->getSources($elementType, $context); + $sources = Craft::$app->getElementSources()->getSources($elementType, $context, $withDisabled, $page); $rootSource = null; while ($path) { @@ -1016,40 +1023,15 @@ public static function renderElements(array $elements, array $variables = []): M */ public static function swapInProvisionalDrafts(array &$elements): void { - $user = self::$provisionalDraftUser ?? Craft::$app->getUser()->getIdentity(); - if (!$user) { - return; - } - - // filter out drafts and revisions - // (don't just exclude derivative elements though! see https://github.com/craftcms/cms/issues/16626) - $canonicalElements = array_filter( - $elements, - fn(ElementInterface $element) => !$element->getIsDraft() && !$element->getIsRevision(), - ); - - if (empty($canonicalElements)) { - return; - } - - $first = reset($canonicalElements); - /** @var T[] $drafts */ - $drafts = $first::find() - ->draftOf($canonicalElements) - ->draftCreator($user) - ->provisionalDrafts() - ->siteId($first->siteId) - ->status(null) - ->indexBy('canonicalId') - ->all(); + $drafts = self::provisionalDrafts($elements); if (empty($drafts)) { return; } // array_filter() preserves keys, so it's safe to loop through it rather than $elements here - foreach ($canonicalElements as $i => $element) { + foreach ($elements as $i => $element) { if (isset($drafts[$element->id])) { $draft = $drafts[$element->id]; $draft->setCanonical($element); @@ -1073,6 +1055,72 @@ public static function swapInProvisionalDrafts(array &$elements): void } } + /** + * Swaps out any canonical elements with provisional drafts, when they exist. + * + * @template T of ElementInterface + * @param T[] $elements + * @since 5.9.0 + */ + public static function loadProvisionalChanges(array $elements): void + { + $drafts = self::provisionalDrafts($elements); + + if (empty($drafts)) { + return; + } + + // array_filter() preserves keys, so it's safe to loop through it rather than $elements here + foreach ($elements as $element) { + if (isset($drafts[$element->id])) { + $draft = $drafts[$element->id]; + $element->hasProvisionalChanges = true; + + foreach ($draft->getModifiedAttributes() as $name) { + $element->$name = $draft->$name; + } + + foreach ($draft->getModifiedFields() as $handle) { + $element->setFieldValue($handle, $draft->getFieldValue($handle)); + } + } + } + } + + /** + * @param ElementInterface[] $elements + * @return ElementInterface[] + */ + private static function provisionalDrafts(array $elements): array + { + $user = self::$provisionalDraftUser ?? Craft::$app->getUser()->getIdentity(); + if (!$user) { + return []; + } + + // filter out drafts and revisions + // (don't just exclude derivative elements though! see https://github.com/craftcms/cms/issues/16626) + $canonicalElements = array_filter( + $elements, + fn(ElementInterface $element) => !$element->getIsDraft() && !$element->getIsRevision(), + ); + + if (empty($canonicalElements)) { + return []; + } + + $first = reset($canonicalElements); + + return $first::find() + ->draftOf($canonicalElements) + ->draftCreator($user) + ->provisionalDrafts() + ->siteId($first->siteId) + ->status(null) + ->indexBy('canonicalId') + ->all(); + } + /** * Returns whether the given element is a multi-site element. * diff --git a/src/helpers/FileHelper.php b/src/helpers/FileHelper.php index 3d65f6cda20..710089908b4 100644 --- a/src/helpers/FileHelper.php +++ b/src/helpers/FileHelper.php @@ -465,6 +465,28 @@ public static function writeToFile(string $file, string $contents, array $option } } + if (!static::isWritable($file)) { + throw new ErrorException("The file path \"$file\" is not writable."); + } + + if (function_exists('disk_free_space')) { + $freeBytes = disk_free_space($dir); + + if ($freeBytes === false) { + Craft::warning("Could not determine the free disk space for \"$dir\"."); + } else { + $bytes = StringHelper::byteLength($contents); + if ($bytes > $freeBytes) { + throw new ErrorException(sprintf( + "Insufficient disk space to write \"%s\". %s bytes free, %s bytes required.", + $file, + $freeBytes, + $bytes, + )); + } + } + } + if (isset($options['lock'])) { $lock = (bool)$options['lock']; } else { diff --git a/src/helpers/StringHelper.php b/src/helpers/StringHelper.php index a1528ff0c7d..5bc241e118f 100644 --- a/src/helpers/StringHelper.php +++ b/src/helpers/StringHelper.php @@ -10,10 +10,11 @@ use BackedEnum; use Craft; use HTMLPurifier_Config; +use Illuminate\Support\Str; use IteratorAggregate; use LitEmoji\LitEmoji; use Normalizer; -use Stringy\Stringy as BaseStringy; +use Throwable; use voku\helper\ASCII; use yii\base\Exception; use yii\base\InvalidArgumentException; @@ -24,6 +25,9 @@ * This helper class provides various multi-byte aware string related manipulation and encoding methods. * * @author Pixel & Tonic, Inc. + * @author Nicolas Grekas + * @author Hamid Sarfraz + * @author Lars Moelleken * @since 3.0.0 */ class StringHelper extends \yii\helpers\StringHelper @@ -52,17 +56,23 @@ class StringHelper extends \yii\helpers\StringHelper * * @param string $str The string to search. * @param string $separator The separator string. - * @param bool $caseSensitive Whether or not to enforce case-sensitivity. + * @param bool $caseSensitive Whether to enforce case-sensitivity. * @return string The resulting string. * @since 3.3.0 */ public static function afterFirst(string $str, string $separator, bool $caseSensitive = true): string { - if ($caseSensitive) { - return (string)BaseStringy::create($str)->afterFirst($separator); + if ($separator === '' || $str === '') { + return ''; } - return (string)BaseStringy::create($str)->afterFirstIgnoreCase($separator); + $offset = $caseSensitive ? mb_strpos($str, $separator) : mb_stripos($str, $separator); + + if ($offset === false) { + return ''; + } + + return mb_substr($str, $offset + mb_strlen($separator)); } /** @@ -70,17 +80,23 @@ public static function afterFirst(string $str, string $separator, bool $caseSens * * @param string $str The string to search. * @param string $separator The separator string. - * @param bool $caseSensitive Whether or not to enforce case-sensitivity. + * @param bool $caseSensitive Whether to enforce case-sensitivity. * @return string The resulting string. * @since 3.3.0 */ public static function afterLast(string $str, string $separator, bool $caseSensitive = true): string { - if ($caseSensitive) { - return (string)BaseStringy::create($str)->afterLast($separator); + if ($separator === '' || $str === '') { + return ''; } - return (string)BaseStringy::create($str)->afterLastIgnoreCase($separator); + $offset = $caseSensitive ? mb_strrpos($str, $separator) : mb_strripos($str, $separator); + + if ($offset === false) { + return ''; + } + + return mb_substr($str, $offset + mb_strlen($separator)); } /** @@ -93,7 +109,7 @@ public static function afterLast(string $str, string $separator, bool $caseSensi */ public static function append(string $str, string $append): string { - return (string)BaseStringy::create($str)->append($append); + return $str . $append; } /** @@ -107,7 +123,7 @@ public static function append(string $str, string $append): string */ public static function appendRandomString(string $str, int $length, string $possibleChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'): string { - return (string)BaseStringy::create($str)->appendRandomString($length, $possibleChars); + return $str . static::randomStringWithChars($possibleChars, $length); } /** @@ -115,13 +131,31 @@ public static function appendRandomString(string $str, int $length, string $poss * * @param string $str The initial un-appended string. * @param string $entropyExtra Extra entropy via a string or int value. - * @param bool $md5 Whether or not to return the unique identifier as a md5 hash. + * @param bool $md5 Whether to return the unique identifier as a md5 hash. * @return string The newly appended string. * @since 3.3.0 */ public static function appendUniqueIdentifier(string $str, string $entropyExtra = '', bool $md5 = true): string { - return (string)BaseStringy::create($str)->appendUniqueIdentifier($entropyExtra, $md5); + try { + $randInt = random_int(0, mt_getrandmax()); + } catch (Throwable) { + $randInt = mt_rand(0, mt_getrandmax()); + } + + $uniqueHelper = $randInt . + session_id() . + ($_SERVER['REMOTE_ADDR'] ?? '') . + ($_SERVER['SERVER_ADDR'] ?? '') . + $entropyExtra; + + $uniqueString = uniqid($uniqueHelper, true); + + if ($md5) { + return md5($uniqueString . $uniqueHelper); + } + + return $uniqueString; } /** @@ -171,7 +205,7 @@ public static function asciiCharMap(bool $flat = false, ?string $language = null */ public static function at(string $str, int $index): string { - return (string)BaseStringy::create($str)->at($index); + return mb_substr($str, $index, 1); } /** @@ -185,11 +219,17 @@ public static function at(string $str, int $index): string */ public static function beforeFirst(string $str, string $separator, bool $caseSensitive = true): string { - if ($caseSensitive) { - return BaseStringy::create($str)->beforeFirst($separator); + if ($separator === '' || $str === '') { + return ''; } - return BaseStringy::create($str)->beforeFirstIgnoreCase($separator); + $offset = $caseSensitive ? mb_strpos($str, $separator) : mb_stripos($str, $separator); + + if ($offset === false) { + return ''; + } + + return mb_substr($str, 0, $offset); } /** @@ -203,11 +243,17 @@ public static function beforeFirst(string $str, string $separator, bool $caseSen */ public static function beforeLast(string $str, string $separator, bool $caseSensitive = true): string { - if ($caseSensitive) { - return BaseStringy::create($str)->beforeLast($separator); + if ($separator === '' || $str === '') { + return ''; + } + + $offset = $caseSensitive ? mb_strrpos($str, $separator) : mb_strripos($str, $separator); + + if ($offset === false) { + return ''; } - return BaseStringy::create($str)->beforeLastIgnoreCase($separator); + return mb_substr($str, 0, $offset); } /** @@ -222,7 +268,20 @@ public static function beforeLast(string $str, string $separator, bool $caseSens */ public static function between(string $str, string $start, string $end, ?int $offset = null): string { - return (string)BaseStringy::create($str)->between($start, $end, $offset); + $startPos = mb_strpos($str, $start, $offset); + + if ($startPos === false) { + return ''; + } + + $substrIndex = $startPos + mb_strlen($start); + $endPos = mb_strpos($str, $end, $substrIndex); + + if ($endPos === false || $endPos === $substrIndex) { + return ''; + } + + return mb_substr($str, $substrIndex, $endPos - $substrIndex); } /** @@ -234,20 +293,20 @@ public static function between(string $str, string $start, string $end, ?int $of */ public static function camelCase(string $str): string { - return (string)BaseStringy::create($str)->camelize(); + return Str::camel($str); } /** - * Returns the string with the first letter of each word capitalized, - * except for when the word is a name which shouldn't be capitalized. + * Returns the string with the first letter of each word capitalized. * * @param string $str The string to parse. * @return string The string with personal names capitalized. * @since 3.3.0 + * @deprecated in 5.9.0. Use [[toPascalCase()]] instead. */ public static function capitalizePersonalName(string $str): string { - return (string)BaseStringy::create($str)->capitalizePersonalName(); + return static::toPascalCase($str); } /** @@ -258,7 +317,7 @@ public static function capitalizePersonalName(string $str): string */ public static function charsAsArray(string $str): array { - return BaseStringy::create($str)->chars(); + return mb_str_split($str); } /** @@ -270,7 +329,7 @@ public static function charsAsArray(string $str): array */ public static function collapseWhitespace(string $str): string { - return (string)BaseStringy::create($str)->collapseWhitespace(); + return trim(mb_ereg_replace('[[:space:]]+', ' ', $str)); } /** @@ -279,12 +338,18 @@ public static function collapseWhitespace(string $str): string * * @param string $haystack The string being checked. * @param string $needle The substring to look for. - * @param bool $caseSensitive Whether or not to force case-sensitivity. - * @return bool Whether or not $haystack contains $needle. + * @param bool $caseSensitive Whether to force case-sensitivity. + * @return bool Whether $haystack contains $needle. */ public static function contains(string $haystack, string $needle, bool $caseSensitive = true): bool { - return BaseStringy::create($haystack)->contains($needle, $caseSensitive); + if (!$caseSensitive) { + // mb_strtolower() isn't as reliable on PHP 8.2 + $haystack = mb_strtoupper($haystack); + $needle = mb_strtoupper($needle); + } + + return str_contains($haystack, $needle); } /** @@ -312,12 +377,22 @@ public static function containsMb4(string $str): bool * * @param string $haystack The string being checked. * @param string[] $needles The substrings to look for. - * @param bool $caseSensitive Whether or not to force case-sensitivity. - * @return bool Whether or not $haystack contains all $needles. + * @param bool $caseSensitive Whether to force case-sensitivity. + * @return bool Whether $haystack contains all $needles. */ public static function containsAll(string $haystack, array $needles, bool $caseSensitive = true): bool { - return BaseStringy::create($haystack)->containsAll($needles, $caseSensitive); + if (empty($needles)) { + return false; + } + + foreach ($needles as $needle) { + if (!static::contains($haystack, $needle, $caseSensitive)) { + return false; + } + } + + return true; } /** @@ -326,12 +401,18 @@ public static function containsAll(string $haystack, array $needles, bool $caseS * * @param string $haystack The string being checked. * @param string[] $needles The substrings to look for. - * @param bool $caseSensitive Whether or not to force case-sensitivity. - * @return bool Whether or not $haystack contains any $needles. + * @param bool $caseSensitive Whether to force case-sensitivity. + * @return bool Whether $haystack contains any $needles. */ public static function containsAny(string $haystack, array $needles, bool $caseSensitive = true): bool { - return BaseStringy::create($haystack)->containsAny($needles, $caseSensitive); + foreach ($needles as $needle) { + if (static::contains($haystack, $needle, $caseSensitive)) { + return true; + } + } + + return false; } /** @@ -373,7 +454,7 @@ public static function convertToUtf8(string $str): string */ public static function count(string $str): int { - return BaseStringy::create($str)->count(); + return mb_strlen($str); } /** @@ -382,12 +463,18 @@ public static function count(string $str): int * * @param string $str The string to search through. * @param string $substring The substring to search for. - * @param bool $caseSensitive Whether or not to enforce case-sensitivity + * @param bool $caseSensitive Whether to enforce case-sensitivity * @return int The number of $substring occurrences. */ public static function countSubstrings(string $str, string $substring, bool $caseSensitive = true): int { - return BaseStringy::create($str)->countSubstr($substring, $caseSensitive); + if (!$caseSensitive) { + // mb_strtolower() isn't as reliable on PHP 8.2 + $str = mb_strtoupper($str); + $substring = mb_strtoupper($substring); + } + + return mb_substr_count($str, $substring); } /** @@ -401,7 +488,7 @@ public static function countSubstrings(string $str, string $substring, bool $cas */ public static function dasherize(string $str): string { - return (string)BaseStringy::create($str)->dasherize(); + return static::delimit($str, '-'); } /** @@ -436,7 +523,8 @@ public static function decdec(string $str): string */ public static function delimit(string $str, string $delimiter): string { - return (string)BaseStringy::create($str)->delimit($delimiter); + $str = (string) mb_ereg_replace('\\B(\\p{Lu})', '-\1', trim($str)); + return mb_ereg_replace('[\\-_\\s]+', $delimiter, mb_strtolower($str)); } /** @@ -490,13 +578,23 @@ public static function encoding(string $str): string * * @param string $str The string to check the end of. * @param string[] $substrings Substrings to look for. - * @param bool $caseSensitive Whether or not to force case-sensitivity. - * @return bool Whether or not $str ends with $substring. + * @param bool $caseSensitive Whether to force case-sensitivity. + * @return bool Whether $str ends with $substring. * @since 3.3.0 */ public static function endsWithAny(string $str, array $substrings, bool $caseSensitive = true): bool { - return BaseStringy::create($str)->endsWithAny($substrings, $caseSensitive); + if ($substrings === []) { + return false; + } + + foreach ($substrings as $substring) { + if (static::endsWith($str, $substring, $caseSensitive)) { + return true; + } + } + + return false; } /** @@ -508,7 +606,11 @@ public static function endsWithAny(string $str, array $substrings, bool $caseSen */ public static function ensureLeft(string $str, string $substring): string { - return (string)BaseStringy::create($str)->ensureLeft($substring); + if (str_starts_with($str, $substring)) { + return $str; + } + + return $substring . $str; } /** @@ -520,7 +622,11 @@ public static function ensureLeft(string $str, string $substring): string */ public static function ensureRight(string $str, string $substring): string { - return (string)BaseStringy::create($str)->ensureRight($substring); + if (str_ends_with($str, $substring)) { + return $str; + } + + return $str . $substring; } /** @@ -532,7 +638,7 @@ public static function ensureRight(string $str, string $substring): string */ public static function escape(string $str): string { - return (string)BaseStringy::create($str)->escape(); + return htmlspecialchars($str); } /** @@ -547,7 +653,102 @@ public static function escape(string $str): string */ public static function extractText(string $str, string $search = '', ?int $length = null, string $replacerForSkippedText = '…'): string { - return (string)BaseStringy::create($str)->extractText($search, $length, $replacerForSkippedText); + if ($str === '') { + return ''; + } + + $trimChars = "\t\r\n -_()!~?=+/*\\,.:;\"'[]{}`&"; + + if ($length === null) { + $length = round(mb_strlen($str) / 2); + } + + if ($search === '') { + if ($length > 0) { + $stringLength = mb_strlen($str); + $end = ($length - 1) > $stringLength ? $stringLength : ($length - 1); + } else { + $end = 0; + } + + $pos = min( + mb_strpos($str, ' ', $end), + mb_strpos($str, '.', $end), + ); + + if ($pos) { + $strSub = mb_substr($str, 0, $pos); + + if ($strSub === '') { + return ''; + } + + return rtrim($strSub, $trimChars) . $replacerForSkippedText; + } + + return $str; + } + + $wordPosition = mb_stripos($str, $search); + $halfSide = (int) ($wordPosition - $length / 2 + mb_strlen($search) / 2); + + $posStart = 0; + if ($halfSide > 0) { + $halfText = mb_substr($str, 0, $halfSide); + if ($halfText !== '') { + $posStart = max( + mb_strrpos($halfText, ' '), + mb_strrpos($halfText, '.'), + ); + } + } + + if ($wordPosition && $halfSide > 0) { + $offset = $posStart + $length - 1; + $real_length = mb_strlen($str); + + if ($offset > $real_length) { + $offset = $real_length; + } + + $posEnd = min( + mb_strpos($str, ' ', $offset), + mb_strpos($str, '.', $offset), + ) - $posStart; + + if (!$posEnd || $posEnd <= 0) { + $strSub = mb_substr($str, $posStart, mb_strlen($str)); + if ($strSub !== '') { + $extract = $replacerForSkippedText . ltrim($strSub, $trimChars); + } else { + $extract = ''; + } + } else { + $strSub = mb_substr($str, $posStart, $posEnd); + $extract = $replacerForSkippedText . trim($strSub, $trimChars) . $replacerForSkippedText; + } + } else { + $offset = $length - 1; + $trueLength = mb_strlen($str); + + if ($offset > $trueLength) { + $offset = $trueLength; + } + + $posEnd = min( + mb_strpos($str, ' ', $offset), + mb_strpos($str, '.', $offset), + ); + + if ($posEnd) { + $strSub = mb_substr($str, 0, $posEnd); + $extract = rtrim($strSub, $trimChars) . $replacerForSkippedText; + } else { + $extract = $str; + } + } + + return $extract; } /** @@ -559,7 +760,11 @@ public static function extractText(string $str, string $search = '', ?int $lengt */ public static function first(string $str, int $number): string { - return (string)BaseStringy::create($str)->first($number); + if ($str === '' || $number <= 0) { + return ''; + } + + return mb_substr($str, 0, $number); } /** @@ -570,7 +775,7 @@ public static function first(string $str, int $number): string */ public static function hasLowerCase(string $str): bool { - return BaseStringy::create($str)->hasLowerCase(); + return mb_ereg_match('.*[[:lower:]]', $str); } /** @@ -581,7 +786,7 @@ public static function hasLowerCase(string $str): bool */ public static function hasUpperCase(string $str): bool { - return BaseStringy::create($str)->hasUpperCase(); + return mb_ereg_match('.*[[:upper:]]', $str); } /** @@ -594,7 +799,23 @@ public static function hasUpperCase(string $str): bool */ public static function htmlDecode(string $str, int $flags = ENT_COMPAT): string { - return (string)BaseStringy::create($str)->htmlDecode($flags); + if (!isset($str[3]) || !str_contains($str, '&')) { + return $str; + } + + do { + $strCompare = $str; + + if (str_contains($str, '&')) { + if (str_contains($str, '&#')) { + $str = (string) preg_replace('/(&#(?:x0*[0-9a-fA-F]{2,6}(?![0-9a-fA-F;])|(?:0*\d{2,6}(?![0-9;]))))/S', '$1;', $str); + } + + $str = html_entity_decode($str, $flags, 'UTF-8'); + } + } while ($strCompare !== $str); + + return $str; } /** @@ -607,7 +828,7 @@ public static function htmlDecode(string $str, int $flags = ENT_COMPAT): string */ public static function htmlEncode(string $str, int $flags = ENT_COMPAT): string { - return (string)BaseStringy::create($str)->htmlEncode($flags); + return htmlentities($str, $flags, 'UTF-8'); } /** @@ -620,7 +841,8 @@ public static function htmlEncode(string $str, int $flags = ENT_COMPAT): string */ public static function humanize(string $str): string { - return (string)BaseStringy::create($str)->humanize(); + $str = str_replace(['_id', '_'], ['', ' '], $str); + return static::upperCaseFirst(trim($str)); } /** @@ -635,11 +857,15 @@ public static function humanize(string $str): string */ public static function indexOf(string $str, string $needle, int $offset = 0, bool $caseSensitive = true): int|false { + if ($str === '' && $needle === '') { + return 0; + } + if ($caseSensitive) { - return BaseStringy::create($str)->indexOf($needle, $offset); + return mb_strpos($str, $needle, $offset); } - return BaseStringy::create($str)->indexOfIgnoreCase($needle, $offset); + return mb_stripos($str, $needle, $offset); } /** @@ -656,11 +882,15 @@ public static function indexOf(string $str, string $needle, int $offset = 0, boo */ public static function indexOfLast(string $str, string $needle, int $offset = 0, bool $caseSensitive = true): int|false { + if ($str === '' && $needle === '') { + return 0; + } + if ($caseSensitive) { - return BaseStringy::create($str)->indexOfLast($needle, $offset); + return mb_strrpos($str, $needle, $offset); } - return BaseStringy::create($str)->indexOfLastIgnoreCase($needle, $offset); + return mb_strripos($str, $needle, $offset); } /** @@ -673,7 +903,14 @@ public static function indexOfLast(string $str, string $needle, int $offset = 0, */ public static function insert(string $str, string $substring, int $index): string { - return (string)BaseStringy::create($str)->insert($substring, $index); + $len = mb_strlen($str); + if ($index > $len) { + return $str; + } + + return mb_substr($str, 0, $index) . + $substring . + mb_substr($str, $index, $len); } /** @@ -684,83 +921,96 @@ public static function insert(string $str, string $substring, int $index): strin * * @param string $str The string to process. * @param string $pattern The string or pattern to match against. - * @return bool Whether or not we match the provided pattern. + * @return bool Whether we match the provided pattern. * @since 3.3.0 */ public static function is(string $str, string $pattern): bool { - return BaseStringy::create($str)->is($pattern); + return Str::is($pattern, $str); } /** * Returns true if the string contains only alphabetic chars, false otherwise. * * @param string $str The string to check. - * @return bool Whether or not $str contains only alphabetic chars. + * @return bool Whether $str contains only alphabetic chars. */ public static function isAlpha(string $str): bool { - return BaseStringy::create($str)->isAlpha(); + return mb_ereg_match('^[[:alpha:]]*$', $str); } /** * Returns true if the string contains only alphabetic and numeric chars, false otherwise. * * @param string $str The string to check. - * @return bool Whether or not $str contains only alphanumeric chars. + * @return bool Whether $str contains only alphanumeric chars. */ public static function isAlphanumeric(string $str): bool { - return BaseStringy::create($str)->isAlphanumeric(); + return mb_ereg_match('^[[:alnum:]]*$', $str); } /** * Returns true if the string is base64 encoded, false otherwise. * * @param string $str The string to check. - * @param bool $emptyStringIsValid Whether or not an empty string is considered valid. - * @return bool Whether or not $str is base64 encoded. + * @param bool $emptyStringIsValid Whether an empty string is considered valid. + * @return bool Whether $str is base64 encoded. * @since 3.3.0 */ public static function isBase64(string $str, bool $emptyStringIsValid = true): bool { - return BaseStringy::create($str)->isBase64($emptyStringIsValid); + if (!$emptyStringIsValid && $str === '') { + return false; + } + + $base64String = base64_decode($str, true); + return $base64String !== false && base64_encode($base64String) === $str; } /** * Returns true if the string contains only whitespace chars, false otherwise. * * @param string $str The string to check. - * @return bool Whether or not $str contains only whitespace characters. + * @return bool Whether $str contains only whitespace characters. * @since 3.3.0 */ public static function isBlank(string $str): bool { - return BaseStringy::create($str)->isBlank(); + return mb_ereg_match('^[[:space:]]*$', $str); } /** * Returns true if the string contains only hexadecimal chars, false otherwise. * * @param string $str The string to check. - * @return bool Whether or not $str contains only hexadecimal chars. + * @return bool Whether $str contains only hexadecimal chars. * @since 3.3.0 */ public static function isHexadecimal(string $str): bool { - return BaseStringy::create($str)->isHexadecimal(); + return mb_ereg_match('^[[:xdigit:]]*$', $str); } /** * Returns true if the string contains HTML-Tags, false otherwise. * * @param string $str The string to check. - * @return bool Whether or not $str contains HTML tags. + * @return bool Whether $str contains HTML tags. * @since 3.3.0 */ public static function isHtml(string $str): bool { - return BaseStringy::create($str)->isHtml(); + if ($str === '') { + return false; + } + + // init + $matches = []; + $str = static::emojiToShortcodes($str); + preg_match("/<\\/?\\w+(?:(?:\\s+\\w+(?:\\s*=\\s*(?:\".*?\"|'.*?'|[^'\">\\s]+))?)*\\s*|\\s*)\\/?>/u", $str, $matches); + return $matches !== []; } /** @@ -770,35 +1020,53 @@ public static function isHtml(string $str): bool * * @param string $str The string to check. * @param bool $onlyArrayOrObjectResultsAreValid - * @return bool Whether or not $str is JSON. + * @return bool Whether $str is JSON. * @since 3.3.0 */ public static function isJson(string $str, bool $onlyArrayOrObjectResultsAreValid = false): bool { - return BaseStringy::create($str)->isJson($onlyArrayOrObjectResultsAreValid); + try { + $decoded = Json::decode($str); + } catch (InvalidArgumentException) { + return false; + } + + if ($decoded === null && strtolower($str) !== 'null') { + return false; + } + + if ($onlyArrayOrObjectResultsAreValid && !is_object($decoded) && !is_array($decoded)) { + return false; + } + + return true; } /** * Returns true if the string contains only lower case chars, false otherwise. * * @param string $str The string to check. - * @return bool Whether or not $str is only lower case characters. + * @return bool Whether $str is only lower case characters. */ public static function isLowerCase(string $str): bool { - return BaseStringy::create($str)->isLowerCase(); + return mb_ereg_match('^[[:lower:]]*$', $str); } /** * Returns true if the string is serialized, false otherwise. * * @param string $str The string to check. - * @return bool Whether or not $str is serialized. + * @return bool Whether $str is serialized. * @since 3.3.0 */ public static function isSerialized(string $str): bool { - return BaseStringy::create($str)->isSerialized(); + if ($str === '') { + return false; + } + + return $str === 'b:0;' || @unserialize($str, []) !== false; } /** @@ -806,11 +1074,11 @@ public static function isSerialized(string $str): bool * otherwise. * * @param string $str The string to check. - * @return bool Whether or not $str contains only lower case characters. + * @return bool Whether $str contains only lower case characters. */ public static function isUpperCase(string $str): bool { - return BaseStringy::create($str)->isUpperCase(); + return mb_ereg_match('^[[:upper:]]*$', $str); } /** @@ -829,12 +1097,13 @@ public static function isUtf8(string $str): bool * Returns true if the string contains only whitespace chars, false otherwise. * * @param string $str The string to check. - * @return bool Whether or not $str contains only whitespace characters. + * @return bool Whether $str contains only whitespace characters. * @since 3.3.0 + * @deprecated in 5.9.0. [[`isBlank()`]] should be used instead. */ public static function isWhitespace(string $str): bool { - return BaseStringy::create($str)->isBlank(); + return static::isBlank($str); } /** @@ -860,7 +1129,11 @@ public static function isUUID(string $uuid): bool */ public static function last(string $str, int $number): string { - return (string)BaseStringy::create($str)->last($number); + if ($str === '' || $number <= 0) { + return ''; + } + + return mb_substr($str, -$number); } /** @@ -869,17 +1142,24 @@ public static function last(string $str, int $number): string * @param string $str The string from which to get the substring. * @param string $needle The substring to look for. * @param bool $beforeNeedle - * @param bool $caseSensetive Whether or not to perform a case sensitive search. + * @param bool $caseSensetive Whether to perform a case sensitive search. * @return string The last $number characters. * @since 3.3.0 */ public static function lastSubstringOf(string $str, string $needle, bool $beforeNeedle = false, bool $caseSensetive = false): string { - if ($caseSensetive) { - return (string)BaseStringy::create($str)->lastSubstringOf($needle, $beforeNeedle); + if ($str === '' || $needle === '') { + return ''; + } + + if ($beforeNeedle) { + $part = $caseSensetive ? mb_strrchr($str, $needle, $beforeNeedle) : mb_strrichr($str, $needle, $beforeNeedle); + ; + } else { + $part = $caseSensetive ? mb_strrchr($str, $needle) : mb_strrichr($str, $needle); } - return (string)BaseStringy::create($str)->lastSubstringOfIgnoreCase($needle, $beforeNeedle); + return $part === false ? '' : $part; } /** @@ -890,7 +1170,7 @@ public static function lastSubstringOf(string $str, string $needle, bool $before */ public static function length(string $str): int { - return BaseStringy::create($str)->length(); + return mb_strlen($str); } /** @@ -903,7 +1183,7 @@ public static function length(string $str): int */ public static function lineWrapAfterWord(string $str, int $limit): string { - return (string)BaseStringy::create($str)->lineWrapAfterWord($limit); + return Str::wordWrap($str, $limit) . "\n"; } /** @@ -915,8 +1195,11 @@ public static function lineWrapAfterWord(string $str, int $limit): string */ public static function lines(string $str): array { - $lines = BaseStringy::create($str)->lines(); - return array_map(fn(BaseStringy $line) => (string)$line, $lines); + if ($str === '') { + return ['']; + } + + return mb_split("[\r\n]{1,2}", $str); } /** @@ -928,7 +1211,7 @@ public static function lines(string $str): array */ public static function firstLine(string $str): string { - return (string)BaseStringy::create($str)->lines()[0]; + return static::lines($str)[0]; } /** @@ -939,7 +1222,7 @@ public static function firstLine(string $str): string */ public static function lowercaseFirst(string $str): string { - return (string)BaseStringy::create($str)->lowerCaseFirst(); + return Str::lcfirst($str); } /** @@ -958,7 +1241,11 @@ public static function lowercaseFirst(string $str): string */ public static function pad(string $str, int $length, string $padStr = ' ', string $padType = 'right'): string { - return (string)BaseStringy::create($str)->pad($length, $padStr, $padType); + return match ($padType) { + 'left' => static::padLeft($str, $length, $padStr), + 'both' => static::padBoth($str, $length, $padStr), + default => static::padRight($str, $length, $padStr), + }; } /** @@ -973,7 +1260,7 @@ public static function pad(string $str, int $length, string $padStr = ' ', strin */ public static function padBoth(string $str, int $length, string $padStr = ' '): string { - return (string)BaseStringy::create($str)->padBoth($length, $padStr); + return Str::padBoth($str, $length, $padStr); } /** @@ -988,7 +1275,7 @@ public static function padBoth(string $str, int $length, string $padStr = ' '): */ public static function padLeft(string $str, int $length, string $padStr = ' '): string { - return (string)BaseStringy::create($str)->padLeft($length, $padStr); + return Str::padLeft($str, $length, $padStr); } /** @@ -1003,7 +1290,7 @@ public static function padLeft(string $str, int $length, string $padStr = ' '): */ public static function padRight(string $str, int $length, string $padStr = ' '): string { - return (string)BaseStringy::create($str)->padRight($length, $padStr); + return Str::padRight($str, $length, $padStr); } /** @@ -1015,7 +1302,7 @@ public static function padRight(string $str, int $length, string $padStr = ' '): */ public static function prepend(string $str, string $string): string { - return (string)BaseStringy::create($str)->prepend($string); + return $string . $str; } /** @@ -1031,7 +1318,7 @@ public static function prepend(string $str, string $string): string public static function randomString(int $length = 36, bool $extendedChars = false): string { if ($extendedChars) { - $validChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890`~!@#$%^&*()-_=+[]\{}|;:\'",./<>?"'; + $validChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890`~!@#$%^&*()-_=+[]\{}|;:\'",./<>?'; } else { $validChars = 'abcdefghijklmnopqrstuvwxyz'; } @@ -1050,10 +1337,15 @@ public static function randomString(int $length = 36, bool $extendedChars = fals */ public static function randomStringWithChars(string $validChars, int $length): string { + if ($validChars === '') { + return ''; + } + $randomString = ''; // count the number of chars in the valid chars string so we know how many choices we have - $numValidChars = static::length($validChars); + $chars = static::charsAsArray($validChars); + $numValidChars = count($chars); // repeat the steps until we've created a string of the right length for ($i = 0; $i < $length; $i++) { @@ -1061,7 +1353,7 @@ public static function randomStringWithChars(string $validChars, int $length): s $randomPick = random_int(0, $numValidChars - 1); // take the random character out of the string of valid chars - $randomChar = $validChars[$randomPick]; + $randomChar = $chars[$randomPick]; // add the randomly-chosen char onto the end of our string $randomString .= $randomChar; @@ -1082,7 +1374,11 @@ public static function randomStringWithChars(string $validChars, int $length): s */ public static function regexReplace(string $str, string $pattern, string $replacement, string $options = 'msr'): string { - return (string)BaseStringy::create($str)->regexReplace($pattern, $replacement, $options); + if ($options === 'msr') { + $options = 'ms'; + } + + return (string) preg_replace("/$pattern/u$options", $replacement, $str); } /** @@ -1095,7 +1391,7 @@ public static function regexReplace(string $str, string $pattern, string $replac */ public static function removeHtml(string $str, ?string $allowableTags = null): string { - return (string)BaseStringy::create($str)->removeHtml($allowableTags ?? ''); + return strip_tags($str, $allowableTags); } /** @@ -1108,7 +1404,7 @@ public static function removeHtml(string $str, ?string $allowableTags = null): s */ public static function removeHtmlBreak(string $str, string $replacement = ''): string { - return (string)BaseStringy::create($str)->removeHtmlBreak($replacement); + return preg_replace("#/\r\n|\r|\n|#isU", $replacement, $str); } /** @@ -1120,7 +1416,11 @@ public static function removeHtmlBreak(string $str, string $replacement = ''): s */ public static function removeLeft(string $str, string $substring): string { - return (string)BaseStringy::create($str)->removeLeft($substring); + if ($substring && str_starts_with($str, $substring)) { + return mb_substr($str, mb_strlen($substring)); + } + + return $str; } /** @@ -1132,7 +1432,11 @@ public static function removeLeft(string $str, string $substring): string */ public static function removeRight(string $str, string $substring): string { - return (string)BaseStringy::create($str)->removeRight($substring); + if ($substring && str_ends_with($str, $substring)) { + return mb_substr($str, 0, mb_strlen($str) - mb_strlen($substring)); + } + + return $str; } /** @@ -1145,7 +1449,7 @@ public static function removeRight(string $str, string $substring): string */ public static function repeat(string $str, int $multiplier): string { - return (string)BaseStringy::create($str)->repeat($multiplier); + return str_repeat($str, $multiplier); } /** @@ -1158,7 +1462,7 @@ public static function repeat(string $str, int $multiplier): string */ public static function replace(string $str, string $search, string $replacement): string { - return (string)BaseStringy::create($str)->replace($search, $replacement); + return str_replace($search, $replacement, $str); } /** @@ -1167,13 +1471,26 @@ public static function replace(string $str, string $search, string $replacement) * @param string $str The haystack to search through. * @param string[] $search The needle(s) to search for. * @param string|string[] $replacement The string(s) to replace with. - * @param bool $caseSensitive Whether or not to perform a case-sensitive search. + * @param bool $caseSensitive Whether to perform a case-sensitive search. * @return string The resulting string after the replacements. * @since 3.3.0 */ public static function replaceAll(string $str, array $search, string|array $replacement, bool $caseSensitive = true): string { - return (string)BaseStringy::create($str)->replaceAll($search, $replacement, $caseSensitive); + if ($caseSensitive) { + return str_replace($search, $replacement, $str); + } + + // str_ireplace() doesn't handle multibyte characters properly + foreach ($search as &$s) { + if ($s === '') { + $s = '/^(?<=.)$/'; + } else { + $s = '/' . preg_quote($s, '/') . '/ui'; + } + } + + return preg_replace($search, $replacement, $str); } /** @@ -1187,7 +1504,11 @@ public static function replaceAll(string $str, array $search, string|array $repl */ public static function replaceBeginning(string $str, string $search, string $replacement): string { - return (string)BaseStringy::create($str)->replaceBeginning($search, $replacement); + if ($search === '') { + return $replacement . $str; + } + + return Str::replaceStart($search, $replacement, $str); } /** @@ -1201,7 +1522,11 @@ public static function replaceBeginning(string $str, string $search, string $rep */ public static function replaceEnding(string $str, string $search, string $replacement): string { - return (string)BaseStringy::create($str)->replaceEnding($search, $replacement); + if ($search === '') { + return $str . $replacement; + } + + return Str::replaceEnd($search, $replacement, $str); } /** @@ -1215,7 +1540,7 @@ public static function replaceEnding(string $str, string $search, string $replac */ public static function replaceFirst(string $str, string $search, string $replacement): string { - return (string)BaseStringy::create($str)->replaceFirst($search, $replacement); + return Str::replaceFirst($search, $replacement, $str); } /** @@ -1229,7 +1554,7 @@ public static function replaceFirst(string $str, string $search, string $replace */ public static function replaceLast(string $str, string $search, string $replacement): string { - return (string)BaseStringy::create($str)->replaceLast($search, $replacement); + return Str::replaceLast($search, $replacement, $str); } /** @@ -1280,7 +1605,7 @@ public static function replaceMb4(string $str, callable|string $replace): string */ public static function reverse(string $str): string { - return (string)BaseStringy::create($str)->reverse(); + return Str::reverse($str); } /** @@ -1297,7 +1622,40 @@ public static function reverse(string $str): string */ public static function safeTruncate(string $str, int $length, string $substring = '', bool $ignoreDoNotSplitWordsForOneWord = true): string { - return (string)BaseStringy::create($str)->safeTruncate($length, $substring, $ignoreDoNotSplitWordsForOneWord); + if ($str === '' || $length <= 0) { + return $substring; + } + + if ($length >= mb_strlen($str)) { + return $str; + } + + // need to further trim the string so we can append the substring + $length -= mb_strlen($substring); + if ($length <= 0) { + return $substring; + } + + $truncated = mb_substr($str, 0, $length); + if ($truncated === '') { + return ''; + } + + // if the last word was truncated + $spacePosition = mb_strpos($str, ' ', $length - 1); + if ($spacePosition !== $length) { + // find pos of the last occurrence of a space, get up to that + $last_position = mb_strrpos($truncated, ' ', 0); + + if ( + $last_position !== false || + ($spacePosition !== false && !$ignoreDoNotSplitWordsForOneWord) + ) { + $truncated = mb_substr($truncated, 0, (int) $last_position); + } + } + + return $truncated . $substring; } /** @@ -1311,7 +1669,31 @@ public static function safeTruncate(string $str, int $length, string $substring */ public static function shortenAfterWord(string $str, int $length, string $strAddOn = '…'): string { - return (string)BaseStringy::create($str)->shortenAfterWord($length, $strAddOn); + if ($str === '' || $length <= 0) { + return ''; + } + + if (mb_strlen($str) <= $length) { + return $str; + } + + if (mb_substr($str, $length - 1, 1) === ' ') { + return (mb_substr($str, 0, $length - 1)) . $strAddOn; + } + + $str = mb_substr($str, 0, $length); + if ($str === '') { + return $strAddOn; + } + + $array = explode(' ', $str, -1); + $new_str = implode(' ', $array); + + if ($new_str === '') { + return (mb_substr($str, 0, $length - 1)) . $strAddOn; + } + + return $new_str . $strAddOn; } /** @@ -1323,7 +1705,16 @@ public static function shortenAfterWord(string $str, int $length, string $strAdd */ public static function shuffle(string $str): string { - return (string)BaseStringy::create($str)->shuffle(); + $indexes = range(0, mb_strlen($str) - 1); + shuffle($indexes); + + $shuffledStr = ''; + + foreach ($indexes as $i) { + $shuffledStr .= mb_substr($str, $i, 1); + } + + return $shuffledStr; } /** @@ -1340,7 +1731,17 @@ public static function shuffle(string $str): string */ public static function slice(string $str, int $start, ?int $end = null): string { - return (string)BaseStringy::create($str)->slice($start, $end); + if ($end === null) { + $length = mb_strlen($str); + } elseif ($end >= 0 && $end <= $start) { + return ''; + } elseif ($end < 0) { + $length = mb_strlen($str) + $end - $start; + } else { + $length = $end - $start; + } + + return mb_substr($str, $start, $length); } /** @@ -1360,9 +1761,7 @@ public static function slice(string $str, int $start, ?int $end = null): string public static function slugify(string $str, string $replacement = '-', ?string $language = null): string { $language ??= Craft::$app->language; - - /** @var ASCII::*_LANGUAGE_CODE $language */ - return (string)BaseStringy::create($str)->slugify($replacement, $language); + return Str::slug($str, $replacement, $language); } /** @@ -1393,20 +1792,6 @@ public static function splitOnWords(string $str): array return ArrayHelper::filterEmptyStringsFromArray($matches[0]); } - /** - * Returns true if the string begins with $substring, false otherwise. By default, the comparison is case-sensitive, - * but can be made insensitive by setting $caseSensitive to false. - * - * @param string $string The string to check the start of. - * @param string $with The substring to look for. - * @param bool $caseSensitive Whether or not to enforce case-sensitivity. - * @return bool Whether or not $str starts with $substring. - */ - public static function startsWith($string, $with, $caseSensitive = true): bool - { - return BaseStringy::create($string)->startsWith($with, $caseSensitive); - } - /** * Returns true if the string begins with any of $substrings, false otherwise. * By default the comparison is case-sensitive, but can be made insensitive by @@ -1414,13 +1799,19 @@ public static function startsWith($string, $with, $caseSensitive = true): bool * * @param string $str The string to check the start of. * @param string[] $substrings The substrings to look for. - * @param bool $caseSensitive Whether or not to enforce case-sensitivity. - * @return bool Whether or not $str starts with $substring. + * @param bool $caseSensitive Whether to enforce case-sensitivity. + * @return bool Whether $str starts with $substring. * @since 3.3.0 */ public static function startsWithAny(string $str, array $substrings, bool $caseSensitive = true): bool { - return BaseStringy::create($str)->startsWithAny($substrings, $caseSensitive); + foreach ($substrings as $substring) { + if (static::startsWith($str, $substring, $caseSensitive)) { + return true; + } + } + + return false; } /** @@ -1432,7 +1823,7 @@ public static function startsWithAny(string $str, array $substrings, bool $caseS */ public static function stripCssMediaQueries(string $str): string { - return (string)BaseStringy::create($str)->stripeCssMediaQueries(); + return preg_replace('#@media\\s+(?:only\\s)?(?:[\\s{(]|screen|all)\\s?[^{]+{.*}\\s*}\\s*#isumU', '', $str); } /** @@ -1444,7 +1835,7 @@ public static function stripCssMediaQueries(string $str): string */ public static function stripEmptyHtmlTags(string $str): string { - return (string)BaseStringy::create($str)->stripeEmptyHtmlTags(); + return preg_replace('/<[^\\/>]*?>\\s*?<\\/[^>]*?>/u', '', $str); } /** @@ -1469,7 +1860,11 @@ public static function stripHtml(string $str): string */ public static function stripWhitespace(string $str): string { - return (string)BaseStringy::create($str)->stripWhitespace(); + if ($str === '') { + return ''; + } + + return preg_replace('/[[:space:]]+/u', '', $str); } /** @@ -1483,7 +1878,11 @@ public static function stripWhitespace(string $str): string */ public static function substr(string $str, int $start, ?int $length = null): string { - return (string)BaseStringy::create($str)->substr($start, $length); + if ($str === '' || $length === 0) { + return ''; + } + + return mb_substr($str, $start, $length); } /** @@ -1499,11 +1898,12 @@ public static function substr(string $str, int $start, ?int $length = null): str */ public static function substringOf(string $str, string $needle, bool $beforeNeedle = false, bool $caseSensitive = false): string { - if ($caseSensitive) { - return (string)BaseStringy::create($str)->substringOf($needle, $beforeNeedle); + if ($str === '' || $needle === '') { + return ''; } - return (string)BaseStringy::create($str)->substringOfIgnoreCase($needle, $beforeNeedle); + $part = $caseSensitive ? mb_strstr($str, $needle, $beforeNeedle) : mb_stristr($str, $needle, $beforeNeedle); + return $part === false ? '' : $part; } /** @@ -1516,7 +1916,7 @@ public static function substringOf(string $str, string $needle, bool $beforeNeed */ public static function surround(string $str, string $substring): string { - return (string)BaseStringy::create($str)->surround($substring); + return Str::wrap($str, $substring); } /** @@ -1527,7 +1927,11 @@ public static function surround(string $str, string $substring): string */ public static function swapCase(string $str): string { - return (string)BaseStringy::create($str)->swapCase(); + if ($str === '') { + return ''; + } + + return mb_strtolower($str) ^ mb_strtoupper($str) ^ $str; } /** @@ -1541,7 +1945,7 @@ public static function swapCase(string $str): string */ public static function tidy(string $str): string { - return (string)BaseStringy::create($str)->tidy(); + return ASCII::normalize_msword($str); } /** @@ -1554,7 +1958,127 @@ public static function tidy(string $str): string */ public static function titleize(string $str, ?array $ignore = null): string { - return (string)BaseStringy::create($str)->titleize($ignore); + if ($str === '') { + return ''; + } + + $smallWords = [ + '(? $matches[1] . Str::ucfirst($matches[2]), + $str, + ); + + // ...and end of title + $str = preg_replace_callback( + '~\\b ( ' . $smallWordsRx . ' ) # small word... + (?= [[:punct:]]* \Z # ...at the end of the title... + | [\'"’”)\]] [ ] ) # ...or of an inserted subphrase? + ~uxi', + fn(array $matches) => Str::ucfirst($matches[1]), + $str, + ); + + // Exceptions for small words in hyphenated compound words. + // e.g. "in-flight" -> In-Flight + $str = preg_replace_callback( + '~\\b + (? Str::ucfirst($matches[1]), + $str, + ); + + // e.g. "Stand-in" -> "Stand-In" (Stand is already capped at this point) + return preg_replace_callback( + '~\\b + (? $matches[1] . Str::ucfirst($matches[2]), + $str, + ); } /** @@ -1573,7 +2097,126 @@ public static function titleize(string $str, ?array $ignore = null): string */ public static function titleizeForHumans(string $str, array $ignore = []): string { - return (string)BaseStringy::create($str)->titleizeForHumans($ignore); + if ($str === '') { + return ''; + } + + $smallWords = [ + '(? $matches[1] . Str::ucfirst($matches[2]), + $str, + ); + + // ...and end of title + $str = preg_replace_callback( + '~\\b ( ' . $smallWordsRx . ' ) # small word... + (?= [[:punct:]]* \Z # ...at the end of the title... + | [\'"’”)\]] [ ] ) # ...or of an inserted subphrase? + ~uxi', + fn(array $matches) => Str::ucfirst($matches[1]), + $str + ); + + // Exceptions for small words in hyphenated compound words. + // e.g. "in-flight" -> In-Flight + $str = preg_replace_callback( + '~\\b + (? Str::ucfirst($matches[1]), + $str, + ); + + // e.g. "Stand-in" -> "Stand-In" (Stand is already capped at this point) + return preg_replace_callback( + '~\\b + (? $matches[1] . Str::ucfirst($matches[2]), + $str, + ); } /** @@ -1588,11 +2231,8 @@ public static function toAscii(string $str, ?string $language = null): string { // Normalize NFD chars to NFC $str = Normalizer::normalize($str, Normalizer::FORM_C); - $language ??= Craft::$app->language; - - /** @var ASCII::*_LANGUAGE_CODE $language */ - return (string)BaseStringy::create($str)->toAscii($language); + return ASCII::to_ascii($str, $language); } /** @@ -1610,7 +2250,7 @@ public static function toAscii(string $str, ?string $language = null): string */ public static function toBoolean(string $str): bool { - return BaseStringy::create($str)->toBoolean(); + return App::parseBooleanEnv($str) ?? false; } /** @@ -1621,7 +2261,7 @@ public static function toBoolean(string $str): bool */ public static function toCamelCase(string $str): string { - return static::camelCase($str); + return Str::camel($str); } /** @@ -1649,7 +2289,7 @@ public static function toKebabCase(string $str, string $glue = '-', bool $lower */ public static function toLowerCase(string $str): string { - return (string)BaseStringy::create($str)->toLowerCase(); + return Str::lower($str); } /** @@ -1675,7 +2315,7 @@ public static function toPascalCase(string $str): string */ public static function toSnakeCase(string $str): string { - return (string)BaseStringy::create($str)->snakeize(); + return Str::snake($str); } /** @@ -1689,7 +2329,8 @@ public static function toSnakeCase(string $str): string */ public static function toSpaces(string $str, int $tabLength = 4): string { - return (string)BaseStringy::create($str)->toSpaces($tabLength); + $tab = str_repeat(' ', $tabLength); + return str_replace("\t", $tab, $str); } /** @@ -1737,7 +2378,8 @@ public static function toString(mixed $object, string $glue = ','): string */ public static function toTabs(string $str, int $tabLength = 4): string { - return (string)BaseStringy::create($str)->toTabs($tabLength); + $tab = str_repeat(' ', $tabLength); + return str_replace($tab, "\t", $str); } /** @@ -1748,7 +2390,7 @@ public static function toTabs(string $str, int $tabLength = 4): string */ public static function toTitleCase(string $str): string { - return (string)BaseStringy::create($str)->toTitleCase(); + return Str::title($str); } /** @@ -1763,7 +2405,7 @@ public static function toTitleCase(string $str): string */ public static function toTransliterate(string $str, bool $strict = false): string { - return (string)BaseStringy::create($str)->toTransliterate($strict); + return ASCII::to_transliterate($str, strict: $strict); } /** @@ -1774,7 +2416,7 @@ public static function toTransliterate(string $str, bool $strict = false): strin */ public static function toUpperCase(string $str): string { - return (string)BaseStringy::create($str)->toUpperCase(); + return Str::upper($str); } /** @@ -1849,7 +2491,18 @@ public static function toHandle(string $str): string */ public static function trim(string $str, ?string $chars = null): string { - return (string)BaseStringy::create($str)->trim($chars); + if ($str === '') { + return ''; + } + + if ($chars !== null) { + $chars = preg_quote($chars); + $pattern = "^[{$chars}]+|[{$chars}]+\$"; + } else { + $pattern = '^[\\s]+|[\\s]+$'; + } + + return mb_ereg_replace($pattern, '', $str); } /** @@ -1864,7 +2517,18 @@ public static function trim(string $str, ?string $chars = null): string */ public static function trimLeft(string $str, ?string $chars = null): string { - return (string)BaseStringy::create($str)->trimLeft($chars); + if ($str === '') { + return ''; + } + + if ($chars !== null) { + $chars = preg_quote($chars); + $pattern = "^[{$chars}]+"; + } else { + $pattern = '^[\\s]+'; + } + + return mb_ereg_replace($pattern, '', $str); } /** @@ -1879,7 +2543,18 @@ public static function trimLeft(string $str, ?string $chars = null): string */ public static function trimRight(string $str, ?string $chars = null): string { - return (string)BaseStringy::create($str)->trimRight($chars); + if ($str === '') { + return ''; + } + + if ($chars !== null) { + $chars = preg_quote($chars); + $pattern = "[{$chars}]+$"; + } else { + $pattern = '[\\s]+$'; + } + + return mb_ereg_replace($pattern, '', $str); } /** @@ -1890,10 +2565,11 @@ public static function trimRight(string $str, ?string $chars = null): string * @param string $str The string to upper camelize. * @return string The upper camelized $str. * @since 3.3.0 + * @deprecated in 5.9.0. [[toPascalCase()]] should be used instead. */ public static function upperCamelize(string $str): string { - return (string)BaseStringy::create($str)->upperCamelize(); + return static::toPascalCase($str); } /** @@ -1905,7 +2581,7 @@ public static function upperCamelize(string $str): string */ public static function upperCaseFirst(string $str): string { - return (string)BaseStringy::create($str)->upperCaseFirst(); + return Str::ucfirst($str); } /** @@ -1949,9 +2625,14 @@ public static function idnToUtf8Email(string $email): string return $email; } + if (Craft::$app->getConfig()->getGeneral()->useIdnaNontransitionalToUnicode && defined('IDNA_NONTRANSITIONAL_TO_UNICODE')) { + $variant = IDNA_NONTRANSITIONAL_TO_UNICODE; + } else { + $variant = INTL_IDNA_VARIANT_UTS46; + } $parts = explode('@', $email, 2); foreach ($parts as &$part) { - if (!empty($part) && ($part = idn_to_utf8($part, IDNA_DEFAULT, INTL_IDNA_VARIANT_UTS46)) === false) { + if (!empty($part) && ($part = idn_to_utf8($part, IDNA_DEFAULT, $variant)) === false) { return $email; } } diff --git a/src/helpers/Template.php b/src/helpers/Template.php index 8f3151d9e8f..a682296bde6 100644 --- a/src/helpers/Template.php +++ b/src/helpers/Template.php @@ -12,6 +12,7 @@ use craft\db\Paginator; use craft\web\twig\variables\Paginate; use craft\web\View; +use Stringable; use Twig\Environment; use Twig\Error\RuntimeError; use Twig\Extension\CoreExtension; @@ -145,6 +146,11 @@ public static function attribute( } try { + // workaround for https://github.com/twigphp/Twig/issues/4701 + if ($type !== TwigTemplate::METHOD_CALL && $item instanceof Stringable) { + $item = (string) $item; + } + return CoreExtension::getAttribute( $env, $source, diff --git a/src/helpers/UrlHelper.php b/src/helpers/UrlHelper.php index 603a0d8dfa8..1e6e1787801 100644 --- a/src/helpers/UrlHelper.php +++ b/src/helpers/UrlHelper.php @@ -96,11 +96,11 @@ public static function buildQuery(array $params): string if ($query === '') { return ''; } - // Decode the param names and a few select chars in param values + // Decode a few select chars $params = []; foreach (explode('&', $query) as $param) { [$n, $v] = array_pad(explode('=', $param, 2), 2, ''); - $n = urldecode($n); + $n = str_replace(['%2F', '%7B', '%7D'], ['/', '{', '}'], $n); $v = str_replace(['%2F', '%7B', '%7D'], ['/', '{', '}'], $v); $params[] = $v !== '' ? "$n=$v" : $n; } @@ -574,6 +574,29 @@ public static function cpHost(): string return static::hostInfo(static::baseCpUrl()); } + /** + * Returns a CP referral URL. + * + * @return string|null + * @since 5.9.0 + */ + public static function cpReferralUrl(): ?string + { + $referrer = Craft::$app->getRequest()->getReferrer(); + + // Make sure it didn't refer itself + if ($referrer === Craft::$app->getRequest()->getFullUri()) { + return null; + } + + // Make sure the CP referred it + if (!str_starts_with($referrer, self::baseCpUrl())) { + return null; + } + + return $referrer; + } + /** * Parses a URL for the host info. * diff --git a/src/imagetransforms/ImageTransformer.php b/src/imagetransforms/ImageTransformer.php index e2f9e803d07..8e1eb14836a 100644 --- a/src/imagetransforms/ImageTransformer.php +++ b/src/imagetransforms/ImageTransformer.php @@ -29,6 +29,7 @@ use craft\helpers\Queue; use craft\helpers\StringHelper; use craft\helpers\UrlHelper; +use craft\i18n\Translation; use craft\image\Raster; use craft\models\ImageTransform; use craft\models\ImageTransformIndex; @@ -108,6 +109,9 @@ public function getTransformUrl(Asset $asset, ImageTransform $imageTransform, bo // Add a Generate Image Transform job to the queue, in case the temp URL never gets requested Queue::push(new GenerateImageTransform([ 'transformId' => $index->id, + 'description' => Translation::prep('app', 'Generating image transform for {file}', [ + 'file' => $asset->getFilename(), + ]), ]), 2048); // Prevent the page from being cached diff --git a/src/mail/Mailer.php b/src/mail/Mailer.php index b2ce6c7c2a2..e8171a62b40 100644 --- a/src/mail/Mailer.php +++ b/src/mail/Mailer.php @@ -199,7 +199,7 @@ public function send($message): bool try { $message->setHtmlBody($view->renderTemplate($template, array_merge($variables, [ - 'body' => Template::raw(Markdown::process($htmlBody)), + 'body' => Template::raw(Markdown::process($htmlBody, 'gfm')), ]), $templateMode)); } catch (Throwable $e) { // Just log it and don't worry about the HTML body diff --git a/src/migrations/Install.php b/src/migrations/Install.php index 08c742429a5..f7e6c0452e9 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -447,6 +447,7 @@ public function createTables(): void 'description' => $this->text(), 'icon' => $this->string(), 'color' => $this->string(), + 'uiLabelFormat' => $this->string()->defaultValue('{title}')->notNull(), 'hasTitleField' => $this->boolean()->defaultValue(true)->notNull(), 'titleTranslationMethod' => $this->string()->notNull()->defaultValue(Field::TRANSLATION_METHOD_SITE), 'titleTranslationKeyFormat' => $this->text(), @@ -856,7 +857,6 @@ public function createTables(): void 'sortOrder' => $this->smallInteger()->unsigned(), 'colspan' => $this->tinyInteger(), 'settings' => $this->json(), - 'enabled' => $this->boolean()->defaultValue(true)->notNull(), 'dateCreated' => $this->dateTime()->notNull(), 'dateUpdated' => $this->dateTime()->notNull(), 'uid' => $this->uid(), @@ -901,6 +901,7 @@ public function createIndexes(): void $this->createIndex(null, Table::ELEMENTS, ['archived', 'dateDeleted', 'draftId', 'revisionId', 'canonicalId'], false); $this->createIndex(null, Table::ELEMENTS, ['archived', 'dateDeleted', 'draftId', 'revisionId', 'canonicalId', 'enabled'], false); $this->createIndex(null, Table::ELEMENTS_BULKOPS, ['timestamp'], false); + $this->createIndex(null, Table::ELEMENTS_OWNERS, ['sortOrder'], false); $this->createIndex(null, Table::ELEMENTS_SITES, ['elementId', 'siteId'], true); $this->createIndex(null, Table::ELEMENTS_SITES, ['siteId'], false); $this->createIndex(null, Table::ELEMENTS_SITES, ['title', 'siteId'], false); @@ -1107,7 +1108,6 @@ public function addForeignKeys(): void $this->addForeignKey(null, Table::RELATIONS, ['sourceSiteId'], Table::SITES, ['id'], 'CASCADE', 'CASCADE'); $this->addForeignKey(null, Table::REVISIONS, ['creatorId'], Table::USERS, ['id'], 'SET NULL', null); $this->addForeignKey(null, Table::REVISIONS, ['canonicalId'], Table::ELEMENTS, ['id'], 'CASCADE', null); - $this->addForeignKey(null, Table::SEARCHINDEXQUEUE, 'elementId', Table::ELEMENTS, 'id', 'CASCADE', null); $this->addForeignKey(null, Table::SEARCHINDEXQUEUE_FIELDS, 'jobId', Table::SEARCHINDEXQUEUE, 'id', 'CASCADE', null); $this->addForeignKey(null, Table::SECTIONS, ['structureId'], Table::STRUCTURES, ['id'], 'SET NULL', null); $this->addForeignKey(null, Table::SECTIONS_ENTRYTYPES, ['sectionId'], Table::SECTIONS, ['id'], 'CASCADE', null); diff --git a/src/migrations/m230617_070415_entrify_matrix_blocks.php b/src/migrations/m230617_070415_entrify_matrix_blocks.php index ae1cadfd07c..5b45fd6d275 100644 --- a/src/migrations/m230617_070415_entrify_matrix_blocks.php +++ b/src/migrations/m230617_070415_entrify_matrix_blocks.php @@ -107,9 +107,7 @@ public function safeUp(): bool $fieldLayout = $fieldLayoutUid ? $fieldsService->getLayoutByUid($fieldLayoutUid) : new FieldLayout(); $fieldLayout->type = Entry::class; $entryType->setFieldLayout($fieldLayout); - /** @var PreviewableFieldInterface|null $thumbField */ - $thumbField = null; - $foundPreviewableField = false; + $cardViewItems = []; foreach ($fieldLayout?->getCustomFieldElements() ?? [] as $layoutElement) { $subField = $layoutElement->getField(); @@ -145,18 +143,14 @@ public function safeUp(): bool 'uid' => $subField->uid, ], updateTimestamp: false); - if (!$thumbField && $subField instanceof ThumbableFieldInterface) { - $layoutElement->providesThumbs = true; - $thumbField = $subField; - } elseif (!$foundPreviewableField && $subField instanceof PreviewableFieldInterface) { - $layoutElement->includeInCards = true; - $foundPreviewableField = true; + if (!isset($fieldLayout->thumbFieldKey) && $subField instanceof ThumbableFieldInterface) { + $fieldLayout->thumbFieldKey = "layoutElement:$layoutElement->uid"; + } elseif ($subField instanceof PreviewableFieldInterface) { + $cardViewItems[] = "layoutElement:$layoutElement->uid"; } } - if (!$foundPreviewableField && $thumbField instanceof PreviewableFieldInterface) { - $thumbField->layoutElement->includeInCards = true; - } + $fieldLayout->setCardView($cardViewItems); } // update the field config diff --git a/src/migrations/m250910_144630_add_elements_owners_sort_order_index.php b/src/migrations/m250910_144630_add_elements_owners_sort_order_index.php new file mode 100644 index 00000000000..060bd9fe18b --- /dev/null +++ b/src/migrations/m250910_144630_add_elements_owners_sort_order_index.php @@ -0,0 +1,32 @@ +createIndexIfMissing(Table::ELEMENTS_OWNERS, ['sortOrder'], false); + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + $this->dropIndexIfExists(Table::ELEMENTS_OWNERS, ['sortOrder'], false); + + return true; + } +} diff --git a/src/migrations/m251030_203440_drop_widgets_enabled_column.php b/src/migrations/m251030_203440_drop_widgets_enabled_column.php new file mode 100644 index 00000000000..880806233ce --- /dev/null +++ b/src/migrations/m251030_203440_drop_widgets_enabled_column.php @@ -0,0 +1,33 @@ +db->columnExists(Table::WIDGETS, 'enabled')) { + $this->dropColumn(Table::WIDGETS, 'enabled'); + } + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + $this->addColumn(Table::WIDGETS, 'enabled', $this->boolean()->defaultValue(true)->notNull()->after('settings')); + return true; + } +} diff --git a/src/migrations/m251110_192405_entry_type_ui_label_formats.php b/src/migrations/m251110_192405_entry_type_ui_label_formats.php new file mode 100644 index 00000000000..f57b23bb792 --- /dev/null +++ b/src/migrations/m251110_192405_entry_type_ui_label_formats.php @@ -0,0 +1,30 @@ +addColumn(Table::ENTRYTYPES, 'uiLabelFormat', $this->string()->defaultValue('{title}')->notNull()->after('color')); + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + $this->dropColumn(Table::ENTRYTYPES, 'uiLabelFormat'); + return true; + } +} diff --git a/src/migrations/m251205_190131_drop_searchindexqueue_fk.php b/src/migrations/m251205_190131_drop_searchindexqueue_fk.php new file mode 100644 index 00000000000..6dfef73c60a --- /dev/null +++ b/src/migrations/m251205_190131_drop_searchindexqueue_fk.php @@ -0,0 +1,30 @@ +dropForeignKeyIfExists(Table::SEARCHINDEXQUEUE, 'elementId'); + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + $this->addForeignKey(null, Table::SEARCHINDEXQUEUE, 'elementId', Table::ELEMENTS, 'id', 'CASCADE', null); + return true; + } +} diff --git a/src/migrations/m251230_192239_update_field_layouts.php b/src/migrations/m251230_192239_update_field_layouts.php new file mode 100644 index 00000000000..b1278740dc7 --- /dev/null +++ b/src/migrations/m251230_192239_update_field_layouts.php @@ -0,0 +1,118 @@ +select(['id', 'config']) + ->from(Table::FIELDLAYOUTS) + ->all(); + + foreach ($dbLayouts as $layout) { + $config = is_string($layout['config']) ? Json::decode($layout['config']) : $layout['config']; + if ($this->updateLayoutConfig($config)) { + $this->update(Table::FIELDLAYOUTS, [ + 'config' => $config, + ], ['id' => $layout['id']]); + } + } + + $pc = Craft::$app->getProjectConfig(); + $muteEvents = $pc->muteEvents; + $pc->muteEvents = true; + + $pcLayoutArrays = $pc->find( + fn(array $item, string $path) => !empty($item['fieldLayouts']) && is_array($item['fieldLayouts']), + ); + + foreach ($pcLayoutArrays as $path => $configs) { + $updated = false; + foreach ($configs['fieldLayouts'] as &$config) { + if (is_array($config) && $this->updateLayoutConfig($config)) { + $updated = true; + } + } + unset($config); + if ($updated) { + $pc->set($path, $configs); + } + } + + $pc->muteEvents = $muteEvents; + + return true; + } + + private function updateLayoutConfig(array &$config): bool + { + if (empty($config['tabs'])) { + return false; + } + + $updateCardView = empty($config['cardView']); + $updateThumbField = empty($config['thumbFieldKey']); + + if (!$updateCardView && !$updateThumbField) { + return false; + } + + $updated = false; + + if ($updateCardView) { + $config['cardView'] = []; + } + + foreach ($config['tabs'] as &$tab) { + if (empty($tab['elements'])) { + continue; + } + + foreach ($tab['elements'] as &$element) { + if (isset($element['uid'])) { + if (isset($element['includeInCards'])) { + if ($updateCardView && $element['includeInCards']) { + $config['cardView'][] = "layoutElement:{$element['uid']}"; + } + unset($element['includeInCards']); + $updated = true; + } + + if (isset($element['providesThumbs'])) { + if ($updateThumbField && $element['providesThumbs']) { + $config['thumbFieldKey'] = "layoutElement:{$element['uid']}"; + } + unset($element['providesThumbs']); + $updated = true; + } + } + } + } + unset($tab); + + return $updated; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + echo "m251230_192239_update_field_layouts cannot be reverted.\n"; + return false; + } +} diff --git a/src/models/EntryType.php b/src/models/EntryType.php index 888a5f200d6..f2a1eae4daf 100644 --- a/src/models/EntryType.php +++ b/src/models/EntryType.php @@ -94,6 +94,12 @@ public static function get(int|string $id): ?static */ public ?Color $color = null; + /** + * @var string UI label format + * @since 5.9.0 + */ + public string $uiLabelFormat = '{title}'; + /** * @var bool Has title field */ @@ -451,6 +457,7 @@ public function getConfig(): array 'description' => $this->description ?: null, 'icon' => $this->icon || $this->icon === '0' ? $this->icon : null, 'color' => $this->color?->value, + 'uiLabelFormat' => $this->uiLabelFormat, 'hasTitleField' => $this->hasTitleField, 'titleTranslationMethod' => $this->titleTranslationMethod, 'titleTranslationKeyFormat' => $this->titleTranslationKeyFormat ?: null, diff --git a/src/models/FieldLayout.php b/src/models/FieldLayout.php index 8b14b4ae816..631187bc8e8 100644 --- a/src/models/FieldLayout.php +++ b/src/models/FieldLayout.php @@ -28,6 +28,7 @@ use craft\fieldlayoutelements\Markdown; use craft\fieldlayoutelements\Template; use craft\fieldlayoutelements\Tip; +use craft\fields\ContentBlock; use craft\helpers\ArrayHelper; use craft\helpers\Html; use craft\helpers\StringHelper; @@ -220,6 +221,17 @@ public static function createFromConfig(array $config): self */ public ?array $reservedFieldHandles = null; + /** + * @var string|null The element key that provides thumbnails for this layout + * @since 5.9.0 + */ + public ?string $thumbFieldKey = null; + + /** + * @see getThumbField() + */ + private BaseField|false $thumbField; + /** * @var BaseField[][] * @see getAvailableCustomFields() @@ -265,6 +277,12 @@ public static function createFromConfig(array $config): self */ private array $_cardView; + /** + * @var array + * @see cardAttributes() + */ + private array $_cardAttributes; + /** * @var string * @see getCardThumbAlignment() @@ -523,15 +541,7 @@ public function getCardView(): array */ public function setCardView(?array $items): void { - $this->_cardView = []; - - if ($items !== null) { - foreach ($items as $item) { - $this->_cardView[] = $item; - } - } - - // Clear caches + $this->_cardView = array_values($items ?? []); $this->reset(); } @@ -781,6 +791,7 @@ public function getConfig(): ?array 'tabs' => $tabConfigs, 'generatedFields' => $generatedFields, 'cardView' => $cardViewConfig, + 'thumbFieldKey' => $this->thumbFieldKey, 'cardThumbAlignment' => $cardThumbAlignment, ]; } @@ -793,7 +804,7 @@ public function getConfig(): ?array public function resetUids(): void { $this->uid = StringHelper::UUID(); - $cardView = $this->getCardView(); + $cardViewReplacements = []; foreach ($this->getTabs() as $tab) { $tab->uid = StringHelper::UUID(); @@ -801,15 +812,18 @@ public function resetUids(): void foreach ($tab->getElements() as $element) { $oldUid = $element->uid; $element->uid = StringHelper::UUID(); - - $cardViewPos = array_search("layoutElement:$oldUid", $cardView); - if ($cardViewPos !== false) { - $cardView[$cardViewPos] = "layoutElement:$element->uid"; - } + $cardViewReplacements["layoutElement:$oldUid"] = "layoutElement:$element->uid"; } } - $this->setCardView($cardView); + // update the card view items + // (look for `layoutElement:x` anywhere in the item, in case it also + // includes a content block field UUID) + $cardViewItems = []; + foreach ($this->getCardView() as $item) { + $cardViewItems[] = strtr($item, $cardViewReplacements); + } + $this->setCardView($cardViewItems); } /** @@ -825,6 +839,48 @@ public function getElementByUid(string $uid): ?FieldLayoutElement return $this->_element($filter); } + /** + * Returns a layout element by its `layoutElement:` key. + * + * @param string $key + * @return FieldLayoutElement|null + * @since 5.9.0 + */ + public function getElementByKey(string $key): ?FieldLayoutElement + { + if (str_starts_with($key, 'layoutElement:')) { + $uid = StringHelper::removeLeft($key, 'layoutElement:'); + return $this->getElementByUid($uid); + } + + if (!str_starts_with($key, 'contentBlock:')) { + return null; + } + + $keyParts = explode('.', $key); + $key = array_shift($keyParts); + + // get the Content Block field + $uid = StringHelper::removeLeft($key, 'contentBlock:'); + $layoutElement = $this->getElementByUid($uid); + + if (!$layoutElement instanceof CustomField) { + return null; + } + + try { + $field = $layoutElement->getField(); + } catch (FieldNotFoundException) { + return null; + } + + if (!$field instanceof ContentBlock) { + return null; + } + + return $field->getFieldLayout()->getElementByKey(implode('.', $keyParts)); + } + /** * Returns the layout elements of a given type. * @@ -997,7 +1053,7 @@ public function getVisibleCustomFields(ElementInterface $element): array public function getEditableCustomFields(ElementInterface $element): array { return $this->_customFields( - fn(CustomField $layoutElement) => $layoutElement->editable(), + fn(CustomField $layoutElement) => $layoutElement->editable($element), $element, ); } @@ -1010,12 +1066,21 @@ public function getEditableCustomFields(ElementInterface $element): array */ public function getThumbField(): ?BaseField { - /** @var BaseField|null */ - return $this->_element(fn(FieldLayoutElement $layoutElement) => ( - $layoutElement instanceof BaseField && - $layoutElement->thumbable() && - $layoutElement->providesThumbs - )); + if (!isset($this->thumbField)) { + if (!isset($this->thumbFieldKey)) { + return null; + } + + $field = $this->getElementByKey($this->thumbFieldKey); + if (!$field instanceof BaseField || !$field->thumbable()) { + $this->thumbField = false; + return null; + } + + $this->thumbField = $field; + } + + return $this->thumbField ?: null; } /** @@ -1024,14 +1089,16 @@ public function getThumbField(): ?BaseField * @param ElementInterface|null $element * @return BaseField[] * @since 5.0.0 + * @deprecated in 5.9.0 */ public function getCardBodyFields(?ElementInterface $element): array { + $cardViewItems = array_flip($this->getCardView()); /** @var BaseField[] */ return iterator_to_array($this->_elements(fn(FieldLayoutElement $layoutElement) => ( $layoutElement instanceof BaseField && $layoutElement->previewable() && - $layoutElement->includeInCards + (isset($cardViewItems[$layoutElement->attribute()]) || isset($cardViewItems["layoutElement:$layoutElement->uid"])) ), $element)); } @@ -1040,15 +1107,16 @@ public function getCardBodyFields(?ElementInterface $element): array * * @return array * @since 5.5.0 + * @deprecated in 5.9.0 */ public function getCardBodyAttributes(): array { - $cardViewValues = $this->getCardView(); + $cardViewItems = array_flip($this->getCardView()); // filter only the selected attributes $attributes = array_filter( - $this->type::cardAttributes(), - fn($cardAttribute, $key) => in_array($key, $cardViewValues), + $this->type::cardAttributes($this), + fn($cardAttribute, $key) => isset($cardViewItems[$key]), ARRAY_FILTER_USE_BOTH ); @@ -1064,90 +1132,142 @@ public function getCardBodyAttributes(): array * Returns the fields and attributes that should be used in element card bodies in the correct order. * * @param ElementInterface|null $element - * @return array + * @param array $cardElements (deprecated) + * @return array * @since 5.5.0 */ public function getCardBodyElements(?ElementInterface $element = null, array $cardElements = []): array { - // get attributes that should show in a card - $attributes = $this->getCardBodyAttributes(); + // todo: simplify further to only return key/html pairs + $cardElements = []; - $layoutElements = []; + foreach ($this->getCardView() as $key) { + $html = $this->getCardBodyHtmlForElement($key, $element); - if (empty($cardElements)) { - // index field layout elements by prefix + uid - foreach ($this->getCardBodyFields($element) as $layoutElement) { - $layoutElements["layoutElement:$layoutElement->uid"] = $layoutElement; + if ($html) { + $cardElements[$key] = ['html' => $html]; } + } - foreach ($this->getGeneratedFields() as $field) { - if (($field['name'] ?? '') !== '') { - $layoutElements["generatedField:{$field['uid']}"] = [ - 'html' => $element ? ($element->getGeneratedFieldValues()[$field['uid']] ?? '') : Html::encode($field['name']), - ]; - } - } - } else { - // we only need to worry about body fields as the attributes are taken care of via getCardBodyAttributes() - foreach ($cardElements as $cardElement) { - if (str_starts_with($cardElement['value'], 'layoutElement:')) { - $uid = str_replace('layoutElement:', '', $cardElement['value']); - $layoutElement = $this->getElementByUid($uid); - if ($layoutElement === null) { - $fieldId = $cardElement['fieldId']; - if ($fieldId) { - $field = Craft::$app->getFields()->getFieldById($fieldId); - $layoutElement = new CustomField(); - $layoutElement->setField($field); - } else { - // this will kick in for native field that have just been dragged into the field layout designer - $fieldLabel = $cardElement['fieldLabel']; - if ($fieldLabel) { - $layoutElement['value'] = $layoutElement; - $layoutElement['label'] = $fieldLabel; - } - } - } + return $cardElements; + } - $layoutElements[$cardElement['value']] = $layoutElement; - } elseif (str_starts_with($cardElement['value'], 'generatedField:')) { - $uid = str_replace('generatedField:', '', $cardElement['value']); - $field = $this->getGeneratedFieldByUid($uid); - if ($field) { - $layoutElements[$cardElement['value']] = [ - 'html' => $element ? ($element->getGeneratedFieldValues()[$uid] ?? '') : Html::encode($field['name']), - ]; - } elseif (isset($cardElement['fieldLabel'])) { - $layoutElements[$cardElement['value']] = [ - 'html' => Html::encode($cardElement['fieldLabel']), - ]; - } - } + /** + * Returns the card body HTML for a given card element key. + * + * @param string $key + * @param ElementInterface|null $element + * @since 5.9.0 + */ + public function getCardBodyHtmlForElement(string $key, ?ElementInterface $element = null): ?string + { + return match (true) { + str_starts_with($key, 'layoutElement:') => $this->cardHtmlForLayoutElement($key, $element), + str_starts_with($key, 'contentBlock:') => $this->cardHtmlForContentBlock($key, $element), + str_starts_with($key, 'generatedField:') => $this->cardHtmlForGeneratedField($key, $element), + default => $this->cardHtmlForAttribute($key, $element), + }; + } + + private function cardHtmlForLayoutElement(string $key, ?ElementInterface $element): ?string + { + $layoutElement = $this->getElementByKey($key); + + if (!$layoutElement instanceof BaseField) { + return null; + } + + if ($element) { + return $layoutElement->previewHtml($element); + } + + if ($layoutElement instanceof CustomField) { + try { + $field = $layoutElement->getField(); + } catch (FieldNotFoundException) { + return null; } + return $field->previewPlaceholderHtml(null, null); } - // get the card view config - array of all the attributes, fields and generated fields that should be shown in the card - $cardViewValues = $this->getCardView(); + return $layoutElement->previewPlaceholderHtml(null, $element); + } + + private function cardHtmlForContentBlock(string $key, ?ElementInterface $element): ?string + { + // the key will be in the format `contentBlock:X::[...]::layoutElement:X` + $keyParts = explode('.', $key); + $key = array_shift($keyParts); - // filter out any generated fields that shouldn't show in the card - $layoutElements = array_filter( - $layoutElements, - fn($key) => !str_starts_with($key, 'generatedField:') || in_array($key, $cardViewValues), - ARRAY_FILTER_USE_KEY - ); + // get the Content Block field + $uid = StringHelper::removeLeft($key, 'contentBlock:'); + $layoutElement = $this->getElementByUid($uid); - $elements = array_merge($layoutElements, $attributes); + if (!$layoutElement instanceof CustomField) { + return null; + } + + try { + $field = $layoutElement->getField(); + } catch (FieldNotFoundException) { + return null; + } - // make sure we don't have any cardViewValues that are no longer allowed to show in cards - $cardViewValues = array_filter($cardViewValues, fn($value) => isset($elements[$value])); + if (!$field instanceof ContentBlock) { + return null; + } - // return elements in the order specified in the config - return array_replace( - array_flip($cardViewValues), - $elements + return $field->getFieldLayout()->getCardBodyHtmlForElement( + implode('.', $keyParts), + $element?->getFieldValue($field->handle), ); } + private function cardHtmlForGeneratedField(string $key, ?ElementInterface $element): ?string + { + $uid = StringHelper::removeLeft($key, 'generatedField:'); + $field = $this->getGeneratedFieldByUid($uid); + + if (!$field) { + return null; + } + + if ($element) { + return $element->getGeneratedFieldValues()[$uid] ?? null; + } + + return Html::encode($field['name'] ?? ''); + } + + private function cardHtmlForAttribute(string $key, ?ElementInterface $element): ?string + { + if ($element) { + return $element->getAttributeHtml($key); + } + + $attribute = $this->cardAttributes()[$key] ?? null; + + if (!$attribute) { + return null; + } + + $html = $this->type::attributePreviewHtml([ + ...$attribute, + 'value' => $key, + ]); + + if (is_callable($html)) { + return $html(); + } + + return $html; + } + + private function cardAttributes(): array + { + return $this->_cardAttributes ??= $this->type::cardAttributes($this); + } + /** * @param callable|null $filter * @param ElementInterface|null $element diff --git a/src/models/Section.php b/src/models/Section.php index efdeef83b6c..7bf47607b3b 100644 --- a/src/models/Section.php +++ b/src/models/Section.php @@ -18,6 +18,7 @@ use craft\enums\PropagationMethod; use craft\helpers\ArrayHelper; use craft\helpers\Db; +use craft\helpers\ElementHelper; use craft\helpers\StringHelper; use craft\helpers\UrlHelper; use craft\records\Section as SectionRecord; @@ -144,6 +145,11 @@ public static function get(int|string $id): ?static */ private ?array $_entryTypes = null; + /** + * @see page() + */ + private string|false $page; + /** * @inheritdoc */ @@ -409,6 +415,39 @@ public function getCpEditUrl(): ?string return UrlHelper::cpUrl("settings/sections/$this->id"); } + /** + * Returns the section’s control panel index page URI. + * + * @return string + * @since 5.9.0 + */ + public function getCpIndexUri(): string + { + $page = $this->getPage(); + return sprintf( + 'content/%s/%s', + $page ? StringHelper::toKebabCase($page) : 'entries', + $this->handle, + ); + } + + /** + * Returns the page name this section belongs to. + * + * @return string|null + * @since 5.9.0 + */ + public function getPage(): ?string + { + if (!isset($this->page)) { + $sourceKey = $this->type === Section::TYPE_SINGLE ? 'singles' : "section:$this->uid"; + $source = ElementHelper::findSource(Entry::class, $sourceKey, withDisabled: true); + $this->page = $source['page'] ?? false; + } + + return $this->page ?: null; + } + /** * @inheritdoc */ diff --git a/src/models/UserGroup.php b/src/models/UserGroup.php index 2cca1fb27fd..569214507ea 100644 --- a/src/models/UserGroup.php +++ b/src/models/UserGroup.php @@ -8,6 +8,11 @@ namespace craft\models; use Craft; +use craft\base\Actionable; +use craft\base\Chippable; +use craft\base\CpEditable; +use craft\base\Describable; +use craft\base\Grippable; use craft\base\Model; use craft\records\UserGroup as UserGroupRecord; use craft\validators\HandleValidator; @@ -19,8 +24,17 @@ * @author Pixel & Tonic, Inc. * @since 3.0.0 */ -class UserGroup extends Model +class UserGroup extends Model implements Chippable, Grippable, Describable, CpEditable, Actionable { + /** + * @inheritdoc + */ + public static function get(int|string $id): ?static + { + /** @phpstan-ignore-next-line */ + return Craft::$app->getUserGroups()->getGroupById($id); + } + /** * @var int|null ID */ @@ -47,6 +61,85 @@ class UserGroup extends Model */ public ?string $uid = null; + /** + * @inheritdoc + */ + public function getUiLabel(): string + { + return Craft::t('site', $this->name); + } + + /** + * @inheritdoc + */ + public function getId(): ?int + { + return $this->id; + } + + /** + * @inheritdoc + */ + public function getHandle(): ?string + { + return $this->handle; + } + + /** + * @inheritdoc + */ + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @inheritdoc + */ + public function getCpEditUrl(): ?string + { + if (!$this->id || !Craft::$app->getUser()->getIsAdmin()) { + return null; + } + + return "settings/users/groups/$this->id"; + } + + /** + * @inheritdoc + */ + public function getActionMenuItems(): array + { + $items = []; + + if ( + $this->id && + Craft::$app->getUser()->getIsAdmin() && + Craft::$app->getConfig()->getGeneral()->allowAdminChanges + ) { + $editId = sprintf('action-edit-%s', mt_rand()); + $items[] = [ + 'id' => $editId, + 'icon' => 'gear', + 'label' => Craft::t('app', 'User group settings'), + ]; + + $view = Craft::$app->getView(); + $view->registerJsWithVars(fn($id, $params) => << { + new Craft.CpScreenSlideout('user-settings/edit-group', { + params: $params, + }); +}); +JS, [ + $view->namespaceInputId($editId), + ['groupId' => $this->id], + ]); + } + + return $items; + } + /** * @inheritdoc */ diff --git a/src/records/EntryType.php b/src/records/EntryType.php index 58efc5b3485..a9e9a1d5ff4 100644 --- a/src/records/EntryType.php +++ b/src/records/EntryType.php @@ -23,6 +23,7 @@ * @property string|null $description Description * @property string|null $icon Icon * @property string|null $color Color + * @property string $uiLabelFormat UI label format * @property bool $hasTitleField Has title field * @property string $titleTranslationMethod Title translation method * @property string|null $titleTranslationKeyFormat Title translation key format diff --git a/src/services/Assets.php b/src/services/Assets.php index 54b77c251be..4189b8cbd3a 100644 --- a/src/services/Assets.php +++ b/src/services/Assets.php @@ -870,9 +870,11 @@ public function ensureFolderByFullPathAndVolume(string $fullPath, Volume $volume $folderModel = $parentFolder; $parentId = $parentFolder->id; + $fullPath = trim($fullPath, '/\\'); + if ($fullPath !== '') { // If we don't have a folder matching these, create a new one - $parts = preg_split('/\\\\|\//', trim($fullPath, '/\\')); + $parts = preg_split('/\\\\|\//', $fullPath); // creep up the folder path $path = ''; diff --git a/src/services/ElementSources.php b/src/services/ElementSources.php index 53461a115a9..a6515e901c2 100644 --- a/src/services/ElementSources.php +++ b/src/services/ElementSources.php @@ -67,15 +67,63 @@ public static function filterExtraHeadings(array $sources): array (isset($sources[$i + 1]) && $sources[$i + 1]['type'] !== self::TYPE_HEADING), ARRAY_FILTER_USE_BOTH)); } + /** + * @see defineSources() + */ + private array $sources = []; + /** * Returns the element index sources in the custom groupings/order. * * @param class-string $elementType The element type class * @param string $context The context * @param bool $withDisabled Whether disabled sources should be included + * @param string|null $page The page to fetch sources for + * @return array[] + */ + public function getSources( + string $elementType, + string $context = self::CONTEXT_INDEX, + bool $withDisabled = false, + ?string $page = null, + ): array { + $sources = $this->sources($elementType, $context); + + if (!$withDisabled) { + $sources = array_filter($sources, fn(array $source) => !($source['disabled'] ?? false)); + } + + if ($page && isset($sources[0]['page'])) { + $pageNameId = $this->pageNameId($page); + $sources = array_filter($sources, fn(array $source) => ( + isset($source['page']) && + $this->pageNameId($source['page']) === $pageNameId + )); + } + + return array_values($sources); + } + + /** + * @param class-string $elementType + * @param string $context * @return array[] */ - public function getSources(string $elementType, string $context = self::CONTEXT_INDEX, bool $withDisabled = false): array + private function sources(string $elementType, string $context): array + { + if (!isset($this->sources[$elementType][$context])) { + $this->sources[$elementType][$context] = $this->defineSources($elementType, $context); + } + + return $this->sources[$elementType][$context]; + } + + /** + * @param class-string $elementType + * @param string $context + * @return array[] + */ + private function defineSources(string $elementType, string $context): array { $nativeSources = $this->_nativeSources($elementType, $context); $sourceConfigs = $this->_sourceConfigs($elementType); @@ -85,15 +133,14 @@ public function getSources(string $elementType, string $context = self::CONTEXT_ $sources = []; $indexedNativeSources = ArrayHelper::index(array_filter($nativeSources, fn($s) => $s['type'] === self::TYPE_NATIVE), 'key'); $nativeSourceKeys = []; + + $firstPage = $sourceConfigs[0]['page'] ?? null; + foreach ($sourceConfigs as $source) { if ($source['type'] === self::TYPE_NATIVE) { if (isset($indexedNativeSources[$source['key']])) { - if ($withDisabled || !($source['disabled'] ?? false)) { - $sources[] = $source + $indexedNativeSources[$source['key']]; - $nativeSourceKeys[$source['key']] = true; - } else { - unset($indexedNativeSources[$source['key']]); - } + $sources[] = $source + $indexedNativeSources[$source['key']]; + $nativeSourceKeys[$source['key']] = true; } } else { if ($source['type'] === self::TYPE_CUSTOM) { @@ -101,9 +148,6 @@ public function getSources(string $elementType, string $context = self::CONTEXT_ continue; } $source = $elementType::modifyCustomSource($source); - if (!$withDisabled && ($source['disabled'] ?? false)) { - continue; - } } $sources[] = $source; } @@ -117,13 +161,19 @@ public function getSources(string $elementType, string $context = self::CONTEXT_ )); if (!empty($missingSources)) { - if (!empty($sources)) { + // If there are any headings, add a blank heading + if (ArrayHelper::contains($sources, fn(array $source) => $source['type'] === self::TYPE_HEADING)) { $sources[] = [ 'type' => self::TYPE_HEADING, 'heading' => '', + 'page' => $firstPage, ]; } - array_push($sources, ...$missingSources); + + array_push($sources, ...array_map(fn(array $source) => [ + ...$source, + 'page' => $firstPage, + ], $missingSources)); } } else { $sources = $nativeSources; @@ -157,12 +207,18 @@ public function getSources(string $elementType, string $context = self::CONTEXT_ * @param string $sourceKey The source key * @param string $context The context * @param bool $withDisabled Whether disabled sources should be included + * @param string|null $page The page to fetch sources for * @return bool * @since 5.7.11 */ - public function sourceExists(string $elementType, string $sourceKey, string $context = self::CONTEXT_INDEX, bool $withDisabled = false): bool - { - foreach ($this->getSources($elementType, $context, $withDisabled) as $source) { + public function sourceExists( + string $elementType, + string $sourceKey, + string $context = self::CONTEXT_INDEX, + bool $withDisabled = false, + ?string $page = null, + ): bool { + foreach ($this->getSources($elementType, $context, $withDisabled, $page) as $source) { if (($source['key'] ?? null) === $sourceKey) { return true; } @@ -171,6 +227,77 @@ public function sourceExists(string $elementType, string $sourceKey, string $con return false; } + /** + * Returns the unique pages found for the given element type’s sources. + * + * @param class-string $elementType The element type class + * @param string $context The context + * @param bool $withDisabled Whether disabled sources should be included + * @return string[] + * @since 5.9.0 + */ + public function getPages(string $elementType, string $context = self::CONTEXT_INDEX, bool $withDisabled = false): array + { + $pages = []; + foreach ($this->getSources($elementType, $context, $withDisabled) as $source) { + if (isset($source['page'])) { + $pages[$source['page']] = true; + } + } + return array_keys($pages); + } + + /** + * Returns the first page found for the given element type’s sources. + * + * @param class-string $elementType The element type class + * @param string $context The context + * @param bool $withDisabled Whether disabled sources should be included + * @return string|null + * @since 5.9.0 + */ + public function getFirstPage(string $elementType, string $context = self::CONTEXT_INDEX, bool $withDisabled = false): ?string + { + foreach ($this->getSources($elementType, $context, $withDisabled) as $source) { + if (isset($source['page'])) { + return $source['page']; + } + } + return null; + } + + /** + * Returns whether the given page exists for an element type. + * + * @param class-string $elementType The element type class + * @param string $context The context + * @param bool $withDisabled Whether disabled sources should be included + * @return bool + * @since 5.9.0 + */ + public function pageExists(string $elementType, string $page, string $context = self::CONTEXT_INDEX, bool $withDisabled = false): bool + { + $nameId = $this->pageNameId($page); + foreach ($this->getSources($elementType, $context, $withDisabled) as $source) { + if (isset($source['page']) && $nameId === $this->pageNameId($source['page'])) { + return true; + } + } + return false; + } + + /** + * Returns a normalized ID for a given page name. + * + * @param string $page + * @return string + * @since 5.9.0 + */ + public function pageNameId(string $page): string + { + return mb_strtolower(preg_replace('/[^\p{L}\p{N}\p{M}]/u', '', $page)); + } + /** * Returns whether the given custom source should be available for the current user. * @@ -550,4 +677,18 @@ private function _sourceConfig(string $elementType, string $sourceKey): ?array } return ArrayHelper::firstWhere($sourceConfigs, fn($s) => $s['type'] !== self::TYPE_HEADING && $s['key'] === $sourceKey); } + + /** + * Returns the page settings for a given element type. + * + * @param class-string $elementType + * @return array + * @since 5.9.0 + */ + public function getPageSettings(string $elementType): array + { + return Craft::$app->getProjectConfig()->get( + sprintf('%s.%s', ProjectConfig::PATH_ELEMENT_SOURCE_PAGES, $elementType), + ) ?? []; + } } diff --git a/src/services/Elements.php b/src/services/Elements.php index 7e247a81fb3..f2cfef4c6e8 100644 --- a/src/services/Elements.php +++ b/src/services/Elements.php @@ -3016,17 +3016,26 @@ public function parseRefs(string $str, ?int $defaultSiteId = null): string $sitesService = Craft::$app->getSites(); $allRefTagTokens = []; $str = preg_replace_callback( - '/\{([\w\\\\]+)\:([^@\:\}]+)(?:@([^\:\}]+))?(?:\:([^\}\| ]+))?(?: *\|\| *([^\}]+))?\}/', + '/ + \{ # Tags always begin with a { + (?P[\w\\\\]+) # Ref handle or element type class + \:(?P[^@\:\}\|]+) # Identifier (ID, or another format supported by the element type) + (?:@(?P[^\:\}\|]+))? # [Optional] Site handle, ID, or UUID + (?:\:(?P[^\}\| ]+))? # [Optional] Attribute, property, or field + (?:\ *\|\|\ *(?P[^\}]+))? # [Optional] Fallback text (if the ref fails to resolve) + \} # Tags always close with a } + /x', function(array $matches) use ( $defaultSiteId, $sitesService, &$allRefTagTokens ) { - $matches = array_pad($matches, 6, null); - [$fullMatch, $elementType, $ref, $siteId, $attribute, $fallback] = $matches; - if ($fallback === null) { - $fallback = $fullMatch; - } + $fullMatch = $matches[0]; + $elementType = $matches['elementType']; + $ref = $matches['ref']; + $siteId = $matches['site'] ?? null; + $attribute = $matches['attr'] ?? null; + $fallback = $matches['fallback'] ?? $fullMatch; // Swap out the ref handle for the element type $elementType = $this->getElementTypeByRefHandle($elementType); @@ -3064,7 +3073,11 @@ function(array $matches) use ( $allRefTagTokens[$siteId][$elementType][$refType][$ref][] = [$token, $attribute, $fallback, $fullMatch]; return $token; - }, $str, -1, $count); + }, + $str, + -1, + $count + ); if ($count === 0) { // No ref tags @@ -4315,7 +4328,10 @@ private function _propagateElement( // Copy the title value? if ( $element::hasTitles() && - $siteElement->getTitleTranslationKey() === $element->getTitleTranslationKey() + ( + $siteElement->getTitleTranslationKey() === $element->getTitleTranslationKey() || + ($element->propagateRequired && empty($siteElement->title)) + ) ) { $siteElement->title = $element->title; } @@ -4323,7 +4339,10 @@ private function _propagateElement( // Copy the slug value? if ( $element->slug !== null && - $siteElement->getSlugTranslationKey() === $element->getSlugTranslationKey() + ( + $siteElement->getSlugTranslationKey() === $element->getSlugTranslationKey() || + ($element->propagateRequired && empty($siteElement->slug)) + ) ) { $siteElement->slug = $element->slug; } @@ -4346,6 +4365,19 @@ private function _propagateElement( } } + // Save it + $siteElement->setScenario(Element::SCENARIO_ESSENTIALS); + + // validate element against "live" scenario across all sites, if element is enabled for the site + if ( + ($crossSiteValidate || $element->propagateRequired) && + $siteElement->enabled && + $siteElement->getEnabledForSite() + ) { + $siteElement->setScenario(Element::SCENARIO_LIVE); + } + + // Copy the dirty attributes (except title, slug and uri, which may be translatable) $siteElement->setDirtyAttributes(array_filter($element->getDirtyAttributes(), fn(string $attribute): bool => $attribute !== 'title' && $attribute !== 'slug')); @@ -4358,32 +4390,28 @@ private function _propagateElement( $fieldLayout = $element->getFieldLayout(); if ($fieldLayout !== null) { - // Only copy the non-translatable field values foreach ($fieldLayout->getCustomFields() as $field) { - // Has this field changed, and does it produce the same translation key as it did for the initial element? if ( $element->propagateAll || + // If propagateRequired is set, is the field value invalid on the propagated site element? + ( + $element->propagateRequired && + $field->layoutElement->required && + $field->isValueEmpty($siteElement->getFieldValue($field->handle), $siteElement) + ) || + // Has this field changed, and does it produce the same translation key as it did for the initial element? ( $element->isFieldDirty($field->handle) && $field->getTranslationKey($siteElement) === $field->getTranslationKey($element) ) ) { - // Copy the initial element’s value over - $siteElement->setFieldValue($field->handle, $element->getFieldValue($field->handle)); + $field->propagateValue($element, $siteElement); } } } } } - // Save it - $siteElement->setScenario(Element::SCENARIO_ESSENTIALS); - - // validate element against "live" scenario across all sites, if element is enabled for the site - if ($crossSiteValidate && $siteElement->enabled && $siteElement->getEnabledForSite()) { - $siteElement->setScenario(Element::SCENARIO_LIVE); - } - $siteElement->propagating = true; $siteElement->propagatingFrom = $element; diff --git a/src/services/Entries.php b/src/services/Entries.php index c1cc181f26f..96ed3c42747 100644 --- a/src/services/Entries.php +++ b/src/services/Entries.php @@ -1452,6 +1452,9 @@ private function _createEntryTypeQuery(): Query if ($db->columnExists(Table::ENTRYTYPES, 'color')) { $query->addSelect('color'); } + if ($db->columnExists(Table::ENTRYTYPES, 'uiLabelFormat')) { + $query->addSelect('uiLabelFormat'); + } return $query; } @@ -1676,6 +1679,9 @@ public function handleChangedEntryType(ConfigEvent $event): void if (Craft::$app->getDb()->columnExists(Table::ENTRYTYPES, 'description')) { $entryTypeRecord->description = $data['description'] ?? null; } + if (Craft::$app->getDb()->columnExists(Table::ENTRYTYPES, 'uiLabelFormat')) { + $entryTypeRecord->uiLabelFormat = $data['uiLabelFormat'] ?? '{title}'; + } if (!empty($data['fieldLayouts'])) { // Save the field layout diff --git a/src/services/Fields.php b/src/services/Fields.php index dc0c4d24325..4f236e98bb0 100644 --- a/src/services/Fields.php +++ b/src/services/Fields.php @@ -1128,8 +1128,6 @@ public function assembleLayoutFromPost(?string $namespace = null): FieldLayout $request = Craft::$app->getRequest(); $config = JsonHelper::decode($request->getBodyParam("{$paramPrefix}fieldLayout")); $config['generatedFields'] = $request->getBodyParam("{$paramPrefix}generatedFields") ?: null; - $config['cardView'] = $request->getBodyParam("{$paramPrefix}cardView") ?: null; - $config['cardThumbAlignment'] = Craft::$app->getRequest()->getBodyParam($paramPrefix . 'thumbAlignment'); $layout = $this->createLayout($config); // Make sure all the elements have a dateAdded value set diff --git a/src/services/Gc.php b/src/services/Gc.php index f2dc3dcd8db..8bb8f353495 100644 --- a/src/services/Gc.php +++ b/src/services/Gc.php @@ -160,6 +160,7 @@ public function run(bool $force = false): void $this->_deleteOrphanedDraftsAndRevisions(); $this->_deleteOrphanedSearchIndexes(); + $this->_deleteOrphanedSearchIndexJobs(); $this->_deleteOrphanedRelations(); $this->_deleteOrphanedStructureElements(); $this->_deleteOrphanedFkRows(); @@ -580,6 +581,13 @@ private function _deleteOrphanedSearchIndexes(): void $this->_stdout("done\n", Console::FG_GREEN); } + private function _deleteOrphanedSearchIndexJobs(): void + { + $this->_stdout(' > deleting orphaned search index jobs ... '); + Craft::$app->getSearch()->deleteOrphanedIndexJobs(); + $this->_stdout("done\n", Console::FG_GREEN); + } + private function _deleteOrphanedRelations(): void { $this->_stdout(' > deleting orphaned relations ... '); diff --git a/src/services/Gql.php b/src/services/Gql.php index 9f15978df95..0c31c7006f0 100644 --- a/src/services/Gql.php +++ b/src/services/Gql.php @@ -15,6 +15,7 @@ use craft\behaviors\FieldLayoutBehavior; use craft\db\Query as DbQuery; use craft\db\Table; +use craft\elements\User; use craft\enums\CmsEdition; use craft\errors\GqlException; use craft\events\ConfigEvent; @@ -1798,23 +1799,26 @@ private function globalSetSchemaComponents(): array */ private function userSchemaComponents(): array { - if (Craft::$app->edition === CmsEdition::Solo) { - return [[], []]; - } - $queryComponents = []; - $userGroups = Craft::$app->getUserGroups()->getAllGroups(); - $queryComponents['usergroups.everyone:read'] = [ - 'label' => Craft::t('app', 'Query for users'), - ]; + if (Craft::$app->edition === CmsEdition::Solo) { + $queryComponents['usergroups.solo:read'] = [ + 'label' => Craft::t('app', 'View {type}', ['type' => User::lowerDisplayName()]), + ]; + } else { + $userGroups = Craft::$app->getUserGroups()->getAllGroups(); - foreach ($userGroups as $userGroup) { - $name = Craft::t('site', $userGroup->name); - $prefix = "usergroups.$userGroup->uid"; - $queryComponents["$prefix:read"] = [ - 'label' => Craft::t('app', 'Query for users in the “{name}” user group', ['name' => $name]), + $queryComponents['usergroups.everyone:read'] = [ + 'label' => Craft::t('app', 'Query for users'), ]; + + foreach ($userGroups as $userGroup) { + $name = Craft::t('site', $userGroup->name); + $prefix = "usergroups.$userGroup->uid"; + $queryComponents["$prefix:read"] = [ + 'label' => Craft::t('app', 'Query for users in the “{name}” user group', ['name' => $name]), + ]; + } } return [$queryComponents, []]; diff --git a/src/services/ProjectConfig.php b/src/services/ProjectConfig.php index 9f15373514e..00eb20a3b7a 100644 --- a/src/services/ProjectConfig.php +++ b/src/services/ProjectConfig.php @@ -109,6 +109,8 @@ class ProjectConfig extends Component public const PATH_CATEGORY_GROUPS = 'categoryGroups'; public const PATH_DATE_MODIFIED = 'dateModified'; public const PATH_ELEMENT_SOURCES = 'elementSources'; + /** @since 5.9.0 */ + public const PATH_ELEMENT_SOURCE_PAGES = 'elementSourcesPages'; public const PATH_ENTRY_TYPES = 'entryTypes'; public const PATH_FIELDS = 'fields'; public const PATH_GLOBAL_SETS = 'globalSets'; diff --git a/src/services/Search.php b/src/services/Search.php index b9635424b90..d8627b6f4be 100644 --- a/src/services/Search.php +++ b/src/services/Search.php @@ -308,22 +308,9 @@ public function indexElementIfQueued(int $elementId, int $siteId, ?string $eleme } try { - for ($try = 0; $try < 3; $try++) { - try { - if (!Db::update(Table::SEARCHINDEXQUEUE, ['reserved' => true], ['id' => $jobId])) { - // another process must be handling the same job - return; - } - break; - } catch (DbException $e) { - if (str_contains($e->getPrevious()?->getMessage(), 'deadlock')) { - // A gap lock was probably hit. Try again in one second - // https://github.com/craftcms/cms/issues/17318 - sleep(1); - } else { - throw $e; - } - } + if (!Db::update(Table::SEARCHINDEXQUEUE, ['reserved' => true], ['id' => $jobId])) { + // another process must be handling the same job + return; } } finally { $mutex->release($lockName); @@ -607,6 +594,35 @@ public function deleteOrphanedIndexes(): void $db->createCommand($sql)->execute(); } + /** + * Deletes any search indexes that belong to elements that don’t exist anymore. + * + * @since 5.9.0 + */ + public function deleteOrphanedIndexJobs(): void + { + $db = Craft::$app->getDb(); + $searchIndexQueueTable = Table::SEARCHINDEXQUEUE; + $elementsTable = Table::ELEMENTS; + + if ($db->getIsMysql()) { + $sql = <<createCommand($sql)->execute(); + } + /** * Indexes keywords for a specific element attribute/field. * diff --git a/src/services/Sites.php b/src/services/Sites.php index e6271675cbf..944da40b8a0 100644 --- a/src/services/Sites.php +++ b/src/services/Sites.php @@ -511,6 +511,14 @@ public function setCurrentSite(mixed $site): void if (Craft::$app->has('request', true) && Craft::$app->getRequest()->getIsSiteRequest()) { Craft::$app->language = $this->_currentSite->language; } + + // Set the CRAFT_SITE and CRAFT_SITE_UPPER env vars + if (isset($this->_currentSite->handle)) { + $_SERVER['CRAFT_SITE'] = $this->_currentSite->handle; + $_SERVER['CRAFT_SITE_UPPER'] = strtoupper(StringHelper::toSnakeCase($this->_currentSite->handle)); + } else { + unset($_SERVER['CRAFT_SITE'], $_SERVER['CRAFT_SITE_UPPER']); + } } /** diff --git a/src/services/Structures.php b/src/services/Structures.php index 090e00e4906..2e81256a684 100644 --- a/src/services/Structures.php +++ b/src/services/Structures.php @@ -48,16 +48,35 @@ class Structures extends Component */ public const EVENT_AFTER_INSERT_ELEMENT = 'afterInsertElement'; + /** + * @event MoveElementEvent The event that is triggered before an element’s position is updated. + * + * You may set [[\yii\base\ModelEvent::$isValid]] to `false` to prevent the element from getting repositioned. + * + * @since 5.9.0 + */ + public const EVENT_BEFORE_UPDATE_ELEMENT = 'beforeUpdateElement'; + + /** + * @event MoveElementEvent The event that is triggered after an element’s position is updated. + * @since 5.9.0 + */ + public const EVENT_AFTER_UPDATE_ELEMENT = 'afterUpdateElement'; + /** * @event MoveElementEvent The event that is triggered before an element is moved. * * In Craft 4.5 and later, you may set [[\yii\base\ModelEvent::$isValid]] to `false` to prevent the * element from getting moved. + * + * @deprecated in 5.9.0. [[EVENT_BEFORE_UPDATE_ELEMENT]] should be used instead. */ public const EVENT_BEFORE_MOVE_ELEMENT = 'beforeMoveElement'; /** * @event MoveElementEvent The event that is triggered after an element is moved. + * + * @deprecated in 5.9.0. [[EVENT_AFTER_UPDATE_ELEMENT]] should be used instead. */ public const EVENT_AFTER_MOVE_ELEMENT = 'afterMoveElement'; @@ -540,7 +559,7 @@ private function _doIt(int $structureId, ElementInterface $element, StructureEle [$beforeEvent, $afterEvent] = match ($mode) { self::MODE_INSERT => [self::EVENT_BEFORE_INSERT_ELEMENT, self::EVENT_AFTER_INSERT_ELEMENT], - self::MODE_UPDATE => [self::EVENT_BEFORE_MOVE_ELEMENT, self::EVENT_AFTER_MOVE_ELEMENT], + self::MODE_UPDATE => [self::EVENT_BEFORE_UPDATE_ELEMENT, self::EVENT_AFTER_UPDATE_ELEMENT], }; $targetElementId = $targetElementRecord->isRoot() ? null : $targetElementRecord->elementId; diff --git a/src/services/UserGroups.php b/src/services/UserGroups.php index 82221ad765a..26540e84d56 100644 --- a/src/services/UserGroups.php +++ b/src/services/UserGroups.php @@ -215,6 +215,7 @@ public function getGroupsByUserId(int $userId): array 'g.id', 'g.name', 'g.handle', + 'g.description', 'g.uid', ]) ->from(['g' => Table::USERGROUPS]) diff --git a/src/templates/_components/fieldtypes/Addresses/settings.twig b/src/templates/_components/fieldtypes/Addresses/settings.twig index 4b924f1b91e..bb9a2a98f5d 100644 --- a/src/templates/_components/fieldtypes/Addresses/settings.twig +++ b/src/templates/_components/fieldtypes/Addresses/settings.twig @@ -32,17 +32,30 @@ disabled: readOnly, }) }} -{{ forms.selectField({ +{% embed '_includes/forms/field.twig' with { label: 'View Mode'|t('app'), instructions: 'Choose how nested {type} should be presented to authors.'|t('app', { type: 'addresses'|t('app'), }), - id: 'view-mode', - name: 'viewMode', - options: [ - {label: 'As cards'|t('app'), value: constant('VIEW_MODE_CARDS', field)}, - {label: 'As an element index'|t('app'), value: constant('VIEW_MODE_INDEX', field)}, - ], - value: field.viewMode, - disabled: readOnly, -}) }} +} %} + {% block input %} +
+ + +
+ {% endblock %} +{% endembed %} diff --git a/src/templates/_components/fieldtypes/Categories/input.twig b/src/templates/_components/fieldtypes/Categories/input.twig deleted file mode 100644 index 291653c47e9..00000000000 --- a/src/templates/_components/fieldtypes/Categories/input.twig +++ /dev/null @@ -1,60 +0,0 @@ -{{ hiddenInput(name, '') }} -
-
    - {% nav category in elements %} -
  • - {% set indent = (category.level - 1) * 35 %} -
    - {{- elementChip(element, { - element: category, - context: 'field', - inputName: (name ?? false) ? "#{name}[]" : null, - }) -}} -
    - - {% ifchildren %} -
      - {% children %} -
    - {% endifchildren %} -
  • - {% endnav %} -
- -
- {{ tag('button', { - type: 'button', - text: selectionLabel, - class: [ - 'btn', - 'add', - 'icon', - 'dashed', - ], - aria: { - label: selectionLabel, - describedby: describedBy ?? false, - }, - }) }} - -
-
- -{% if jsClass is defined %} - {% js %} - new {{ jsClass }}({ - id: "{{ id|namespaceInputId|e('js') }}", - name: "{{ name|namespaceInputName|e('js') }}", - elementType: "{{ elementType|e('js') }}", - sources: {{ sources|json_encode|raw }}, - criteria: {{ criteria|json_encode|raw }}, - sourceElementId: {{ sourceElementId ?: 'null' }}, - prevalidate: {{ (prevalidate ?? false) ? 'true' : 'false' }}, - branchLimit: {{ branchLimit ?: 'null' }}, - showSiteMenu: {{ (showSiteMenu ?? false)|json_encode|raw }}, - modalStorageKey: "{{ storageKey|e('js') }}", - selectionLabel: "{{ selectionLabel|e('js') }}", - allowSelfRelations: {{ (allowSelfRelations ?? false)|json_encode|raw }}, - }); - {% endjs %} -{% endif %} diff --git a/src/templates/_components/fieldtypes/Categories/settings.twig b/src/templates/_components/fieldtypes/Categories/settings.twig deleted file mode 100644 index 4481628ea6d..00000000000 --- a/src/templates/_components/fieldtypes/Categories/settings.twig +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "_components/fieldtypes/elementfieldsettings" %} - -{% import "_includes/forms" as forms %} - - -{% block fieldSettings %} - {{ block('sourcesField') }} - - {% block branchLimitField %} - {{ forms.textField({ - label: "Branch Limit"|t('app'), - instructions: "Limit the number of selectable category branches."|t('app'), - id: 'branchLimit', - name: 'branchLimit', - value: field.branchLimit, - size: 2, - errors: field.getErrors('branchLimit') - }) }} - {% endblock %} - - {{ block('defaultPlacementField') }} - {{ block('viewModeField') }} - {{ block('selectionLabelField') }} - {{ block('showSearchInputField') }} - {{ block('validateRelatedElementsField') }} - {{ block('advancedSettings') }} -{% endblock %} diff --git a/src/templates/_components/fieldtypes/ContentBlock/settings.twig b/src/templates/_components/fieldtypes/ContentBlock/settings.twig index 363e0b04e9f..d9bf5d0d58d 100644 --- a/src/templates/_components/fieldtypes/ContentBlock/settings.twig +++ b/src/templates/_components/fieldtypes/ContentBlock/settings.twig @@ -12,37 +12,25 @@ label: 'View Mode'|t('app') } %} {% block input %} -
-
diff --git a/src/templates/_components/fieldtypes/Matrix/settings.twig b/src/templates/_components/fieldtypes/Matrix/settings.twig index 00e64200b11..6a28762c2f3 100644 --- a/src/templates/_components/fieldtypes/Matrix/settings.twig +++ b/src/templates/_components/fieldtypes/Matrix/settings.twig @@ -8,7 +8,7 @@ } %} {% block input %} {% import "_includes/forms" as forms %} - {% set groupedEntryTypes = entryTypes.groupBy(et => et.group ?? 'General'|t('app')) %} + {% set groupedEntryTypes = entryTypes.groupBy(et => (et.group ?? 'General'|t('app'))) %} {% if not groupedEntryTypes|length %} {% set groupedEntryTypes = { ('General'|t('app')): [], @@ -201,41 +201,61 @@
-{{ forms.selectField({ +{% embed '_includes/forms/field.twig' with { label: 'View Mode'|t('app'), instructions: 'Choose how nested {type} should be presented to authors.'|t('app', { type: 'entries'|t('app'), }), - id: 'view-mode', - name: 'viewMode', - options: [ - {label: 'As cards'|t('app'), value: constant('VIEW_MODE_CARDS', field)}, - {label: 'As inline-editable blocks'|t('app'), value: constant('VIEW_MODE_BLOCKS', field)}, - {label: 'As an element index'|t('app'), value: constant('VIEW_MODE_INDEX', field)}, - ], - value: field.viewMode, - toggle: true, - targetPrefix: 'view-mode--', - disabled: readOnly, -}) }} - -{% tag 'div' with { - id: "view-mode--#{constant('VIEW_MODE_CARDS', field)}", - class: field.viewMode != constant('VIEW_MODE_CARDS', field) ? 'hidden' : null, } %} - {{ forms.lightswitchField({ - label: 'Show cards in a grid'|t('app'), - instructions: 'Whether cards should be shown in a multi-column grid on wide viewports.'|t('app'), - id: 'show-cards-in-grid', - name: 'showCardsInGrid', - on: field.showCardsInGrid, - disabled: readOnly, - }) }} -{% endtag %} + {% block input %} +
+ + + + +
+ {% endblock %} +{% endembed %} {% tag 'div' with { - id: "view-mode--#{constant('VIEW_MODE_INDEX', field)}", - class: field.viewMode != constant('VIEW_MODE_INDEX', field) ? 'hidden' : null, + id: "view-mode--index", + class: field.viewMode != 'index' ? 'hidden' : null, } %} {{ forms.lightswitchField({ label: 'Include Table View'|t('app'), @@ -328,10 +348,8 @@ {% if readOnly %} config['disabled'] = true; - {% else %} - config['sortable'] = true; {% endif %} - Craft.ui.createCheckboxSelect(config).appendTo($defaultColumnsContainer); + Craft.ui.createSortableCheckboxSelect(config).appendTo($defaultColumnsContainer); })(); {% endjs %} diff --git a/src/templates/_components/fieldtypes/Number/input.twig b/src/templates/_components/fieldtypes/Number/input.twig index a69c63f6996..2330e63a73f 100644 --- a/src/templates/_components/fieldtypes/Number/input.twig +++ b/src/templates/_components/fieldtypes/Number/input.twig @@ -29,7 +29,7 @@ {% endif %}
- {{ text({ + {% set config = { type: field.step ? 'number' : 'text', id: id, name: formatNumber ? "#{field.handle}[value]" : field.handle, @@ -38,7 +38,16 @@ step: field.step, size: field.size, describedBy: [describedBy ?? null, hasPrefix or hasSuffix ? descriptionId : null]|filter|join(' ') ?: false, - }) }} + } %} + + {% if config.type == 'number' %} + {% set config = config|merge({ + min: field.min ?? null, + max: field.max ?? null, + }) %} + {% endif %} + + {{ text(config) }}
{% if hasSuffix %}