From 3bfc996ea208ad3cb33e409b7001a6c9fd38d6b4 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Wed, 28 Oct 2026 15:47:43 -0500 Subject: [PATCH 01/51] Remove dead legacy control panel scripts CraftCopyAttribute, CraftGlobalSidebar, and CraftTooltip were never imported into the legacy cp bundle; @craftcms/ui and replaced them. Craft.ImageUpload's only consumer was the rebrand script, whose asset bundle no longer registered it, so both go along with the generalsettings webpack package. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG-WIP.md | 1 + packages/craftcms-legacy/cp/src/Craft.js | 1 - packages/craftcms-legacy/cp/src/js/CP.js | 2 +- .../cp/src/js/CraftCopyAttribute.js | 86 ----- .../cp/src/js/CraftGlobalSidebar.js | 64 ---- .../craftcms-legacy/cp/src/js/CraftTooltip.js | 332 ------------------ .../craftcms-legacy/cp/src/js/ImageUpload.js | 158 --------- .../generalsettings/src/rebrand.js | 35 -- .../generalsettings/src/rebrand.scss | 46 --- .../generalsettings/webpack.config.js | 15 - .../src/components/thumbnail-loader/README.md | 2 +- .../LegacyAssets/GeneralSettingsAsset.php | 8 +- 12 files changed, 4 insertions(+), 746 deletions(-) delete mode 100644 packages/craftcms-legacy/cp/src/js/CraftCopyAttribute.js delete mode 100644 packages/craftcms-legacy/cp/src/js/CraftGlobalSidebar.js delete mode 100644 packages/craftcms-legacy/cp/src/js/CraftTooltip.js delete mode 100644 packages/craftcms-legacy/cp/src/js/ImageUpload.js delete mode 100644 packages/craftcms-legacy/generalsettings/src/rebrand.js delete mode 100644 packages/craftcms-legacy/generalsettings/src/rebrand.scss delete mode 100644 packages/craftcms-legacy/generalsettings/webpack.config.js diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 8ad3b6aff12..d2a3b5754db 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -744,6 +744,7 @@ Craft 6 now uses [Laravel's authorization system](https://laravel.com/docs/12.x/ - Deprecated the `cancelToken` request option for `Craft.sendActionRequest()` and `Craft.sendApiRequest()`. `signal` should be used instead. - Removed `Cp.$axios`. - Removed the `Craft.Accordion` and `Craft.EnvVarGenerator` control panel JavaScript classes. ([#19323](https://github.com/craftcms/cms/pull/19323)) +- Removed the `Craft.ImageUpload` control panel JavaScript class. - Deprecated the `Craft.LightSwitch`, `Craft.InfoIcon`, `Craft.ColorInput`, `Craft.PasswordInput`, `Craft.IconPicker`, `Craft.SlidePicker`, `Craft.SlideRuleInput`, and `Craft.Tooltip` control panel JavaScript classes, along with the `.infoicon` jQuery plugin. The corresponding `@craftcms/ui` web components should be used instead. - Deprecated the `Craft.SortableCheckboxSelect` control panel JavaScript class. `` should be used instead. ([#20036](https://github.com/craftcms/cms/pull/20036)) diff --git a/packages/craftcms-legacy/cp/src/Craft.js b/packages/craftcms-legacy/cp/src/Craft.js index be8a050e889..2178e3810dc 100644 --- a/packages/craftcms-legacy/cp/src/Craft.js +++ b/packages/craftcms-legacy/cp/src/Craft.js @@ -31,7 +31,6 @@ import './js/CategoryIndex.js'; import './js/ElementEditor.js'; import './js/ElementFieldSettings.js'; import './js/EntryIndex.js'; -import './js/ImageUpload.js'; import './js/LivePreview.js'; import './js/Preview.js'; import './js/ProgressBar.js'; diff --git a/packages/craftcms-legacy/cp/src/js/CP.js b/packages/craftcms-legacy/cp/src/js/CP.js index d4c5bded296..25807472f50 100644 --- a/packages/craftcms-legacy/cp/src/js/CP.js +++ b/packages/craftcms-legacy/cp/src/js/CP.js @@ -2717,7 +2717,7 @@ var JobProgressIcon = Garnish.Base.extend({ .hide(); // If the sidebar is collapsed, make sure to add a tooltip. - // CraftGlobalSidebar.js will handle removing it and adding it back on expand/contract + // removes it and adds it back on expand/contract if (Garnish.$bod.data('sidebar') === 'collapsed') { this.$tooltip = $('', { placement: 'right', diff --git a/packages/craftcms-legacy/cp/src/js/CraftCopyAttribute.js b/packages/craftcms-legacy/cp/src/js/CraftCopyAttribute.js deleted file mode 100644 index 08cf664c304..00000000000 --- a/packages/craftcms-legacy/cp/src/js/CraftCopyAttribute.js +++ /dev/null @@ -1,86 +0,0 @@ -class CraftCopyAttribute extends HTMLElement { - copyValue(event) { - this.input.select(); - document.execCommand('copy'); - Craft.cp.displayNotice(Craft.t('app', 'Copied to clipboard.')); - $(this.btn).trigger('copy'); - this.input.setSelectionRange(0, 0); - this.btn.focus(); - } - - /** - * - * @param {KeyboardEvent|PointerEvent} event - */ - handleTrigger(event) { - if ( - event instanceof KeyboardEvent && - (event.key === ' ' || event.key === 'Enter') - ) { - event.preventDefault(); - this.copyValue(); - } - - if (event instanceof PointerEvent) { - this.copyValue(); - } - } - - renderInput() { - const input = document.createElement('input'); - input.value = this.value; - input.classList.add('visually-hidden'); - input.readOnly = true; - input.size = Math.max(this.value.length, 1); - input.tabIndex = -1; - input.ariaHidden = 'true'; - - this.input = input; - this.prepend(this.input); - } - - renderIcon() { - const a11yText = document.createElement('span'); - a11yText.classList.add('visually-hidden'); - a11yText.innerText = Craft.t('app', 'Copy to clipboard'); - this.btn.appendChild(a11yText); - - const icon = document.createElement('span'); - icon.ariaHidden = true; - icon.setAttribute('data-icon', 'clipboard'); - this.btn.appendChild(icon); - } - - renderButton() { - const button = document.createElement('button'); - button.type = 'button'; - button.addEventListener('click', this.handleTrigger.bind(this)); - button.addEventListener('keydown', this.handleTrigger.bind(this)); - - button.innerText = this.value; - - this.btn = button; - this.appendChild(this.btn); - } - - connectedCallback() { - this.value = - (this.getAttribute('value') || this.innerText).trim() || - this.innerText.trim(); - this.innerHTML = ''; - - this.renderButton(); - this.renderInput(); - this.renderIcon(); - } - - disconnectedCallback() { - // put the "value" attribute back into - // so that when connectedCallback() is called after insetBefore/insertAfter - // everything can get initialised as expected - // we can't use Element.moveBefore/Element.moveAfter as those are experimental at the moment and not available in Safari & FF - this.setAttribute('value', this.value); - } -} - -customElements.define('craft-copy-attribute', CraftCopyAttribute); diff --git a/packages/craftcms-legacy/cp/src/js/CraftGlobalSidebar.js b/packages/craftcms-legacy/cp/src/js/CraftGlobalSidebar.js deleted file mode 100644 index 372d0fbd012..00000000000 --- a/packages/craftcms-legacy/cp/src/js/CraftGlobalSidebar.js +++ /dev/null @@ -1,64 +0,0 @@ -class CraftGlobalSidebar extends HTMLElement { - get items() { - return this.querySelectorAll('.sidebar-action'); - } - - connectedCallback() { - this.trigger = this.querySelector('#sidebar-trigger'); - - if (this.trigger) { - this.trigger.addEventListener('craft-show', this.expand.bind(this)); - this.trigger.addEventListener('craft-hide', this.collapse.bind(this)); - } - } - - disconnectedCallback() { - if (this.trigger) { - this.trigger.removeEventListener('open', this.expand.bind(this)); - this.trigger.removeEventListener('close', this.collapse.bind(this)); - } - - this.expand(); - } - - itemHasTooltip(item) { - return item.querySelector('craft-tooltip'); - } - - createTooltips() { - if (this.items) { - this.items.forEach((item) => { - if (this.itemHasTooltip(item)) return; - - const tooltip = document.createElement('craft-tooltip'); - tooltip.setAttribute('placement', 'right'); - tooltip.setAttribute('trigger', `.sidebar-action`); - tooltip.setAttribute('text', item.querySelector('.label')?.textContent); - item.append(tooltip); - }); - } - } - - destroyTooltips() { - if (this.items) { - this.items.forEach((item) => { - const tooltip = item.querySelector('craft-tooltip'); - tooltip?.remove(); - }); - } - } - - expand() { - document.body.setAttribute('data-sidebar', 'expanded'); - Craft.setCookie('sidebar', 'expanded'); - this.destroyTooltips(); - } - - collapse() { - document.body.setAttribute('data-sidebar', 'collapsed'); - Craft.setCookie('sidebar', 'collapsed'); - this.createTooltips(); - } -} - -customElements.define('craft-global-sidebar', CraftGlobalSidebar); diff --git a/packages/craftcms-legacy/cp/src/js/CraftTooltip.js b/packages/craftcms-legacy/cp/src/js/CraftTooltip.js deleted file mode 100644 index 4f2ab536ed6..00000000000 --- a/packages/craftcms-legacy/cp/src/js/CraftTooltip.js +++ /dev/null @@ -1,332 +0,0 @@ -import { - arrow, - autoUpdate, - computePosition, - flip, - offset, - shift, -} from '@floating-ui/dom'; - -/** - * Tooltip - * - * Renders a tooltip on hover or focus of the parent element. - * - * Tooltips are used to provide additional or context for an element. By default - * the tooltip will be positioned below an element and will avoid the edges - * of the browser window. - * - * @property {'top'|'top-start'|'top-end'|'right'|'right-start'|'right-end'|'bottom'|'bottom-start'|'bottom-end'|'left'|'left-start'|'left-end'} placement - The placement of the tooltip relative to the parent element. - * @property {boolean} arrow - Whether the tooltip should have an arrow. - * @property {number} offset - The offset of the tooltip from the parent element. - * @property {boolean} self-managed - When true, the tooltip will be its own trigger. - * @property {string} text - Text content for the tooltip - * @property {string} trigger - Selector for the element that should trigger the tooltip. If `self-managed` is set, this setting will be ignored. - * @property {number} delay - The delay before the tooltip is shown on mouseentery. - * @method show - Show the tooltip. - * @method hide - Hide the tooltip. - * @method update - Update the position of the tooltip. - * - * @example - */ -class CraftTooltip extends HTMLElement { - static observedAttributes = ['text', 'placement']; - - get triggerElement() { - if (this.selfManaged) { - return this; - } - - if (this.getAttribute('trigger')) { - return this.closest(this.getAttribute('trigger')); - } - - return this.querySelector('a,button,[role="button"]'); - } - - connectedCallback() { - this.abortController = new AbortController(); - - this.arrowElement = this.querySelector('.arrow'); - this.selfManaged = this.hasAttribute('self-managed'); - - this.arrow = this.getAttribute('arrow') !== 'false'; - this.offset = this.hasAttribute('offset') - ? parseInt(this.getAttribute('offset'), 10) - : 8; - - this.placement = this.getAttribute('placement') || 'bottom'; - this.direction = getComputedStyle(this).direction; - this.delay = this.getAttribute('delay') || 500; - this.delayTimeout = null; - this.maxWidth = this.getAttribute('max-width') || '220px'; - this.text = this.getAttribute('text') || this.innerText; - this.showing = false; - this.lastShow = 0; - this.eventDelay = 150; - - this.renderTooltip(); - this.renderInner(); - - if (this.arrow && !this.arrowElement) { - this.renderArrow(); - } - - this.listeners = [ - ['mouseenter', this.handleMouseEnter], - ['mouseleave', this.handleMouseLeave], - ['keyup', this.handleKeyUp], - ['click', this.handleClick], - ['focusin', this.handleFocusIn], - ['focusout', this.handleFocusOut], - ]; - - if (!this.triggerElement) { - console.warn('No trigger found for tooltip', this); - return false; - } - - // Make sure the trigger accepts pointer events - this.triggerElement.style.pointerEvents = 'auto'; - - this.listeners.forEach(([event, handler]) => { - this.triggerElement.addEventListener(event, (e) => handler(e), { - signal: this.abortController.signal, - }); - }); - - // Update & hide to make sure everything is where it needs to be - this.update(); - this.hide(); - } - - disconnectedCallback() { - this.hide(); - this.abortController.abort(); - } - - attributeChangedCallback(name, oldValue, newValue) { - if (name === 'text' && this.inner) { - this.inner.innerText = newValue; - - // innerText will remove the arrow, so we have to put it back if we need it. - if (this.arrow) { - this.renderArrow(); - this.update(); - } - } - - if (name === 'placement') { - this.placement = newValue; - } - } - - renderTooltip() { - this.tooltip = document.createElement('span'); - this.tooltip.classList.add('craft-tooltip'); - this.tooltip.style['max-width'] = this.maxWidth; - - // Keep the tooltip open when the user is hovering over it. - this.tooltip.addEventListener('mouseenter', () => this.show(), { - signal: this.abortController.signal, - }); - this.tooltip.addEventListener('mouseleave', this.hide, { - signal: this.abortController.signal, - }); - - /** - * We need to append the tooltip to the body because - * the `container-size` property will create a new context - * for position: fixed which is a problem when using `strategy: fixed` - */ - window.document.body.appendChild(this.tooltip); - } - - /** - * Renders an inner container so we can use padding for the offset and - * maintain a better hover experience for users using zoom. - */ - renderInner() { - this.inner = document.createElement('span'); - this.inner.classList.add('inner'); - this.inner.innerText = this.text; - - // Replace the content with the inner container - this.tooltip.appendChild(this.inner); - } - - renderArrow() { - this.arrowElement = document.createElement('span'); - this.arrowElement.classList.add('arrow'); - this.inner.appendChild(this.arrowElement); - } - - handleKeyUp = (e) => { - if (e?.key && e.key === 'Escape') { - this.hide(); - } - }; - - handleMouseEnter = () => { - this.isHovering = true; - - if (this.delayTimeout) { - clearTimeout(this.delayTimeout); - } - - // Open the tooltip after the delay - this.delayTimeout = setTimeout(() => { - // Check if the user is still over the tooltip before doing anything - if (!this.isHovering) { - return; - } - - this.show(); - }, this.delay); - }; - - handleMouseLeave = () => { - this.isHovering = false; - - if (this.delayTimeout) { - clearTimeout(this.delayTimeout); - } - - this.hide(); - }; - - handleFocusIn = () => { - const currentTime = Date.now(); - this.lastShow = currentTime; - - if (this.delayTimeout) { - clearTimeout(this.delayTimeout); - } - - // Only show the tooltip if it's not already open - if (!this.showing) { - // Check that no click event occurred shortly after - this.delayTimeout = setTimeout(() => { - if (currentTime === this.lastShow) { - this.show(); - } - }, this.eventDelay); - } - - this.show(); - }; - - handleFocusOut = () => { - if (this.delayTimeout) { - clearTimeout(this.delayTimeout); - } - - this.hide(); - }; - - handleClick = () => { - if (this.delayTimeout) { - clearTimeout(this.delayTimeout); - } - - const currentTime = Date.now(); - const timeSinceLastShow = currentTime - this.lastShow; - this.lastShow = currentTime; - - // If the click happened very soon after focus, treat it as a single action - if (timeSinceLastShow < this.eventDelay) { - this.show(); - } else { - this.toggle(); - } - }; - - toggle = () => { - if (this.showing) { - this.hide(); - } else { - this.show(); - } - }; - - show = () => { - if (!this.triggerElement || !this.tooltip) { - return; - } - - autoUpdate(this.triggerElement, this.tooltip, this.update); - Object.assign(this.tooltip.style, { - opacity: 1, - // Make sure if a user hovers over the label itself, it stays open - pointerEvents: 'auto', - zIndex: 101, - }); - this.showing = true; - }; - - hide = () => { - Object.assign(this.tooltip.style, { - opacity: 0, - pointerEvents: 'none', - }); - - this.showing = false; - }; - - getStaticSide() { - return { - top: 'bottom', - right: 'left', - bottom: 'top', - left: 'right', - }[this.placement.split('-')[0]]; - } - - cleanup() { - return autoUpdate(this.triggerElement, this.tooltip, this.update); - } - - update = () => { - if (!this.triggerElement || !this.tooltip) { - return; - } - - computePosition(this.triggerElement, this.tooltip, { - strategy: 'fixed', - placement: this.placement, - middleware: [ - flip(), - shift({padding: 10}), - offset(0), - ...(this.arrow ? [arrow({element: this.arrowElement})] : []), - ], - }).then(({x, y, middlewareData, placement}) => { - // Placement may have changed - this.setAttribute('placement', placement); - - Object.assign(this.tooltip.style, { - left: `${x}px`, - top: `${y}px`, - padding: '0px', - // Add padding to the static side for accessible hovers - [`padding${Craft.uppercaseFirst(this.getStaticSide())}`]: `${this.offset}px`, - }); - - if (!this.arrowElement) { - return; - } - - const {x: arrowX, y: arrowY} = middlewareData.arrow; - this.arrowElement.dataset.placement = placement; - Object.assign(this.arrowElement.style, { - left: arrowX != null ? `${arrowX}px` : '', - top: arrowY != null ? `${arrowY}px` : '', - right: '', - bottom: '', - [this.getStaticSide()]: '-4px', - }); - }); - }; -} - -customElements.define('craft-legacy-tooltip', CraftTooltip); diff --git a/packages/craftcms-legacy/cp/src/js/ImageUpload.js b/packages/craftcms-legacy/cp/src/js/ImageUpload.js deleted file mode 100644 index c4914d4abd1..00000000000 --- a/packages/craftcms-legacy/cp/src/js/ImageUpload.js +++ /dev/null @@ -1,158 +0,0 @@ -/** global: Craft */ -/** global: Garnish */ - -/** - * Image upload class for user photos, site icon and logo. - */ -Craft.ImageUpload = Garnish.Base.extend( - { - $container: null, - progressBar: null, - uploader: null, - - init: function (settings) { - this.setSettings(settings, Craft.ImageUpload.defaults); - this.$container = $(this.settings.containerSelector); - this.initImageUpload(); - }, - - initImageUpload: function () { - this.progressBar = new Craft.ProgressBar( - $('
').appendTo(this.$container) - ); - - const options = { - url: Craft.getActionUrl(this.settings.uploadAction), - formData: this.settings.postParameters, - fileInput: this.$container - .find(this.settings.fileInputSelector) - .toArray(), - }; - - options.on = {}; - options.on.start = this._onUploadStart.bind(this); - options.on.progress = this._onUploadProgress.bind(this); - options.on.done = this._onUploadComplete.bind(this); - options.on.fail = this._onUploadFailure.bind(this); - - this.uploader = new Craft.Uploaders.Uploader(this.$container[0], options); - - this.initButtons(); - }, - - initButtons: function () { - this.$container - .find(this.settings.uploadButtonSelector) - .on('click', (ev) => { - this.$container - .find(this.settings.fileInputSelector) - .trigger('click'); - }); - - this.$container - .find(this.settings.deleteButtonSelector) - .on('click', (ev) => { - if ( - confirm( - Craft.t('app', 'Are you sure you want to delete this image?') - ) - ) { - $(ev.currentTarget) - .parent() - .append('
'); - - Craft.sendActionRequest('POST', this.settings.deleteAction, { - data: this.settings.postParameters, - }).then(({data}) => { - this.refreshImage(data); - }); - } - }); - }, - - refreshImage: function (response) { - this.uploader.destroy(); - this.$container.replaceWith((this.$container = $(response.html))); - this.settings.onAfterRefreshImage(response); - Craft.cp.elementThumbLoader.load(this.$container); - this.initImageUpload(); - }, - - /** - * On upload start. - */ - _onUploadStart: function (event) { - this.progressBar.$progressBar.css({ - top: Math.round(this.$container.outerHeight() / 2) - 6, - }); - - this.$container.addClass('uploading'); - this.progressBar.resetProgressBar(); - this.progressBar.showProgressBar(); - }, - - /** - * On upload progress. - */ - _onUploadProgress: function (data) { - var progress = parseInt((data.loaded / data.total) * 100, 10); - this.progressBar.setProgressPercentage(progress); - }, - - /** - * On a file being uploaded. - */ - _onUploadComplete: function ({result}) { - this.refreshImage(result); - - this.progressBar.hideProgressBar(); - this.$container.removeClass('uploading'); - }, - - /** - * On Upload Failure. - */ - _onUploadFailure: function ({error, canceled, file}) { - if (canceled) { - this.progressBar.hideProgressBar(); - this.$container.removeClass('uploading'); - return; - } - - let message = error instanceof Error ? error.message : undefined; - const {errors = {}} = error?.data || {}; - const filename = error?.data?.filename || file?.name; - let errorMessages = errors ? Object.values(errors).flat() : []; - - if (!message) { - if (errorMessages.length) { - message = errorMessages.join('\n'); - } else if (filename) { - message = Craft.t('app', 'Upload failed for “{filename}”.', { - filename, - }); - } else { - message = Craft.t('app', 'Upload failed.'); - } - } - - Craft.cp.displayError(message); - this.progressBar.hideProgressBar(); - this.$container.removeClass('uploading'); - }, - }, - { - defaults: { - postParameters: {}, - uploadAction: '', - deleteAction: '', - fileInputSelector: '', - - onAfterRefreshImage: $.noop, - containerSelector: null, - - uploadButtonSelector: null, - deleteButtonSelector: null, - }, - } -); diff --git a/packages/craftcms-legacy/generalsettings/src/rebrand.js b/packages/craftcms-legacy/generalsettings/src/rebrand.js deleted file mode 100644 index 309e4816b74..00000000000 --- a/packages/craftcms-legacy/generalsettings/src/rebrand.js +++ /dev/null @@ -1,35 +0,0 @@ -import './rebrand.scss'; - -(function ($) { - /** global: Craft */ - /** global: Garnish */ - var settings = { - uploadAction: 'rebrand/upload-site-image', - deleteAction: 'rebrand/delete-site-image', - uploadButtonSelector: '.btn.upload', - deleteButtonSelector: '.btn.delete', - fileInputSelector: 'input[name=image]', - uploadParamName: 'image', - }; - - var logoSettings = $.extend({}, settings, { - postParameters: {type: 'logo'}, - containerSelector: '.cp-image-logo', - }); - - var iconSettings = $.extend({}, settings, { - postParameters: {type: 'icon'}, - containerSelector: '.cp-image-icon', - - onAfterRefreshImage: function (response) { - if (typeof response.html !== 'undefined') { - $('#site-icon') - .find('> img') - .attr('src', $('.cp-image-icon .cp-current-image').data('url')); - } - }, - }); - - new Craft.ImageUpload(iconSettings); - new Craft.ImageUpload(logoSettings); -})(jQuery); diff --git a/packages/craftcms-legacy/generalsettings/src/rebrand.scss b/packages/craftcms-legacy/generalsettings/src/rebrand.scss deleted file mode 100644 index bcae851e0d3..00000000000 --- a/packages/craftcms-legacy/generalsettings/src/rebrand.scss +++ /dev/null @@ -1,46 +0,0 @@ -@charset "UTF-8"; -@import '@craftcms/sass/mixins'; - -.cp-image { - display: flex; - flex-wrap: wrap; - gap: 14px; -} - -.cp-current-image { - border-radius: var(--radius-lg); - width: 100px; - height: 100px; - background: no-repeat; - box-shadow: inset 0 0 0 1px var(--border-hairline); - - &.checkered { - background-color: var(--white); - @include checkered-bg(17px); - background-repeat: repeat; - } -} - -.cp-image-controls { - display: flex; - flex-direction: column; - gap: 14px; -} - -.cp-image-controls .progress-shade { - background: transparentize($white, 0.2); - width: 100%; - height: 100%; - position: absolute; - inset-block-start: 0; - @include left(0); - display: none; -} - -.cp-image-controls.uploading .progress-shade { - display: block; -} - -.cp-image-modal { - width: 500px; -} diff --git a/packages/craftcms-legacy/generalsettings/webpack.config.js b/packages/craftcms-legacy/generalsettings/webpack.config.js deleted file mode 100644 index a8646cebcf3..00000000000 --- a/packages/craftcms-legacy/generalsettings/webpack.config.js +++ /dev/null @@ -1,15 +0,0 @@ -/* jshint esversion: 6 */ -/* globals module, require */ -const {getConfig} = require('@craftcms/webpack'); - -module.exports = getConfig({ - context: __dirname, - config: { - entry: {rebrand: './rebrand.js'}, - output: { - path: - __dirname + - '/../../../cms-assets/resources/legacy/generalsettings/dist', - }, - }, -}); diff --git a/packages/craftcms-ui/src/components/thumbnail-loader/README.md b/packages/craftcms-ui/src/components/thumbnail-loader/README.md index 2aee88431ec..7b4637a5db9 100644 --- a/packages/craftcms-ui/src/components/thumbnail-loader/README.md +++ b/packages/craftcms-ui/src/components/thumbnail-loader/README.md @@ -45,7 +45,7 @@ All construct `new Craft.ElementThumbLoader()` and/or use the shared - `Craft.CP` (creates the shared instance; loads `$pageContainer`, details) - `BaseElementIndexView` / `TableElementIndexView` (`$rows`, `$newElements`) - `BaseElementSelectInput`, `NestedElementManager`, `ElementEditor` -- `CpScreenSlideout`, `CpModal`, `Dashboard`, `ImageUpload` +- `CpScreenSlideout`, `CpModal`, `Dashboard` - `Preview` / `LivePreview` (static `Craft.ElementThumbLoader.retryAll()`) ## Deferred diff --git a/src/View/LegacyAssets/GeneralSettingsAsset.php b/src/View/LegacyAssets/GeneralSettingsAsset.php index 105aed8d978..3737e90e01d 100644 --- a/src/View/LegacyAssets/GeneralSettingsAsset.php +++ b/src/View/LegacyAssets/GeneralSettingsAsset.php @@ -6,8 +6,6 @@ use CraftCms\Cms\View\HtmlStack; -use function CraftCms\Cms\craftAsset; - /** * @deprecated * @@ -21,9 +19,5 @@ class GeneralSettingsAsset implements LegacyAssetInterface CpAsset::class, ]; - public function register(HtmlStack $htmlStack): void - { - // $htmlStack->jsFile(craftAsset('legacy/generalsettings/dist/rebrand.js')); - // $htmlStack->cssFile(craftAsset('legacy/generalsettings/dist/css/rebrand.css')); - } + public function register(HtmlStack $htmlStack): void {} } From 7256c8f1b4dd1cb5a3b944cf7fd2710505cdd807 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Wed, 28 Oct 2026 15:51:35 -0500 Subject: [PATCH 02/51] Move Craft.LivePreview into the Yii2 adapter Core never instantiates it, but plugins still call Craft.LivePreview.init() from their edit pages. It now ships in the adapter's cpcompat bundle, loaded CP-wide by CpCompatAsset. Core's two remaining references were no-ops, since Craft.LivePreview.instances is never populated, so they're dropped. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG-WIP.md | 1 + packages/craftcms-legacy/cp/src/Craft.js | 1 - packages/craftcms-legacy/cp/src/js/Preview.js | 3 --- .../src/js/LivePreview.js => cpcompat/src/live-preview.js} | 7 ++++++- packages/craftcms-legacy/cpcompat/webpack.config.js | 1 + resources/js/modules/slideout/slideout.ts | 3 +-- yii2-adapter/src/View/LegacyAssets/CpCompatAsset.php | 4 +++- 7 files changed, 12 insertions(+), 8 deletions(-) rename packages/craftcms-legacy/{cp/src/js/LivePreview.js => cpcompat/src/live-preview.js} (97%) diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index d2a3b5754db..f4db8dab4df 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -745,6 +745,7 @@ Craft 6 now uses [Laravel's authorization system](https://laravel.com/docs/12.x/ - Removed `Cp.$axios`. - Removed the `Craft.Accordion` and `Craft.EnvVarGenerator` control panel JavaScript classes. ([#19323](https://github.com/craftcms/cms/pull/19323)) - Removed the `Craft.ImageUpload` control panel JavaScript class. +- `Craft.LivePreview` is now provided by the Yii2 adapter. - Deprecated the `Craft.LightSwitch`, `Craft.InfoIcon`, `Craft.ColorInput`, `Craft.PasswordInput`, `Craft.IconPicker`, `Craft.SlidePicker`, `Craft.SlideRuleInput`, and `Craft.Tooltip` control panel JavaScript classes, along with the `.infoicon` jQuery plugin. The corresponding `@craftcms/ui` web components should be used instead. - Deprecated the `Craft.SortableCheckboxSelect` control panel JavaScript class. `` should be used instead. ([#20036](https://github.com/craftcms/cms/pull/20036)) diff --git a/packages/craftcms-legacy/cp/src/Craft.js b/packages/craftcms-legacy/cp/src/Craft.js index 2178e3810dc..f5748a308cc 100644 --- a/packages/craftcms-legacy/cp/src/Craft.js +++ b/packages/craftcms-legacy/cp/src/Craft.js @@ -31,7 +31,6 @@ import './js/CategoryIndex.js'; import './js/ElementEditor.js'; import './js/ElementFieldSettings.js'; import './js/EntryIndex.js'; -import './js/LivePreview.js'; import './js/Preview.js'; import './js/ProgressBar.js'; import './js/Structure.js'; diff --git a/packages/craftcms-legacy/cp/src/js/Preview.js b/packages/craftcms-legacy/cp/src/js/Preview.js index 3dfe02c5d3a..0f8625f88ff 100644 --- a/packages/craftcms-legacy/cp/src/js/Preview.js +++ b/packages/craftcms-legacy/cp/src/js/Preview.js @@ -1110,9 +1110,6 @@ Craft.Preview = Garnish.Base.extend( for (const preview of Craft.Preview.instances) { preview.updateIframe(); } - for (const preview of Craft.LivePreview.instances) { - preview.forceUpdateIframe(); - } }, getActive: function () { diff --git a/packages/craftcms-legacy/cp/src/js/LivePreview.js b/packages/craftcms-legacy/cpcompat/src/live-preview.js similarity index 97% rename from packages/craftcms-legacy/cp/src/js/LivePreview.js rename to packages/craftcms-legacy/cpcompat/src/live-preview.js index b63ea04a634..3761da3af70 100644 --- a/packages/craftcms-legacy/cp/src/js/LivePreview.js +++ b/packages/craftcms-legacy/cpcompat/src/live-preview.js @@ -1,7 +1,12 @@ /** global: Craft */ /** global: Garnish */ /** - * Live Preview + * Backwards-compatibility implementation of the legacy `Craft.LivePreview` + * class, superseded by `Craft.Preview`. Core never instantiates it, but plugins + * still boot it with `Craft.LivePreview.init()` from their edit pages, so it + * must be defined before their inline JS runs. + * + * Loaded CP-wide via CraftCms\Yii2Adapter\View\LegacyAssets\CpCompatAsset. */ Craft.LivePreview = Garnish.Base.extend( { diff --git a/packages/craftcms-legacy/cpcompat/webpack.config.js b/packages/craftcms-legacy/cpcompat/webpack.config.js index 51f09c17ba7..d789401d19f 100644 --- a/packages/craftcms-legacy/cpcompat/webpack.config.js +++ b/packages/craftcms-legacy/cpcompat/webpack.config.js @@ -13,6 +13,7 @@ module.exports = getConfig({ ), 'cp-compat': './cp-compat.js', 'legacy-html-control': './legacy-html-control.js', + 'live-preview': './live-preview.js', }, output: { path: __dirname + '/../../../cms-assets/resources/legacy/cpcompat/dist', diff --git a/resources/js/modules/slideout/slideout.ts b/resources/js/modules/slideout/slideout.ts index 142674b4fdb..d6b4eb8d865 100644 --- a/resources/js/modules/slideout/slideout.ts +++ b/resources/js/modules/slideout/slideout.ts @@ -315,8 +315,7 @@ export class Slideout extends Base implements StackedPanel { ); this._cancelTransitionListeners(); - const activePreview = - Craft.Preview.getActive() || Craft.LivePreview.getActive(); + const activePreview = Craft.Preview.getActive(); this._inPreview = !!activePreview; this.useMobileStyles = activePreview || Craft.useMobileStyles(); diff --git a/yii2-adapter/src/View/LegacyAssets/CpCompatAsset.php b/yii2-adapter/src/View/LegacyAssets/CpCompatAsset.php index 27892e6753e..bd846d5a634 100644 --- a/yii2-adapter/src/View/LegacyAssets/CpCompatAsset.php +++ b/yii2-adapter/src/View/LegacyAssets/CpCompatAsset.php @@ -26,7 +26,8 @@ * stubbed) legacy `Craft.ComponentSelectInput` implementation, relocated into * that bundle so the core CP bundle can drop it while the `componentSelect.twig` * `jsClass` escape hatch keeps working for plugin subclasses. It defines its - * class at top-level eval, so it is registered ahead of cp-compat.js. + * class at top-level eval, so it is registered ahead of cp-compat.js. So is + * live-preview.js, the legacy `Craft.LivePreview` that `Craft.Preview` replaced. * * @internal */ @@ -39,6 +40,7 @@ public function register(HtmlStack $htmlStack): void $htmlStack->html(Cp::vite()(['yii2-adapter/resources/js/cp-compat.ts'])->toHtml(), Position::Head); $htmlStack->jsFile(craftAsset('legacy/cpcompat/dist/component-select-input.js')); $htmlStack->jsFile(craftAsset('legacy/cpcompat/dist/legacy-html-control.js')); + $htmlStack->jsFile(craftAsset('legacy/cpcompat/dist/live-preview.js')); $htmlStack->jsFile(craftAsset('legacy/cpcompat/dist/cp-compat.js')); } } From 51e9bc37794bea42f9d6372dd34374a541c4c044 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Wed, 28 Oct 2026 16:16:33 -0500 Subject: [PATCH 03/51] Split ported modules' Craft globals from their element registration Each module's index.ts now only defines its element and exports. Globals core still calls move to globals.ts, which index.ts imports. Globals only plugins call move to compat.ts, which only the yii2-adapter's cp-compat.ts imports. Moving a global to the adapter is now a one-line move between the two files. Adapter-only from this commit: Craft.AssetImageEditor, Craft.SortableCheckboxSelect, Craft.GroupedEntryTypeManager, Craft.RecoveryCodesSetup and Craft.TagSelectInput, which core no longer calls. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG-WIP.md | 1 + resources/js/modules/README.md | 28 +++++---- resources/js/modules/asset-mover/globals.ts | 5 ++ resources/js/modules/asset-mover/index.ts | 5 +- .../js/modules/asset-select-input/globals.ts | 6 ++ .../js/modules/asset-select-input/index.ts | 6 +- .../js/modules/auth-method-setup/compat.ts | 4 ++ .../js/modules/auth-method-setup/globals.ts | 7 +++ .../js/modules/auth-method-setup/index.ts | 11 +--- .../modules/auth/elevated-session/globals.ts | 41 +++++++++++++ .../js/modules/auth/elevated-session/index.ts | 40 +------------ resources/js/modules/cp-modal/globals.ts | 7 +++ resources/js/modules/cp-modal/index.ts | 7 +-- .../js/modules/customize-sources/globals.ts | 8 +++ .../js/modules/customize-sources/index.ts | 9 +-- .../js/modules/data-table-sorter/globals.ts | 6 ++ .../js/modules/data-table-sorter/index.ts | 6 +- .../js/modules/editable-table/globals.ts | 15 +++++ resources/js/modules/editable-table/index.ts | 15 +---- .../modules/element-action-trigger/globals.ts | 6 ++ .../modules/element-action-trigger/index.ts | 6 +- .../element-deletion-manager/globals.ts | 6 ++ .../modules/element-deletion-manager/index.ts | 6 +- .../js/modules/element-select-input/compat.ts | 4 ++ .../modules/element-select-input/globals.ts | 7 +++ .../js/modules/element-select-input/index.ts | 13 +--- .../modules/element-selector-modal/globals.ts | 27 +++++++++ .../modules/element-selector-modal/index.ts | 24 +------- .../modules/element-table-sorter/globals.ts | 6 ++ .../js/modules/element-table-sorter/index.ts | 6 +- .../modules/element-thumb-loader/globals.ts | 29 +++++++++ .../js/modules/element-thumb-loader/index.ts | 30 +--------- resources/js/modules/entry-mover/globals.ts | 7 +++ resources/js/modules/entry-mover/index.ts | 7 +-- .../modules/field-layout-designer/globals.ts | 22 +++++++ .../js/modules/field-layout-designer/index.ts | 17 +----- resources/js/modules/field-toggle/globals.ts | 9 +++ resources/js/modules/field-toggle/index.ts | 9 +-- resources/js/modules/form-observer/globals.ts | 8 +++ resources/js/modules/form-observer/index.ts | 8 +-- resources/js/modules/grid/globals.ts | 8 +++ resources/js/modules/grid/index.ts | 8 +-- .../grouped-entry-type-manager/compat.ts | 13 ++++ .../grouped-entry-type-manager/index.ts | 18 ------ resources/js/modules/highlight/globals.ts | 8 +++ resources/js/modules/highlight/index.ts | 8 +-- resources/js/modules/image-editor/compat.ts | 5 ++ resources/js/modules/image-editor/globals.ts | 8 +++ resources/js/modules/image-editor/index.ts | 13 +--- .../js/modules/input-generators/globals.ts | 20 +++++++ .../js/modules/input-generators/index.ts | 16 +---- .../js/modules/interval-manager/globals.ts | 7 +++ .../js/modules/interval-manager/index.ts | 7 +-- resources/js/modules/link-field/globals.ts | 8 +++ resources/js/modules/link-field/index.ts | 7 +-- resources/js/modules/listbox/globals.ts | 38 ++++++++++++ resources/js/modules/listbox/index.ts | 40 +------------ .../js/modules/preview-file-modal/globals.ts | 6 ++ .../js/modules/preview-file-modal/index.ts | 6 +- .../js/modules/prompt-handler/globals.ts | 7 +++ resources/js/modules/prompt-handler/index.ts | 7 +-- resources/js/modules/queue/globals.ts | 14 +++++ resources/js/modules/queue/index.ts | 14 +---- resources/js/modules/slideout/globals.ts | 50 ++++++++++++++++ resources/js/modules/slideout/index.ts | 51 +--------------- .../sortable-checkbox-select/compat.ts | 4 ++ .../modules/sortable-checkbox-select/index.ts | 60 +------------------ .../sortable-checkbox-select.ts | 57 ++++++++++++++++++ resources/js/modules/tabs/globals.ts | 7 +++ resources/js/modules/tabs/index.ts | 7 +-- resources/js/modules/uploader/globals.ts | 6 ++ resources/js/modules/uploader/index.ts | 5 +- yii2-adapter/resources/js/cp-compat.ts | 5 ++ 73 files changed, 556 insertions(+), 466 deletions(-) create mode 100644 resources/js/modules/asset-mover/globals.ts create mode 100644 resources/js/modules/asset-select-input/globals.ts create mode 100644 resources/js/modules/auth-method-setup/compat.ts create mode 100644 resources/js/modules/auth-method-setup/globals.ts create mode 100644 resources/js/modules/auth/elevated-session/globals.ts create mode 100644 resources/js/modules/cp-modal/globals.ts create mode 100644 resources/js/modules/customize-sources/globals.ts create mode 100644 resources/js/modules/data-table-sorter/globals.ts create mode 100644 resources/js/modules/editable-table/globals.ts create mode 100644 resources/js/modules/element-action-trigger/globals.ts create mode 100644 resources/js/modules/element-deletion-manager/globals.ts create mode 100644 resources/js/modules/element-select-input/compat.ts create mode 100644 resources/js/modules/element-select-input/globals.ts create mode 100644 resources/js/modules/element-selector-modal/globals.ts create mode 100644 resources/js/modules/element-table-sorter/globals.ts create mode 100644 resources/js/modules/element-thumb-loader/globals.ts create mode 100644 resources/js/modules/entry-mover/globals.ts create mode 100644 resources/js/modules/field-layout-designer/globals.ts create mode 100644 resources/js/modules/field-toggle/globals.ts create mode 100644 resources/js/modules/form-observer/globals.ts create mode 100644 resources/js/modules/grid/globals.ts create mode 100644 resources/js/modules/grouped-entry-type-manager/compat.ts create mode 100644 resources/js/modules/highlight/globals.ts create mode 100644 resources/js/modules/image-editor/compat.ts create mode 100644 resources/js/modules/image-editor/globals.ts create mode 100644 resources/js/modules/input-generators/globals.ts create mode 100644 resources/js/modules/interval-manager/globals.ts create mode 100644 resources/js/modules/link-field/globals.ts create mode 100644 resources/js/modules/listbox/globals.ts create mode 100644 resources/js/modules/preview-file-modal/globals.ts create mode 100644 resources/js/modules/prompt-handler/globals.ts create mode 100644 resources/js/modules/queue/globals.ts create mode 100644 resources/js/modules/slideout/globals.ts create mode 100644 resources/js/modules/sortable-checkbox-select/compat.ts create mode 100644 resources/js/modules/sortable-checkbox-select/sortable-checkbox-select.ts create mode 100644 resources/js/modules/tabs/globals.ts create mode 100644 resources/js/modules/uploader/globals.ts diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index f4db8dab4df..c472b19fa5e 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -746,6 +746,7 @@ Craft 6 now uses [Laravel's authorization system](https://laravel.com/docs/12.x/ - Removed the `Craft.Accordion` and `Craft.EnvVarGenerator` control panel JavaScript classes. ([#19323](https://github.com/craftcms/cms/pull/19323)) - Removed the `Craft.ImageUpload` control panel JavaScript class. - `Craft.LivePreview` is now provided by the Yii2 adapter. +- `Craft.AssetImageEditor`, `Craft.GroupedEntryTypeManager`, `Craft.RecoveryCodesSetup`, `Craft.SortableCheckboxSelect`, and `Craft.TagSelectInput` are now provided by the Yii2 adapter. - Deprecated the `Craft.LightSwitch`, `Craft.InfoIcon`, `Craft.ColorInput`, `Craft.PasswordInput`, `Craft.IconPicker`, `Craft.SlidePicker`, `Craft.SlideRuleInput`, and `Craft.Tooltip` control panel JavaScript classes, along with the `.infoicon` jQuery plugin. The corresponding `@craftcms/ui` web components should be used instead. - Deprecated the `Craft.SortableCheckboxSelect` control panel JavaScript class. `` should be used instead. ([#20036](https://github.com/craftcms/cms/pull/20036)) diff --git a/resources/js/modules/README.md b/resources/js/modules/README.md index b683a2bc4af..369eb42f833 100644 --- a/resources/js/modules/README.md +++ b/resources/js/modules/README.md @@ -12,14 +12,16 @@ Reference implementations, simplest to richest: ## The port pattern -A ported module is a folder of (usually) five files: +A ported module is a folder of (usually) five files, plus one or two for its `Craft.*` globals: | File | Role | | --- | --- | | `.ts` | **The logic class.** A plain class extending `@craftcms/garnish` `Base`. This is the port of the legacy `Craft.Thing` — all behavior lives here. Constructed with `(container, settings)`; no custom-element or attribute concerns. | | `.ce.ts` | **The custom element.** A thin `ControllerElement` subclass (`resources/js/common/web-components/`) that boots the logic class around the server-rendered markup it wraps, and tears it down on disconnect. `.ce` = "custom element". | | `support.ts` | **The instance registry.** A module-scoped `WeakMap` replacing the legacy jQuery `$el.data('thing')` back-reference, so other code can find the instance for an element. | -| `index.ts` | **The shim + barrel.** Registers the element (`defineElement`), assigns the legacy `window.Craft.*` global (or deliberately doesn't — see below), and re-exports the class, element, types, and registry. This is the file `cp.ts` / `legacy.ts` imports. | +| `index.ts` | **The barrel.** Registers the element (`defineElement`) and re-exports the class, element, types, and registry. This is the file `cp.ts` / `legacy.ts` imports. | +| `globals.ts` | **Globals core still calls.** Assigns the `window.Craft.*` globals that core PHP, Twig, or the legacy bundle still use. `index.ts` imports it first. | +| `compat.ts` | **Globals only plugins call.** Assigns `window.Craft.*` globals kept for backwards compatibility. Only the yii2-adapter imports it (`yii2-adapter/resources/js/cp-compat.ts`). | | `README.md` | What legacy file it replaces, what changed, what stayed jQuery, and any deferred work. Mirror the existing ones. | ### The logic class (`.ts`) @@ -62,12 +64,15 @@ state on it (e.g. `component-select`, whose `getInputValue` hook may be assigned before the instance has booted; there the element owns the hook and the class reads it back through a settings callback). -### The shim (`index.ts`) +### The globals (`globals.ts` / `compat.ts`) -- `defineElement('craft-thing', CraftThing)` registers the element (guarded, so - double-import is safe). -- Assign the legacy global so any remaining `new Craft.Thing(...)` boots keep - working: `window.Craft.Thing = Thing`. Assign the **plain modern ES class** — +- `index.ts` calls `defineElement('craft-thing', CraftThing)` to register the + element (guarded, so double-import is safe). +- Assign the legacy global with `registerCraftGlobals({Thing})` so remaining + `new Craft.Thing(...)` boots keep working. Put it in `globals.ts` while core + still calls it, and in `compat.ts` once only plugins do. Moving a global to the + adapter means moving its line from one file to the other. Delete a module's + `globals.ts` (and its import) once it's empty. Assign the **plain modern ES class** — **no `compatify`** — unless something still subclasses it via the legacy `Garnish.Base.extend()` (nothing in core does). Re-attach static members the legacy bundle exposed (`Thing.Item`, `Thing.Group`). @@ -81,12 +86,15 @@ reads it back through a settings callback). 1. Import the module for its side effects from the right entry point — `resources/js/cp.ts` (Inertia/modern surfaces) and/or `resources/js/legacy.ts` - (legacy Twig surfaces). Importing `index.ts` is what registers the element and - assigns the global. + (legacy Twig surfaces). Importing `index.ts` registers the element and the + `globals.ts` globals. Import a `compat.ts` from + `yii2-adapter/resources/js/cp-compat.ts`. 2. Update the Twig/PHP that rendered the legacy markup to emit `` (with settings as attributes) instead of a manual `new Craft.Thing(...)` `{% js %}` boot. -3. Delete the legacy `cp/src/js/Thing.js`, remove its `import` from +3. Once core no longer calls `Craft.Thing`, move it from `globals.ts` to + `compat.ts`. +4. Delete the legacy `cp/src/js/Thing.js`, remove its `import` from `packages/craftcms-legacy/cp/src/Craft.js`, and **rebuild the legacy bundle** (`cd packages/craftcms-legacy && pnpm run build`) — confirm the built `cms-assets/resources/legacy/cp/dist/cp.js` no longer references it. diff --git a/resources/js/modules/asset-mover/globals.ts b/resources/js/modules/asset-mover/globals.ts new file mode 100644 index 00000000000..a5fc85411eb --- /dev/null +++ b/resources/js/modules/asset-mover/globals.ts @@ -0,0 +1,5 @@ +import {AssetMover} from './asset-mover'; +import {registerCraftGlobals} from '@/common/craft-global'; + +// `new Craft.AssetMover()` is created by the asset index. Plain modern ES class. +registerCraftGlobals({AssetMover}); diff --git a/resources/js/modules/asset-mover/index.ts b/resources/js/modules/asset-mover/index.ts index 63be39b5691..d6e175bba26 100644 --- a/resources/js/modules/asset-mover/index.ts +++ b/resources/js/modules/asset-mover/index.ts @@ -1,7 +1,4 @@ +import './globals'; import {AssetMover} from './asset-mover'; -import {registerCraftGlobals} from '@/common/craft-global'; - -// `new Craft.AssetMover()` is created by the asset index. Plain modern ES class. -registerCraftGlobals({AssetMover}); export {AssetMover}; diff --git a/resources/js/modules/asset-select-input/globals.ts b/resources/js/modules/asset-select-input/globals.ts new file mode 100644 index 00000000000..1894970a242 --- /dev/null +++ b/resources/js/modules/asset-select-input/globals.ts @@ -0,0 +1,6 @@ +import {AssetSelectInput} from './asset-select-input'; +import {registerCraftGlobals} from '@/common/craft-global'; + +// Assign the legacy `Craft.AssetSelectInput` global so PHP-emitted code and the +// still-legacy cp bundle keep working. +registerCraftGlobals({AssetSelectInput}); diff --git a/resources/js/modules/asset-select-input/index.ts b/resources/js/modules/asset-select-input/index.ts index 046c1dc1c0a..fe834fd5af7 100644 --- a/resources/js/modules/asset-select-input/index.ts +++ b/resources/js/modules/asset-select-input/index.ts @@ -1,8 +1,4 @@ -import {registerCraftGlobals} from '@/common/craft-global'; +import './globals'; import {AssetSelectInput} from './asset-select-input'; -// Assign the legacy `Craft.AssetSelectInput` global so PHP-emitted code and the -// still-legacy cp bundle keep working. -registerCraftGlobals({AssetSelectInput}); - export {AssetSelectInput}; diff --git a/resources/js/modules/auth-method-setup/compat.ts b/resources/js/modules/auth-method-setup/compat.ts new file mode 100644 index 00000000000..ec65dadcbeb --- /dev/null +++ b/resources/js/modules/auth-method-setup/compat.ts @@ -0,0 +1,4 @@ +import {RecoveryCodesSetup} from './recovery-codes-setup'; +import {registerCraftGlobals} from '@/common/craft-global'; + +registerCraftGlobals({RecoveryCodesSetup}); diff --git a/resources/js/modules/auth-method-setup/globals.ts b/resources/js/modules/auth-method-setup/globals.ts new file mode 100644 index 00000000000..de9a250fcb2 --- /dev/null +++ b/resources/js/modules/auth-method-setup/globals.ts @@ -0,0 +1,7 @@ +import {AuthMethodSetup} from './auth-method-setup'; +import {registerCraftGlobals} from '@/common/craft-global'; + +// Plain modern class — nothing subclasses it via the legacy `.extend()` API. +// The slideout subclass rides along as `Craft.AuthMethodSetup.Slideout` (a +// static on the class). +registerCraftGlobals({AuthMethodSetup}); diff --git a/resources/js/modules/auth-method-setup/index.ts b/resources/js/modules/auth-method-setup/index.ts index 8302c984d4d..20be71fc2bc 100644 --- a/resources/js/modules/auth-method-setup/index.ts +++ b/resources/js/modules/auth-method-setup/index.ts @@ -1,4 +1,5 @@ import './auth.scss'; +import './globals'; import { AuthMethodSetup, type AuthMethodSetupSettings, @@ -8,20 +9,14 @@ import { type AuthMethodSetupSlideoutData, } from './auth-method-setup-slideout'; import {RecoveryCodesSetup} from './recovery-codes-setup'; -import {registerCraftGlobals} from '@/common/craft-global'; /** * No custom element here: the auth-method listing is booted from `{% js %}` * template code (`new Craft.AuthMethodSetup({onRefresh})`), whose callback - * settings can't be expressed as element attributes. This file's job is the - * `Craft.*` globals and the module stylesheet. + * settings can't be expressed as element attributes, so the class is reached + * through the `Craft.AuthMethodSetup` global. */ -// Plain modern classes — nothing subclasses these via the legacy `.extend()` -// API. The slideout subclass rides along as `Craft.AuthMethodSetup.Slideout` -// (a static on the class). -registerCraftGlobals({AuthMethodSetup, RecoveryCodesSetup}); - export { AuthMethodSetup, type AuthMethodSetupSettings, diff --git a/resources/js/modules/auth/elevated-session/globals.ts b/resources/js/modules/auth/elevated-session/globals.ts new file mode 100644 index 00000000000..c23060ef70a --- /dev/null +++ b/resources/js/modules/auth/elevated-session/globals.ts @@ -0,0 +1,41 @@ +import {ElevatedSessionForm} from './elevated-session-form'; +import {elevatedSessionManager} from './manager'; +import {registerCraftGlobals} from '@/common/craft-global'; + +class LegacyElevatedSessionManager { + static minSafeElevatedSessionTimeout = 5; + + get fetchingTimeout(): boolean { + return elevatedSessionManager.state.checking; + } + + async requireElevatedSession( + onSuccess: () => void, + onCancel?: () => void, + minimumRemainingSeconds = LegacyElevatedSessionManager.minSafeElevatedSessionTimeout + ): Promise { + const confirmed = await elevatedSessionManager.require({ + minimumRemainingSeconds, + }); + + if (confirmed) { + onSuccess(); + } else { + onCancel?.(); + } + } + + showLoginModal(): void { + void elevatedSessionManager.require({force: true}); + } +} + +// Assign onto the legacy `Craft` global so the Twig/PHP-emitted +// `new Craft.ElevatedSessionForm(...)` (EmailField, _password/_team/_permissions +// screens) and `Craft.elevatedSessionManager` keep working. The manager global is +// a thin `LegacyElevatedSessionManager` shim over the modern reactive manager. +registerCraftGlobals({ + ElevatedSessionManager: LegacyElevatedSessionManager, + elevatedSessionManager: new LegacyElevatedSessionManager(), + ElevatedSessionForm, +}); diff --git a/resources/js/modules/auth/elevated-session/index.ts b/resources/js/modules/auth/elevated-session/index.ts index a00b688c419..410f1c585ac 100644 --- a/resources/js/modules/auth/elevated-session/index.ts +++ b/resources/js/modules/auth/elevated-session/index.ts @@ -1,3 +1,4 @@ +import './globals'; import {createApp} from 'vue'; import ElevatedSessionHost from './ElevatedSessionHost.vue'; import {ElevatedSessionForm} from './elevated-session-form'; @@ -8,45 +9,6 @@ import { ElevatedSessionManager, type ElevatedSessionOptions, } from './manager'; -import {registerCraftGlobals} from '@/common/craft-global'; - -class LegacyElevatedSessionManager { - static minSafeElevatedSessionTimeout = 5; - - get fetchingTimeout(): boolean { - return elevatedSessionManager.state.checking; - } - - async requireElevatedSession( - onSuccess: () => void, - onCancel?: () => void, - minimumRemainingSeconds = LegacyElevatedSessionManager.minSafeElevatedSessionTimeout - ): Promise { - const confirmed = await elevatedSessionManager.require({ - minimumRemainingSeconds, - }); - - if (confirmed) { - onSuccess(); - } else { - onCancel?.(); - } - } - - showLoginModal(): void { - void elevatedSessionManager.require({force: true}); - } -} - -// Assign onto the legacy `Craft` global so the Twig/PHP-emitted -// `new Craft.ElevatedSessionForm(...)` (EmailField, _password/_team/_permissions -// screens) and `Craft.elevatedSessionManager` keep working. The manager global is -// a thin `LegacyElevatedSessionManager` shim over the modern reactive manager. -registerCraftGlobals({ - ElevatedSessionManager: LegacyElevatedSessionManager, - elevatedSessionManager: new LegacyElevatedSessionManager(), - ElevatedSessionForm, -}); // Register the declarative custom element, so Twig can emit // `` instead of the manual global constructor. diff --git a/resources/js/modules/cp-modal/globals.ts b/resources/js/modules/cp-modal/globals.ts new file mode 100644 index 00000000000..f13c924e2f7 --- /dev/null +++ b/resources/js/modules/cp-modal/globals.ts @@ -0,0 +1,7 @@ +import {CpModal} from './cp-modal'; +import {registerCraftGlobals} from '@/common/craft-global'; + +// Assign the legacy `Craft` global: `new Craft.CpModal(...)` is emitted by the +// PHP deletion blockers. Plain modern ES class — nothing subclasses it via the +// legacy `.extend()` API. +registerCraftGlobals({CpModal}); diff --git a/resources/js/modules/cp-modal/index.ts b/resources/js/modules/cp-modal/index.ts index b6271cefba7..856faf8a07a 100644 --- a/resources/js/modules/cp-modal/index.ts +++ b/resources/js/modules/cp-modal/index.ts @@ -1,9 +1,4 @@ +import './globals'; import {CpModal} from './cp-modal'; -import {registerCraftGlobals} from '@/common/craft-global'; - -// Assign the legacy `Craft` global: `new Craft.CpModal(...)` is emitted by the -// PHP deletion blockers. Plain modern ES class — nothing subclasses it via the -// legacy `.extend()` API. -registerCraftGlobals({CpModal}); export {CpModal}; diff --git a/resources/js/modules/customize-sources/globals.ts b/resources/js/modules/customize-sources/globals.ts new file mode 100644 index 00000000000..f2d6516ee96 --- /dev/null +++ b/resources/js/modules/customize-sources/globals.ts @@ -0,0 +1,8 @@ +import {openCustomizeSourcesModal} from './open-customize-sources-modal'; +import {registerCraftGlobals} from '@/common/craft-global'; + +/** + * `Craft.openCustomizeSourcesModal` is how the legacy element index offers + * Customize Sources: it has no modal of its own anymore, so it opens this one. + */ +registerCraftGlobals({openCustomizeSourcesModal}); diff --git a/resources/js/modules/customize-sources/index.ts b/resources/js/modules/customize-sources/index.ts index 614ddf28f38..57dddd04c2f 100644 --- a/resources/js/modules/customize-sources/index.ts +++ b/resources/js/modules/customize-sources/index.ts @@ -1,11 +1,4 @@ -import {registerCraftGlobals} from '@/common/craft-global'; -import {openCustomizeSourcesModal} from './open-customize-sources-modal'; - -/** - * `Craft.openCustomizeSourcesModal` is how the legacy element index offers - * Customize Sources: it has no modal of its own anymore, so it opens this one. - */ -registerCraftGlobals({openCustomizeSourcesModal}); +import './globals'; export {openCustomizeSourcesModal} from './open-customize-sources-modal'; export type {CustomizeSourcesModalOptions} from './open-customize-sources-modal'; diff --git a/resources/js/modules/data-table-sorter/globals.ts b/resources/js/modules/data-table-sorter/globals.ts new file mode 100644 index 00000000000..b7e48acd602 --- /dev/null +++ b/resources/js/modules/data-table-sorter/globals.ts @@ -0,0 +1,6 @@ +import {DataTableSorter} from './data-table-sorter'; +import {registerCraftGlobals} from '@/common/craft-global'; + +// `new Craft.DataTableSorter(...)` is created by Craft.AdminTable (legacy) and +// the modern editable-table module. Plain modern ES class. +registerCraftGlobals({DataTableSorter}); diff --git a/resources/js/modules/data-table-sorter/index.ts b/resources/js/modules/data-table-sorter/index.ts index 328930f7cbd..5f589f01145 100644 --- a/resources/js/modules/data-table-sorter/index.ts +++ b/resources/js/modules/data-table-sorter/index.ts @@ -1,8 +1,4 @@ +import './globals'; import {DataTableSorter} from './data-table-sorter'; -import {registerCraftGlobals} from '@/common/craft-global'; - -// `new Craft.DataTableSorter(...)` is created by Craft.AdminTable (legacy) and -// the modern editable-table module. Plain modern ES class. -registerCraftGlobals({DataTableSorter}); export {DataTableSorter}; diff --git a/resources/js/modules/editable-table/globals.ts b/resources/js/modules/editable-table/globals.ts new file mode 100644 index 00000000000..d534569cd97 --- /dev/null +++ b/resources/js/modules/editable-table/globals.ts @@ -0,0 +1,15 @@ +import {EditableTable, Row} from './editable-table'; +import {compatify} from '@craftcms/garnish/compat'; +import {registerCraftGlobals} from '@/common/craft-global'; + +// Wrap the modern classes with the Garnish compat shim so the still-legacy +// `TableFieldSettings` bundle (which uses `.extend()` / `this.base(...)`) keeps +// working unchanged. Statics are inherited automatically, so only the nested `Row` +// needs re-attaching — as the FLD index does with `.Tab`/`.Element`. +const EditableTableCompat = Object.assign(compatify(EditableTable), { + Row: compatify(Row), +}); + +// Assign onto the legacy `Craft` global (created by the cp bundle) so Twig-emitted +// `new Craft.EditableTable(...)` and `TableFieldSettings` subclasses keep working. +registerCraftGlobals({EditableTable: EditableTableCompat}); diff --git a/resources/js/modules/editable-table/index.ts b/resources/js/modules/editable-table/index.ts index 85e56932d90..35e0250fd7d 100644 --- a/resources/js/modules/editable-table/index.ts +++ b/resources/js/modules/editable-table/index.ts @@ -1,17 +1,4 @@ +import './globals'; import {EditableTable, Row} from './editable-table'; -import {compatify} from '@craftcms/garnish/compat'; -import {registerCraftGlobals} from '@/common/craft-global'; - -// Wrap the modern classes with the Garnish compat shim so the still-legacy -// `TableFieldSettings` bundle (which uses `.extend()` / `this.base(...)`) keeps -// working unchanged. Statics are inherited automatically, so only the nested `Row` -// needs re-attaching — as the FLD index does with `.Tab`/`.Element`. -const EditableTableCompat = Object.assign(compatify(EditableTable), { - Row: compatify(Row), -}); - -// Assign onto the legacy `Craft` global (created by the cp bundle) so Twig-emitted -// `new Craft.EditableTable(...)` and `TableFieldSettings` subclasses keep working. -registerCraftGlobals({EditableTable: EditableTableCompat}); export {EditableTable, Row}; diff --git a/resources/js/modules/element-action-trigger/globals.ts b/resources/js/modules/element-action-trigger/globals.ts new file mode 100644 index 00000000000..0d18ad93ada --- /dev/null +++ b/resources/js/modules/element-action-trigger/globals.ts @@ -0,0 +1,6 @@ +import {ElementActionTrigger} from './element-action-trigger'; +import {registerCraftGlobals} from '@/common/craft-global'; + +// `new Craft.ElementActionTrigger(...)` is emitted by PHP element actions and +// created by the (still-legacy) element index. Plain modern ES class. +registerCraftGlobals({ElementActionTrigger}); diff --git a/resources/js/modules/element-action-trigger/index.ts b/resources/js/modules/element-action-trigger/index.ts index 417b178891d..4fa67dfee1e 100644 --- a/resources/js/modules/element-action-trigger/index.ts +++ b/resources/js/modules/element-action-trigger/index.ts @@ -1,8 +1,4 @@ +import './globals'; import {ElementActionTrigger} from './element-action-trigger'; -import {registerCraftGlobals} from '@/common/craft-global'; - -// `new Craft.ElementActionTrigger(...)` is emitted by PHP element actions and -// created by the (still-legacy) element index. Plain modern ES class. -registerCraftGlobals({ElementActionTrigger}); export {ElementActionTrigger}; diff --git a/resources/js/modules/element-deletion-manager/globals.ts b/resources/js/modules/element-deletion-manager/globals.ts new file mode 100644 index 00000000000..b78bc085739 --- /dev/null +++ b/resources/js/modules/element-deletion-manager/globals.ts @@ -0,0 +1,6 @@ +import {ElementDeletionManager} from './element-deletion-manager'; +import {registerCraftGlobals} from '@/common/craft-global'; + +// `new Craft.ElementDeletionManager(...)` is created by the element index / PHP. +// Plain modern ES class (with a static `.Blocker` for BC). +registerCraftGlobals({ElementDeletionManager}); diff --git a/resources/js/modules/element-deletion-manager/index.ts b/resources/js/modules/element-deletion-manager/index.ts index ce65c5cef76..7c0ebcd6337 100644 --- a/resources/js/modules/element-deletion-manager/index.ts +++ b/resources/js/modules/element-deletion-manager/index.ts @@ -1,8 +1,4 @@ +import './globals'; import {ElementDeletionManager} from './element-deletion-manager'; -import {registerCraftGlobals} from '@/common/craft-global'; - -// `new Craft.ElementDeletionManager(...)` is created by the element index / PHP. -// Plain modern ES class (with a static `.Blocker` for BC). -registerCraftGlobals({ElementDeletionManager}); export {ElementDeletionManager}; diff --git a/resources/js/modules/element-select-input/compat.ts b/resources/js/modules/element-select-input/compat.ts new file mode 100644 index 00000000000..2390fac54e1 --- /dev/null +++ b/resources/js/modules/element-select-input/compat.ts @@ -0,0 +1,4 @@ +import {TagSelectInput} from './tag-select-input'; +import {registerCraftGlobals} from '@/common/craft-global'; + +registerCraftGlobals({TagSelectInput}); diff --git a/resources/js/modules/element-select-input/globals.ts b/resources/js/modules/element-select-input/globals.ts new file mode 100644 index 00000000000..7092db0d533 --- /dev/null +++ b/resources/js/modules/element-select-input/globals.ts @@ -0,0 +1,7 @@ +import {BaseElementSelectInput} from './base-element-select-input'; +import {EntrySelectInput} from './entry-select-input'; +import {registerCraftGlobals} from '@/common/craft-global'; + +// Assign legacy `Craft.*` globals so PHP-emitted `new Craft.BaseElementSelectInput({…})` +// and subclasses via `.extend()` keep working. +registerCraftGlobals({BaseElementSelectInput, EntrySelectInput}); diff --git a/resources/js/modules/element-select-input/index.ts b/resources/js/modules/element-select-input/index.ts index 83530acc422..cba35d7cad0 100644 --- a/resources/js/modules/element-select-input/index.ts +++ b/resources/js/modules/element-select-input/index.ts @@ -1,22 +1,11 @@ -import {registerCraftGlobals} from '@/common/craft-global'; import '@/modules/element-thumb-loader'; -import {BaseElementSelectInput} from './base-element-select-input'; -import {EntrySelectInput} from './entry-select-input'; -import {TagSelectInput} from './tag-select-input'; +import './globals'; import CraftElementSelectInput, { CraftAssetSelectInput, CraftEntrySelectInput, } from './element-select-input.ce'; import {defineElement} from '@/common/web-components'; -// Assign legacy `Craft.*` globals so PHP-emitted `new Craft.BaseElementSelectInput({…})` -// and subclasses via `.extend()` keep working. -registerCraftGlobals({ - BaseElementSelectInput, - EntrySelectInput, - TagSelectInput, -}); - defineElement('craft-asset-select-input', CraftAssetSelectInput); defineElement('craft-element-select-input', CraftElementSelectInput); defineElement('craft-entry-select-input', CraftEntrySelectInput); diff --git a/resources/js/modules/element-selector-modal/globals.ts b/resources/js/modules/element-selector-modal/globals.ts new file mode 100644 index 00000000000..e6e8bd05e4c --- /dev/null +++ b/resources/js/modules/element-selector-modal/globals.ts @@ -0,0 +1,27 @@ +import { + AssetSelectorController, + ElementSelectorController, + registerElementSelectorController, +} from '@craftcms/ui'; +import {createElementSelectorModal} from './create-element-selector-modal'; +import {VolumeFolderSelectorModal} from './volume-folder-selector-modal'; +import {registerCraftGlobals} from '@/common/craft-global'; + +/** + * `Craft.*` stays the entry point for plugins and PHP-emitted boots + * (`new Craft.VolumeFolderSelectorModal(…)` in `MoveAssets.php` and the legacy + * `AssetIndex`). + * + * `createElementSelectorModal` is now **async** and resolves to a + * controller-backed handle rather than a modal object — a deliberate break, as + * the modal is no longer a class anyone can subclass. `registerElementSelectorController` + * replaces `registerElementSelectorModalClass`; plugins register a controller + * (business logic) and get both presentation layers for free. + */ +registerCraftGlobals({ + VolumeFolderSelectorModal, + createElementSelectorModal, + registerElementSelectorController, + ElementSelectorController, + AssetSelectorController, +}); diff --git a/resources/js/modules/element-selector-modal/index.ts b/resources/js/modules/element-selector-modal/index.ts index fdb15e8c463..b8771b1a55e 100644 --- a/resources/js/modules/element-selector-modal/index.ts +++ b/resources/js/modules/element-selector-modal/index.ts @@ -1,13 +1,10 @@ -import {registerCraftGlobals} from '@/common/craft-global'; +import './globals'; import { AssetSelectorController, - ElementSelectorController, adoptLegacyRegistrations, hasElementSelectorController, registerElementSelectorController, } from '@craftcms/ui'; -import {createElementSelectorModal} from './create-element-selector-modal'; -import {VolumeFolderSelectorModal} from './volume-folder-selector-modal'; const ASSET = 'CraftCms\\Cms\\Asset\\Elements\\Asset'; @@ -22,25 +19,6 @@ if (!hasElementSelectorController(ASSET)) { registerElementSelectorController(ASSET, AssetSelectorController); } -/** - * `Craft.*` stays the entry point for plugins and PHP-emitted boots - * (`new Craft.VolumeFolderSelectorModal(…)` in `MoveAssets.php` and the legacy - * `AssetIndex`). - * - * `createElementSelectorModal` is now **async** and resolves to a - * controller-backed handle rather than a modal object — a deliberate break, as - * the modal is no longer a class anyone can subclass. `registerElementSelectorController` - * replaces `registerElementSelectorModalClass`; plugins register a controller - * (business logic) and get both presentation layers for free. - */ -registerCraftGlobals({ - VolumeFolderSelectorModal, - createElementSelectorModal, - registerElementSelectorController, - ElementSelectorController, - AssetSelectorController, -}); - export {createElementSelectorModal} from './create-element-selector-modal'; export type { ElementSelectorModalHandle, diff --git a/resources/js/modules/element-table-sorter/globals.ts b/resources/js/modules/element-table-sorter/globals.ts new file mode 100644 index 00000000000..aa2c70688b6 --- /dev/null +++ b/resources/js/modules/element-table-sorter/globals.ts @@ -0,0 +1,6 @@ +import {ElementTableSorter} from './element-table-sorter'; +import {registerCraftGlobals} from '@/common/craft-global'; + +// `new Craft.ElementTableSorter(...)` is created by Craft.TableElementIndexView. +// Plain modern ES class. +registerCraftGlobals({ElementTableSorter}); diff --git a/resources/js/modules/element-table-sorter/index.ts b/resources/js/modules/element-table-sorter/index.ts index 2100e85be16..d864d9407b9 100644 --- a/resources/js/modules/element-table-sorter/index.ts +++ b/resources/js/modules/element-table-sorter/index.ts @@ -1,8 +1,4 @@ +import './globals'; import {ElementTableSorter} from './element-table-sorter'; -import {registerCraftGlobals} from '@/common/craft-global'; - -// `new Craft.ElementTableSorter(...)` is created by Craft.TableElementIndexView. -// Plain modern ES class. -registerCraftGlobals({ElementTableSorter}); export {ElementTableSorter}; diff --git a/resources/js/modules/element-thumb-loader/globals.ts b/resources/js/modules/element-thumb-loader/globals.ts new file mode 100644 index 00000000000..58953224dba --- /dev/null +++ b/resources/js/modules/element-thumb-loader/globals.ts @@ -0,0 +1,29 @@ +import {ThumbnailLoader} from '@craftcms/ui/utilities/thumbnail-loader'; +import {registerCraftGlobals} from '@/common/craft-global'; + +/** + * Legacy `Craft.ElementThumbLoader` — a thin bridge over `@craftcms/ui`'s + * `ThumbnailLoader` (see the package's `components/thumbnail-loader/README.md`). + * + * Callers (`Craft.cp.elementThumbLoader`, element index views, slideouts, + * modals, previews) pass `load()` a jQuery collection and expect every element + * in it to be scanned for `.thumb[data-sizes]` descendants, so the override + * fans the collection out to the modern `load(root, selector)` signature. + * `Preview`/`LivePreview` call the static `Craft.ElementThumbLoader.retryAll()`, + * inherited from `ThumbnailLoader`. + * + * Already modern code (it imported `@craftcms/ui` even in the legacy bundle); + * this just relocates it into `resources/js`. + */ +class ElementThumbLoader extends ThumbnailLoader { + override load($elements: any): void { + for (let i = 0; i < $elements.length; i++) { + super.load($elements[i], '.thumb[data-sizes]'); + } + } +} + +registerCraftGlobals({ElementThumbLoader}); +window.dispatchEvent(new Event('craft:element-thumb-loader-ready')); + +export {ElementThumbLoader}; diff --git a/resources/js/modules/element-thumb-loader/index.ts b/resources/js/modules/element-thumb-loader/index.ts index 58953224dba..19b8b09ea98 100644 --- a/resources/js/modules/element-thumb-loader/index.ts +++ b/resources/js/modules/element-thumb-loader/index.ts @@ -1,29 +1 @@ -import {ThumbnailLoader} from '@craftcms/ui/utilities/thumbnail-loader'; -import {registerCraftGlobals} from '@/common/craft-global'; - -/** - * Legacy `Craft.ElementThumbLoader` — a thin bridge over `@craftcms/ui`'s - * `ThumbnailLoader` (see the package's `components/thumbnail-loader/README.md`). - * - * Callers (`Craft.cp.elementThumbLoader`, element index views, slideouts, - * modals, previews) pass `load()` a jQuery collection and expect every element - * in it to be scanned for `.thumb[data-sizes]` descendants, so the override - * fans the collection out to the modern `load(root, selector)` signature. - * `Preview`/`LivePreview` call the static `Craft.ElementThumbLoader.retryAll()`, - * inherited from `ThumbnailLoader`. - * - * Already modern code (it imported `@craftcms/ui` even in the legacy bundle); - * this just relocates it into `resources/js`. - */ -class ElementThumbLoader extends ThumbnailLoader { - override load($elements: any): void { - for (let i = 0; i < $elements.length; i++) { - super.load($elements[i], '.thumb[data-sizes]'); - } - } -} - -registerCraftGlobals({ElementThumbLoader}); -window.dispatchEvent(new Event('craft:element-thumb-loader-ready')); - -export {ElementThumbLoader}; +export {ElementThumbLoader} from './globals'; diff --git a/resources/js/modules/entry-mover/globals.ts b/resources/js/modules/entry-mover/globals.ts new file mode 100644 index 00000000000..cf2e0b1ae00 --- /dev/null +++ b/resources/js/modules/entry-mover/globals.ts @@ -0,0 +1,7 @@ +import {EntryMover} from './entry-mover'; +import {registerCraftGlobals} from '@/common/craft-global'; + +// Assign the legacy `Craft` global: `new Craft.EntryMover(...)` is emitted by +// `MoveToSection.php`. Plain modern ES class — nothing subclasses it via the +// legacy `.extend()` API. +registerCraftGlobals({EntryMover}); diff --git a/resources/js/modules/entry-mover/index.ts b/resources/js/modules/entry-mover/index.ts index 630274ffc72..d6d01967efd 100644 --- a/resources/js/modules/entry-mover/index.ts +++ b/resources/js/modules/entry-mover/index.ts @@ -1,9 +1,4 @@ +import './globals'; import {EntryMover} from './entry-mover'; -import {registerCraftGlobals} from '@/common/craft-global'; - -// Assign the legacy `Craft` global: `new Craft.EntryMover(...)` is emitted by -// `MoveToSection.php`. Plain modern ES class — nothing subclasses it via the -// legacy `.extend()` API. -registerCraftGlobals({EntryMover}); export {EntryMover}; diff --git a/resources/js/modules/field-layout-designer/globals.ts b/resources/js/modules/field-layout-designer/globals.ts new file mode 100644 index 00000000000..2e6bf2d7074 --- /dev/null +++ b/resources/js/modules/field-layout-designer/globals.ts @@ -0,0 +1,22 @@ +import {FieldLayoutDesigner} from './field-layout-designer'; +import {Tab} from './tab'; +import {Element} from './element'; +import {CardViewDesigner} from './card-view-designer'; +import {BaseDrag, ElementDrag, TabDrag} from './drags'; +import '@/modules/grid'; +import {registerCraftGlobals} from '@/common/craft-global'; + +// Re-expose the sub-classes on the constructor, as the legacy bundle did +// (`Craft.FieldLayoutDesigner.Tab`, `.Element`, `.CardViewDesigner`, etc.). +Object.assign(FieldLayoutDesigner, { + Tab, + Element, + CardViewDesigner, + BaseDrag, + TabDrag, + ElementDrag, +}); + +// Assign onto the legacy `Craft` global (created by the cp bundle) so the +// PHP-emitted `new Craft.FieldLayoutDesigner("#id", settings)` keeps working. +registerCraftGlobals({FieldLayoutDesigner}); diff --git a/resources/js/modules/field-layout-designer/index.ts b/resources/js/modules/field-layout-designer/index.ts index 70b3b2bbe5d..c65c23b77ee 100644 --- a/resources/js/modules/field-layout-designer/index.ts +++ b/resources/js/modules/field-layout-designer/index.ts @@ -1,3 +1,4 @@ +import './globals'; import {FieldLayoutDesigner} from './field-layout-designer'; import {Tab} from './tab'; import {Element} from './element'; @@ -6,22 +7,6 @@ import {BaseDrag, ElementDrag, TabDrag} from './drags'; import '@/modules/grid'; import CraftFieldLayoutDesigner from '@/modules/field-layout-designer/field-layout-designer.ce'; import {defineElement} from '@/common/web-components'; -import {registerCraftGlobals} from '@/common/craft-global'; - -// Re-expose the sub-classes on the constructor, as the legacy bundle did -// (`Craft.FieldLayoutDesigner.Tab`, `.Element`, `.CardViewDesigner`, etc.). -Object.assign(FieldLayoutDesigner, { - Tab, - Element, - CardViewDesigner, - BaseDrag, - TabDrag, - ElementDrag, -}); - -// Assign onto the legacy `Craft` global (created by the cp bundle) so the -// PHP-emitted `new Craft.FieldLayoutDesigner("#id", settings)` keeps working. -registerCraftGlobals({FieldLayoutDesigner}); defineElement('craft-field-layout-designer', CraftFieldLayoutDesigner); diff --git a/resources/js/modules/field-toggle/globals.ts b/resources/js/modules/field-toggle/globals.ts new file mode 100644 index 00000000000..cd698eed994 --- /dev/null +++ b/resources/js/modules/field-toggle/globals.ts @@ -0,0 +1,9 @@ +import {FieldToggle} from './field-toggle'; +import {registerCraftGlobals} from '@/common/craft-global'; + +// Assign the legacy `Craft` global so the still-legacy `$.fn.fieldtoggle` jQuery +// plugin and the `Craft.initUiElements` `.fieldtoggle` sweep (both in the cp +// bundle), plus `new Craft.FieldToggle(...)` in UI.js / LinkField / +// ElementDeletionManager, keep working. Plain modern ES class — nothing +// subclasses it via legacy `.extend()`. +registerCraftGlobals({FieldToggle}); diff --git a/resources/js/modules/field-toggle/index.ts b/resources/js/modules/field-toggle/index.ts index 6bfdba352af..8cd1e0a8a3b 100644 --- a/resources/js/modules/field-toggle/index.ts +++ b/resources/js/modules/field-toggle/index.ts @@ -1,12 +1,5 @@ +import './globals'; import {FieldToggle} from './field-toggle'; import {fieldToggleData} from './support'; -import {registerCraftGlobals} from '@/common/craft-global'; - -// Assign the legacy `Craft` global so the still-legacy `$.fn.fieldtoggle` jQuery -// plugin and the `Craft.initUiElements` `.fieldtoggle` sweep (both in the cp -// bundle), plus `new Craft.FieldToggle(...)` in UI.js / LinkField / -// ElementDeletionManager, keep working. Plain modern ES class — nothing -// subclasses it via legacy `.extend()`. -registerCraftGlobals({FieldToggle}); export {FieldToggle, fieldToggleData}; diff --git a/resources/js/modules/form-observer/globals.ts b/resources/js/modules/form-observer/globals.ts new file mode 100644 index 00000000000..899322a95b1 --- /dev/null +++ b/resources/js/modules/form-observer/globals.ts @@ -0,0 +1,8 @@ +import {FormObserver} from './form-observer'; +import {registerCraftGlobals} from '@/common/craft-global'; + +// Assign the legacy `Craft` global: `new Craft.FormObserver(...)` is still +// called from the CP bundle by `Craft.ElementEditor` for draft autosave. +// Plain modern ES class — +// nothing subclasses it via legacy `.extend()`. +registerCraftGlobals({FormObserver}); diff --git a/resources/js/modules/form-observer/index.ts b/resources/js/modules/form-observer/index.ts index 7c799fc02da..88fecdaf884 100644 --- a/resources/js/modules/form-observer/index.ts +++ b/resources/js/modules/form-observer/index.ts @@ -1,10 +1,4 @@ +import './globals'; import {FormObserver} from './form-observer'; -import {registerCraftGlobals} from '@/common/craft-global'; - -// Assign the legacy `Craft` global: `new Craft.FormObserver(...)` is still -// called from the CP bundle by `Craft.ElementEditor` for draft autosave. -// Plain modern ES class — -// nothing subclasses it via legacy `.extend()`. -registerCraftGlobals({FormObserver}); export {FormObserver}; diff --git a/resources/js/modules/grid/globals.ts b/resources/js/modules/grid/globals.ts new file mode 100644 index 00000000000..e07012258ab --- /dev/null +++ b/resources/js/modules/grid/globals.ts @@ -0,0 +1,8 @@ +import {Grid} from './grid'; +import {registerCraftGlobals} from '@/common/craft-global'; + +// Assign the legacy `Craft` global: `new Craft.Grid(...)` and the `.grid` +// jQuery plugin both boot it (Dashboard, field-layout-designer, generic CP +// `.grid` elements). Plain modern ES class — nothing subclasses it via the +// legacy `.extend()` API. +registerCraftGlobals({Grid}); diff --git a/resources/js/modules/grid/index.ts b/resources/js/modules/grid/index.ts index 2d5dfdc9765..83a80752b36 100644 --- a/resources/js/modules/grid/index.ts +++ b/resources/js/modules/grid/index.ts @@ -1,10 +1,4 @@ +import './globals'; import {Grid} from './grid'; -import {registerCraftGlobals} from '@/common/craft-global'; - -// Assign the legacy `Craft` global: `new Craft.Grid(...)` and the `.grid` -// jQuery plugin both boot it (Dashboard, field-layout-designer, generic CP -// `.grid` elements). Plain modern ES class — nothing subclasses it via the -// legacy `.extend()` API. -registerCraftGlobals({Grid}); export {Grid}; diff --git a/resources/js/modules/grouped-entry-type-manager/compat.ts b/resources/js/modules/grouped-entry-type-manager/compat.ts new file mode 100644 index 00000000000..405f81c4381 --- /dev/null +++ b/resources/js/modules/grouped-entry-type-manager/compat.ts @@ -0,0 +1,13 @@ +import {Group, GroupedEntryTypeManager} from './grouped-entry-type-manager'; +import {registerCraftGlobals} from '@/common/craft-global'; + +// Re-expose the Group sub-class on the constructor, as the legacy bundle did +// (`Craft.GroupedEntryTypeManager.Group`). +Object.assign(GroupedEntryTypeManager, {Group}); + +// Core renders ``; the global is only for plugins +// that still boot `new Craft.GroupedEntryTypeManager(...)`. Nothing subclasses +// it via legacy `.extend()`, so the modern ES class is assigned directly. +// (`Craft.GroupedEntryTypeSelectInput` is gone without replacement: the +// cross-group select behavior now lives in the manager + `craft-component-select`.) +registerCraftGlobals({GroupedEntryTypeManager}); diff --git a/resources/js/modules/grouped-entry-type-manager/index.ts b/resources/js/modules/grouped-entry-type-manager/index.ts index ca67386f7bc..b8fe1bb9694 100644 --- a/resources/js/modules/grouped-entry-type-manager/index.ts +++ b/resources/js/modules/grouped-entry-type-manager/index.ts @@ -1,26 +1,8 @@ import {Group, GroupedEntryTypeManager} from './grouped-entry-type-manager'; import CraftEntryTypeManager from '@/modules/grouped-entry-type-manager/grouped-entry-type-manager.ce'; import {defineElement} from '@/common/web-components'; -import {registerCraftGlobals} from '@/common/craft-global'; import './grouped-entry-type-manager.css'; -// Re-expose the Group sub-class on the constructor, as the legacy bundle did -// (`Craft.GroupedEntryTypeManager.Group`). -Object.assign(GroupedEntryTypeManager, {Group}); - -// Assign onto the legacy `Craft` global so any remaining -// `new Craft.GroupedEntryTypeManager(...)` boots (plugins) keep working — the -// legacy GroupedEntryTypeManager.js/GroupedEntryTypeSelectInput.js are removed -// from the legacy bundle. Nothing subclasses it via legacy `.extend()`, so — -// like `SortableCheckboxSelect` — it needs no compat shim and is assigned as -// the modern ES class directly. (`Craft.GroupedEntryTypeSelectInput` is gone -// without replacement: the cross-group select behavior now lives in the -// manager + `craft-component-select`, not a select subclass.) -registerCraftGlobals({GroupedEntryTypeManager}); - defineElement('craft-entry-type-manager', CraftEntryTypeManager); export {GroupedEntryTypeManager, Group, CraftEntryTypeManager}; -export {attachChipMoveActions} from './grouped-entry-type-manager'; -export type {GroupedEntryTypeManagerSettings} from './grouped-entry-type-manager'; -export {groupedEntryTypeManagerData} from './support'; diff --git a/resources/js/modules/highlight/globals.ts b/resources/js/modules/highlight/globals.ts new file mode 100644 index 00000000000..5b51847f29b --- /dev/null +++ b/resources/js/modules/highlight/globals.ts @@ -0,0 +1,8 @@ +import {highlighter} from '@/common/utils/highlight-code'; +import {registerCraftGlobals} from '@/common/craft-global'; + +/** + * Plugins highlight code with `Craft.highlighter`, as in Craft 5: + * `Craft.highlighter.highlight(code, {lang: 'json'}).html`. + */ +registerCraftGlobals({highlighter}); diff --git a/resources/js/modules/highlight/index.ts b/resources/js/modules/highlight/index.ts index 1967eeb0c22..aa21bf08766 100644 --- a/resources/js/modules/highlight/index.ts +++ b/resources/js/modules/highlight/index.ts @@ -1,10 +1,4 @@ +import './globals'; import {highlighter} from '@/common/utils/highlight-code'; -import {registerCraftGlobals} from '@/common/craft-global'; - -/** - * Plugins highlight code with `Craft.highlighter`, as in Craft 5: - * `Craft.highlighter.highlight(code, {lang: 'json'}).html`. - */ -registerCraftGlobals({highlighter}); export {highlighter}; diff --git a/resources/js/modules/image-editor/compat.ts b/resources/js/modules/image-editor/compat.ts new file mode 100644 index 00000000000..c5c3a8bb42d --- /dev/null +++ b/resources/js/modules/image-editor/compat.ts @@ -0,0 +1,5 @@ +import {AssetImageEditor} from './open-asset-image-editor'; +import {registerCraftGlobals} from '@/common/craft-global'; + +// The legacy editor's name, kept for plugins. +registerCraftGlobals({AssetImageEditor}); diff --git a/resources/js/modules/image-editor/globals.ts b/resources/js/modules/image-editor/globals.ts new file mode 100644 index 00000000000..b0dd93d09ad --- /dev/null +++ b/resources/js/modules/image-editor/globals.ts @@ -0,0 +1,8 @@ +import {openAssetImageEditor} from './open-asset-image-editor'; +import {registerCraftGlobals} from '@/common/craft-global'; + +/** + * `Craft.openAssetImageEditor` is how markup that isn't an Inertia page opens + * the image editor dialog. + */ +registerCraftGlobals({openAssetImageEditor}); diff --git a/resources/js/modules/image-editor/index.ts b/resources/js/modules/image-editor/index.ts index 1d267138e75..5110ce49b4c 100644 --- a/resources/js/modules/image-editor/index.ts +++ b/resources/js/modules/image-editor/index.ts @@ -1,15 +1,4 @@ -import {registerCraftGlobals} from '@/common/craft-global'; -import { - AssetImageEditor, - openAssetImageEditor, -} from './open-asset-image-editor'; - -/** - * `Craft.openAssetImageEditor` is how markup that isn't an Inertia page opens - * the image editor dialog. `Craft.AssetImageEditor` is the legacy editor's - * name, kept for plugins. - */ -registerCraftGlobals({openAssetImageEditor, AssetImageEditor}); +import './globals'; export { AssetImageEditor, diff --git a/resources/js/modules/input-generators/globals.ts b/resources/js/modules/input-generators/globals.ts new file mode 100644 index 00000000000..6900b07564b --- /dev/null +++ b/resources/js/modules/input-generators/globals.ts @@ -0,0 +1,20 @@ +import {BaseInputGenerator} from './base-input-generator'; +import {HandleGenerator} from './handle-generator'; +import {SlugGenerator} from './slug-generator'; +import {UriFormatGenerator} from './uri-format-generator'; +import {DynamicGenerator} from './dynamic-generator'; +import {registerCraftGlobals} from '@/common/craft-global'; + +// Assign onto the legacy `Craft` global so Twig-emitted and plugin +// `new Craft.HandleGenerator('#name', '#handle')` boots — and the modern +// editable-table / generated-fields consumers — keep working. These are plain +// modern ES classes (no `compatify`): nothing in core subclasses them via legacy +// `.extend()`, and plugins that need custom generation use the callback-based +// `Craft.DynamicGenerator`. +registerCraftGlobals({ + BaseInputGenerator, + HandleGenerator, + SlugGenerator, + UriFormatGenerator, + DynamicGenerator, +}); diff --git a/resources/js/modules/input-generators/index.ts b/resources/js/modules/input-generators/index.ts index bcab2398933..ac9ea0b25df 100644 --- a/resources/js/modules/input-generators/index.ts +++ b/resources/js/modules/input-generators/index.ts @@ -1,23 +1,9 @@ +import './globals'; import {BaseInputGenerator} from './base-input-generator'; import {HandleGenerator} from './handle-generator'; import {SlugGenerator} from './slug-generator'; import {UriFormatGenerator} from './uri-format-generator'; import {DynamicGenerator} from './dynamic-generator'; -import {registerCraftGlobals} from '@/common/craft-global'; - -// Assign onto the legacy `Craft` global so Twig-emitted and plugin -// `new Craft.HandleGenerator('#name', '#handle')` boots — and the modern -// editable-table / generated-fields consumers — keep working. These are plain -// modern ES classes (no `compatify`): nothing in core subclasses them via legacy -// `.extend()`, and plugins that need custom generation use the callback-based -// `Craft.DynamicGenerator`. -registerCraftGlobals({ - BaseInputGenerator, - HandleGenerator, - SlugGenerator, - UriFormatGenerator, - DynamicGenerator, -}); export { BaseInputGenerator, diff --git a/resources/js/modules/interval-manager/globals.ts b/resources/js/modules/interval-manager/globals.ts new file mode 100644 index 00000000000..5a1accc3bcb --- /dev/null +++ b/resources/js/modules/interval-manager/globals.ts @@ -0,0 +1,7 @@ +import {IntervalManager} from './interval-manager'; +import {registerCraftGlobals} from '@/common/craft-global'; + +// Assign the legacy `Craft` global: `new Craft.IntervalManager(...)` is still +// called from the cp bundle by `Craft.ProgressBar`. Plain modern ES class — +// nothing subclasses it via legacy `.extend()`. +registerCraftGlobals({IntervalManager}); diff --git a/resources/js/modules/interval-manager/index.ts b/resources/js/modules/interval-manager/index.ts index 6ce568a6a68..6130519670e 100644 --- a/resources/js/modules/interval-manager/index.ts +++ b/resources/js/modules/interval-manager/index.ts @@ -1,9 +1,4 @@ +import './globals'; import {IntervalManager} from './interval-manager'; -import {registerCraftGlobals} from '@/common/craft-global'; - -// Assign the legacy `Craft` global: `new Craft.IntervalManager(...)` is still -// called from the cp bundle by `Craft.ProgressBar`. Plain modern ES class — -// nothing subclasses it via legacy `.extend()`. -registerCraftGlobals({IntervalManager}); export {IntervalManager}; diff --git a/resources/js/modules/link-field/globals.ts b/resources/js/modules/link-field/globals.ts new file mode 100644 index 00000000000..0eb0c15468e --- /dev/null +++ b/resources/js/modules/link-field/globals.ts @@ -0,0 +1,8 @@ +import {LinkField} from './link-field'; +import {LinkInput} from './link-input'; +import {registerCraftGlobals} from '@/common/craft-global'; + +// Assign the legacy `Craft` globals: `new Craft.LinkField(...)` is emitted by +// `Link.php` and `new Craft.LinkInput(...)` by `BaseTextLinkType.php`. Plain +// modern ES classes — nothing subclasses them via the legacy `.extend()` API. +registerCraftGlobals({LinkField, LinkInput}); diff --git a/resources/js/modules/link-field/index.ts b/resources/js/modules/link-field/index.ts index 31e89e4d581..cd93106b55b 100644 --- a/resources/js/modules/link-field/index.ts +++ b/resources/js/modules/link-field/index.ts @@ -1,10 +1,5 @@ +import './globals'; import {LinkField} from './link-field'; import {LinkInput} from './link-input'; -import {registerCraftGlobals} from '@/common/craft-global'; - -// Assign the legacy `Craft` globals: `new Craft.LinkField(...)` is emitted by -// `Link.php` and `new Craft.LinkInput(...)` by `BaseTextLinkType.php`. Plain -// modern ES classes — nothing subclasses them via the legacy `.extend()` API. -registerCraftGlobals({LinkField, LinkInput}); export {LinkField, LinkInput}; diff --git a/resources/js/modules/listbox/globals.ts b/resources/js/modules/listbox/globals.ts new file mode 100644 index 00000000000..2bd41c7f290 --- /dev/null +++ b/resources/js/modules/listbox/globals.ts @@ -0,0 +1,38 @@ +import {Listbox, type ListboxSettings} from './listbox'; +import {jq} from '@/common/utils/jquery'; +import {registerCraftGlobals} from '@/common/craft-global'; + +/** + * Legacy programmatic callers (`CustomizeSourcesModal`, `BaseElementIndex`, + * `Preview`) construct `new Craft.Listbox($container, {…})` + * and expect their `onChange` to receive a **jQuery-wrapped** option (they call + * `$selectedOption.data('mode')`, `.data('value')`, `.switchDeviceType($opt)`, + * etc.). The modern {@link Listbox} hands `onChange` a RAW element, so the + * global is a thin subclass that re-wraps the option with jQuery at + * `setSettings` time before delegating to the user's callback. + * + * The `` custom element uses the bare {@link Listbox} directly + * and works with raw elements — it does NOT go through this jQuery-wrapping + * global. + */ +class CraftListboxGlobal extends Listbox { + // legacy callers expect onChange to receive a jQuery-wrapped option + override setSettings( + settings?: Partial, + defaults?: Partial + ): void { + const $ = jq(); + if ($ && settings && settings.onChange instanceof Function) { + const original: Function = settings.onChange; + settings = { + ...settings, + onChange: (option, index) => original.call(this, $(option), index), + }; + } + super.setSettings(settings, defaults); + } +} + +// Assign onto the legacy `Craft` global so the PHP-emitted +// `new Craft.Listbox($container, {…})` keeps working. +registerCraftGlobals({Listbox: CraftListboxGlobal}); diff --git a/resources/js/modules/listbox/index.ts b/resources/js/modules/listbox/index.ts index 2af8a5413be..24104dd0941 100644 --- a/resources/js/modules/listbox/index.ts +++ b/resources/js/modules/listbox/index.ts @@ -1,43 +1,7 @@ -import {Listbox, type ListboxSettings} from './listbox'; +import './globals'; +import {Listbox} from './listbox'; import CraftListbox from '@/modules/listbox/listbox.ce'; import {defineElement} from '@/common/web-components'; -import {jq} from '@/common/utils/jquery'; -import {registerCraftGlobals} from '@/common/craft-global'; - -/** - * Legacy programmatic callers (`CustomizeSourcesModal`, `BaseElementIndex`, - * `Preview`) construct `new Craft.Listbox($container, {…})` - * and expect their `onChange` to receive a **jQuery-wrapped** option (they call - * `$selectedOption.data('mode')`, `.data('value')`, `.switchDeviceType($opt)`, - * etc.). The modern {@link Listbox} hands `onChange` a RAW element, so the - * global is a thin subclass that re-wraps the option with jQuery at - * `setSettings` time before delegating to the user's callback. - * - * The `` custom element uses the bare {@link Listbox} directly - * and works with raw elements — it does NOT go through this jQuery-wrapping - * global. - */ -class CraftListboxGlobal extends Listbox { - // legacy callers expect onChange to receive a jQuery-wrapped option - override setSettings( - settings?: Partial, - defaults?: Partial - ): void { - const $ = jq(); - if ($ && settings && settings.onChange instanceof Function) { - const original: Function = settings.onChange; - settings = { - ...settings, - onChange: (option, index) => original.call(this, $(option), index), - }; - } - super.setSettings(settings, defaults); - } -} - -// Assign onto the legacy `Craft` global so the PHP-emitted -// `new Craft.Listbox($container, {…})` keeps working. -registerCraftGlobals({Listbox: CraftListboxGlobal}); defineElement('craft-listbox', CraftListbox); diff --git a/resources/js/modules/preview-file-modal/globals.ts b/resources/js/modules/preview-file-modal/globals.ts new file mode 100644 index 00000000000..b60e7b4fc5c --- /dev/null +++ b/resources/js/modules/preview-file-modal/globals.ts @@ -0,0 +1,6 @@ +import {PreviewFileModal} from './preview-file-modal'; +import {registerCraftGlobals} from '@/common/craft-global'; + +// Assign the legacy `Craft.PreviewFileModal` global so PHP-emitted code and the +// still-legacy cp bundle keep working. +registerCraftGlobals({PreviewFileModal}); diff --git a/resources/js/modules/preview-file-modal/index.ts b/resources/js/modules/preview-file-modal/index.ts index da9ead85d07..53f49aa674b 100644 --- a/resources/js/modules/preview-file-modal/index.ts +++ b/resources/js/modules/preview-file-modal/index.ts @@ -1,8 +1,4 @@ -import {registerCraftGlobals} from '@/common/craft-global'; +import './globals'; import {PreviewFileModal} from './preview-file-modal'; -// Assign the legacy `Craft.PreviewFileModal` global so PHP-emitted code and the -// still-legacy cp bundle keep working. -registerCraftGlobals({PreviewFileModal}); - export {PreviewFileModal}; diff --git a/resources/js/modules/prompt-handler/globals.ts b/resources/js/modules/prompt-handler/globals.ts new file mode 100644 index 00000000000..dfb9eaf2181 --- /dev/null +++ b/resources/js/modules/prompt-handler/globals.ts @@ -0,0 +1,7 @@ +import {PromptHandler} from './prompt-handler'; +import {registerCraftGlobals} from '@/common/craft-global'; + +// Assign the legacy `Craft` global: `new Craft.PromptHandler()` is created by +// `Craft.AssetIndex` in the cp bundle. Plain modern ES class — nothing +// subclasses it via the legacy `.extend()` API. +registerCraftGlobals({PromptHandler}); diff --git a/resources/js/modules/prompt-handler/index.ts b/resources/js/modules/prompt-handler/index.ts index c6bab08bf81..f1ff64076c3 100644 --- a/resources/js/modules/prompt-handler/index.ts +++ b/resources/js/modules/prompt-handler/index.ts @@ -1,9 +1,4 @@ +import './globals'; import {PromptHandler} from './prompt-handler'; -import {registerCraftGlobals} from '@/common/craft-global'; - -// Assign the legacy `Craft` global: `new Craft.PromptHandler()` is created by -// `Craft.AssetIndex` in the cp bundle. Plain modern ES class — nothing -// subclasses it via the legacy `.extend()` API. -registerCraftGlobals({PromptHandler}); export {PromptHandler}; diff --git a/resources/js/modules/queue/globals.ts b/resources/js/modules/queue/globals.ts new file mode 100644 index 00000000000..cf7d912ad42 --- /dev/null +++ b/resources/js/modules/queue/globals.ts @@ -0,0 +1,14 @@ +import {QueueService} from './queue'; +import {registerCraftGlobals} from '@/common/craft-global'; + +/** + * Legacy and plugin code reaches the queue through the `Craft` global rather + * than an ES module import, so expose the service class there + * (`Craft.QueueService.getInstance()`). + * + * Note that the legacy webpack bundle's `CP.js` cannot use this global — it + * needs the service while its own bundle is still evaluating, before Vite's + * module scripts have run — so it imports `./queue` directly and bundles its + * own copy. + */ +registerCraftGlobals({QueueService}); diff --git a/resources/js/modules/queue/index.ts b/resources/js/modules/queue/index.ts index 70abeab8c2d..6c7423bd8a2 100644 --- a/resources/js/modules/queue/index.ts +++ b/resources/js/modules/queue/index.ts @@ -1,17 +1,5 @@ +import './globals'; import {QueueService} from './queue'; -import {registerCraftGlobals} from '@/common/craft-global'; - -/** - * Legacy and plugin code reaches the queue through the `Craft` global rather - * than an ES module import, so expose the service class there - * (`Craft.QueueService.getInstance()`). - * - * Note that the legacy webpack bundle's `CP.js` cannot use this global — it - * needs the service while its own bundle is still evaluating, before Vite's - * module scripts have run — so it imports `./queue` directly and bundles its - * own copy. - */ -registerCraftGlobals({QueueService}); export {QueueService}; export * from './types'; diff --git a/resources/js/modules/slideout/globals.ts b/resources/js/modules/slideout/globals.ts new file mode 100644 index 00000000000..2a179a48060 --- /dev/null +++ b/resources/js/modules/slideout/globals.ts @@ -0,0 +1,50 @@ +import {compatify} from '@craftcms/garnish/compat'; +import {Slideout} from './slideout'; +import {CpScreenSlideout} from './cp-screen-slideout'; +import {ElementEditorSlideout} from './element-editor-slideout'; +import {registerCraftGlobals} from '@/common/craft-global'; + +// `compatify`, not the plain class: plugins subclass this global via the +// legacy `Garnish.Base.extend()` API, whose `this.base()` calls must +// dispatch into the modern methods. +const CompatSlideout = compatify(Slideout); + +// Mirror the static API onto the compatified constructor. All of these are +// shared references: `defaults` may be mutated directly by consumers, and +// `instances`/`openPanels` are kept live by `removePanel`/`destroy` mutating +// them in place rather than reassigning. The static methods close over +// `Slideout` (the modern class), not `this`, so no `.bind()` is needed. +Object.assign(CompatSlideout, { + defaults: Slideout.defaults, + instances: Slideout.instances, + openPanels: Slideout.openPanels, + positionProp: Slideout.positionProp, + totalPanels: Slideout.totalPanels, + addPanel: Slideout.addPanel, + removePanel: Slideout.removePanel, + updateStyles: Slideout.updateStyles, +}); + +registerCraftGlobals({Slideout: CompatSlideout}); + +// Same reasoning one level down: plugins subclass CP screen slideouts via +// the legacy `.extend()` API. +const CompatCpScreenSlideout = compatify(CpScreenSlideout); + +// `Craft.ElementEditorSlideout.defaults` is a separate object built by that +// file's own `.extend()` call; only this one needs mirroring here. +Object.assign(CompatCpScreenSlideout, { + defaults: CpScreenSlideout.defaults, +}); + +registerCraftGlobals({CpScreenSlideout: CompatCpScreenSlideout}); + +// And once more for element editors — nothing in core `.extend()`s this +// anymore, but it stays compatified so plugin subclasses keep working. +const CompatElementEditorSlideout = compatify(ElementEditorSlideout); + +Object.assign(CompatElementEditorSlideout, { + defaults: ElementEditorSlideout.defaults, +}); + +registerCraftGlobals({ElementEditorSlideout: CompatElementEditorSlideout}); diff --git a/resources/js/modules/slideout/index.ts b/resources/js/modules/slideout/index.ts index e4317d42bf8..25b472f5cff 100644 --- a/resources/js/modules/slideout/index.ts +++ b/resources/js/modules/slideout/index.ts @@ -1,4 +1,4 @@ -import {compatify} from '@craftcms/garnish/compat'; +import './globals'; import {Slideout, type SlideoutSettings} from './slideout'; import { CpScreenSlideout, @@ -9,60 +9,13 @@ import { type ElementEditorSlideoutSettings, } from './element-editor-slideout'; import {containerSlideouts} from './support'; -import {registerCraftGlobals} from '@/common/craft-global'; /** * No custom element / `.ce.ts` here: slideouts are always constructed * programmatically around dynamic content — there's no server-rendered - * markup for a custom element to boot from. This file's job is the - * `Craft.*` globals and the class-level open/close hook. + * markup for a custom element to boot from. */ -// `compatify`, not the plain class: plugins subclass this global via the -// legacy `Garnish.Base.extend()` API, whose `this.base()` calls must -// dispatch into the modern methods. -const CompatSlideout = compatify(Slideout); - -// Mirror the static API onto the compatified constructor. All of these are -// shared references: `defaults` may be mutated directly by consumers, and -// `instances`/`openPanels` are kept live by `removePanel`/`destroy` mutating -// them in place rather than reassigning. The static methods close over -// `Slideout` (the modern class), not `this`, so no `.bind()` is needed. -Object.assign(CompatSlideout, { - defaults: Slideout.defaults, - instances: Slideout.instances, - openPanels: Slideout.openPanels, - positionProp: Slideout.positionProp, - totalPanels: Slideout.totalPanels, - addPanel: Slideout.addPanel, - removePanel: Slideout.removePanel, - updateStyles: Slideout.updateStyles, -}); - -registerCraftGlobals({Slideout: CompatSlideout}); - -// Same reasoning one level down: plugins subclass CP screen slideouts via -// the legacy `.extend()` API. -const CompatCpScreenSlideout = compatify(CpScreenSlideout); - -// `Craft.ElementEditorSlideout.defaults` is a separate object built by that -// file's own `.extend()` call; only this one needs mirroring here. -Object.assign(CompatCpScreenSlideout, { - defaults: CpScreenSlideout.defaults, -}); - -registerCraftGlobals({CpScreenSlideout: CompatCpScreenSlideout}); - -// And once more for element editors — nothing in core `.extend()`s this -// anymore, but it stays compatified so plugin subclasses keep working. -const CompatElementEditorSlideout = compatify(ElementEditorSlideout); - -Object.assign(CompatElementEditorSlideout, { - defaults: ElementEditorSlideout.defaults, -}); - -registerCraftGlobals({ElementEditorSlideout: CompatElementEditorSlideout}); - // Repositioning open HUDs when a slideout opens or closes used to be hooked // up here, via `Garnish.on(Slideout, 'open close')`. It now lives in the // shared panel stack, so Vue slideouts trigger it too. diff --git a/resources/js/modules/sortable-checkbox-select/compat.ts b/resources/js/modules/sortable-checkbox-select/compat.ts new file mode 100644 index 00000000000..b0ff4532237 --- /dev/null +++ b/resources/js/modules/sortable-checkbox-select/compat.ts @@ -0,0 +1,4 @@ +import {SortableCheckboxSelect} from './sortable-checkbox-select'; +import {registerCraftGlobals} from '@/common/craft-global'; + +registerCraftGlobals({SortableCheckboxSelect}); diff --git a/resources/js/modules/sortable-checkbox-select/index.ts b/resources/js/modules/sortable-checkbox-select/index.ts index fbcbd30f631..9db6c3811c5 100644 --- a/resources/js/modules/sortable-checkbox-select/index.ts +++ b/resources/js/modules/sortable-checkbox-select/index.ts @@ -1,61 +1,3 @@ -import {Base} from '@craftcms/garnish'; import '@craftcms/ui/components/checkbox-select/checkbox-select'; -import type CraftCheckboxSelect from '@craftcms/ui/components/checkbox-select/checkbox-select'; -import {registerCraftGlobals} from '@/common/craft-global'; -declare const $: any; - -/** - * Turns a legacy `.cp-checkbox-select` container into a - * ``, keeping its attributes and items. - */ -function upgrade(container: HTMLElement): CraftCheckboxSelect { - if (container.localName === 'craft-checkbox-select') { - // SAFETY: Checked by tag name above. - return container as CraftCheckboxSelect; - } - - const element = document.createElement('craft-checkbox-select'); - for (const {name, value} of Array.from(container.attributes)) { - element.setAttribute(name, value); - } - element.append(...Array.from(container.childNodes)); - container.replaceWith(element); - - return element; -} - -/** - * @deprecated in 6.0.0. Use ``, which fires `craft-order-change`. - * - * Kept for code that still calls `new Craft.SortableCheckboxSelect($container)`: - * it upgrades the container to the element, and re-triggers its moves as - * `sortChange`. - */ -export class SortableCheckboxSelect extends Base { - $container: any; - element: CraftCheckboxSelect; - - constructor(container: any) { - super(); - this.element = upgrade($(container)[0]); - this.element.sortable = true; - this.element.presentation = 'add-menu'; - this.element.addEventListener('craft-order-change', this.#onOrderChange); - this.$container = $(this.element); - this.$container.data('sortableCheckboxSelect', this); - } - - #onOrderChange = (): void => { - this.trigger('sortChange'); - }; - - override destroy(): void { - this.element.removeEventListener('craft-order-change', this.#onOrderChange); - this.$container.removeData('sortableCheckboxSelect'); - super.destroy(); - } -} - -registerCraftGlobals({SortableCheckboxSelect}); +export {SortableCheckboxSelect} from './sortable-checkbox-select'; diff --git a/resources/js/modules/sortable-checkbox-select/sortable-checkbox-select.ts b/resources/js/modules/sortable-checkbox-select/sortable-checkbox-select.ts new file mode 100644 index 00000000000..18638b1ca4f --- /dev/null +++ b/resources/js/modules/sortable-checkbox-select/sortable-checkbox-select.ts @@ -0,0 +1,57 @@ +import {Base} from '@craftcms/garnish'; +import type CraftCheckboxSelect from '@craftcms/ui/components/checkbox-select/checkbox-select'; + +declare const $: any; + +/** + * Turns a legacy `.cp-checkbox-select` container into a + * ``, keeping its attributes and items. + */ +function upgrade(container: HTMLElement): CraftCheckboxSelect { + if (container.localName === 'craft-checkbox-select') { + // SAFETY: Checked by tag name above. + return container as CraftCheckboxSelect; + } + + const element = document.createElement('craft-checkbox-select'); + for (const {name, value} of Array.from(container.attributes)) { + element.setAttribute(name, value); + } + element.append(...Array.from(container.childNodes)); + container.replaceWith(element); + + return element; +} + +/** + * @deprecated in 6.0.0. Use ``, which fires `craft-order-change`. + * + * Kept for code that still calls `new Craft.SortableCheckboxSelect($container)`: + * it upgrades the container to the element, and re-triggers its moves as + * `sortChange`. + */ +export class SortableCheckboxSelect extends Base { + $container: any; + element: CraftCheckboxSelect; + + constructor(container: any) { + super(); + this.element = upgrade($(container)[0]); + this.element.sortable = true; + this.element.presentation = 'add-menu'; + this.element.addEventListener('craft-order-change', this.#onOrderChange); + this.$container = $(this.element); + this.$container.data('sortableCheckboxSelect', this); + } + + #onOrderChange = (): void => { + this.trigger('sortChange'); + }; + + override destroy(): void { + this.element.removeEventListener('craft-order-change', this.#onOrderChange); + this.$container.removeData('sortableCheckboxSelect'); + super.destroy(); + } +} diff --git a/resources/js/modules/tabs/globals.ts b/resources/js/modules/tabs/globals.ts new file mode 100644 index 00000000000..2eb1401eb29 --- /dev/null +++ b/resources/js/modules/tabs/globals.ts @@ -0,0 +1,7 @@ +import {Tabs} from './tabs'; +import {registerCraftGlobals} from '@/common/craft-global'; + +// Assign the legacy `Craft` global: `new Craft.Tabs(...)` is created by CP, +// Preview, cp-screen-slideout, and matrix-entry. Plain modern ES class — +// nothing subclasses it via the legacy `.extend()` API. +registerCraftGlobals({Tabs}); diff --git a/resources/js/modules/tabs/index.ts b/resources/js/modules/tabs/index.ts index f8d0a88ad74..1d28338dc40 100644 --- a/resources/js/modules/tabs/index.ts +++ b/resources/js/modules/tabs/index.ts @@ -1,9 +1,4 @@ +import './globals'; import {Tabs} from './tabs'; -import {registerCraftGlobals} from '@/common/craft-global'; - -// Assign the legacy `Craft` global: `new Craft.Tabs(...)` is created by CP, -// Preview, cp-screen-slideout, and matrix-entry. Plain modern ES class — -// nothing subclasses it via the legacy `.extend()` API. -registerCraftGlobals({Tabs}); export {Tabs}; diff --git a/resources/js/modules/uploader/globals.ts b/resources/js/modules/uploader/globals.ts new file mode 100644 index 00000000000..f5cec3cf02b --- /dev/null +++ b/resources/js/modules/uploader/globals.ts @@ -0,0 +1,6 @@ +import {BaseUploader} from './base-uploader'; +import {Uploader} from './uploader'; +import {registerCraftGlobals} from '@/common/craft-global'; + +const Uploaders = {BaseUploader, Uploader}; +registerCraftGlobals({Uploaders}); diff --git a/resources/js/modules/uploader/index.ts b/resources/js/modules/uploader/index.ts index 9828263cec1..cfc6058c5bd 100644 --- a/resources/js/modules/uploader/index.ts +++ b/resources/js/modules/uploader/index.ts @@ -1,9 +1,6 @@ import '@/uploads'; +import './globals'; import {BaseUploader} from './base-uploader'; import {Uploader} from './uploader'; -import {registerCraftGlobals} from '@/common/craft-global'; - -const Uploaders = {BaseUploader, Uploader}; -registerCraftGlobals({Uploaders}); export {BaseUploader, Uploader}; diff --git a/yii2-adapter/resources/js/cp-compat.ts b/yii2-adapter/resources/js/cp-compat.ts index 4de3407e5dc..e3ec41ceeec 100644 --- a/yii2-adapter/resources/js/cp-compat.ts +++ b/yii2-adapter/resources/js/cp-compat.ts @@ -3,3 +3,8 @@ import './matrix/field-actions'; import './nested-element-manager'; import './element-editor'; import './native-field-refresh'; +import '@/modules/auth-method-setup/compat'; +import '@/modules/element-select-input/compat'; +import '@/modules/grouped-entry-type-manager/compat'; +import '@/modules/image-editor/compat'; +import '@/modules/sortable-checkbox-select/compat'; From cc0968ceee2f2018249fc581ca66f9a17923c3e3 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Wed, 28 Oct 2026 16:43:25 -0500 Subject: [PATCH 04/51] Publish Craft 6 control panel JS APIs from @craftcms/cp Uploads, highlighter, AuthChallengeForm, createElementSelectorModal and the element selector controller registry are now exports of the @craftcms/cp import-map module rather than Craft.* globals, so control panel plugin code imports them. The Yii2 adapter keeps Craft.createElementSelectorModal and Craft.highlighter for Craft 5 plugins. Pages outside the control panel still get Craft.Uploads from the standalone uploads entry, since they have no import map. SlideoutPanel no longer assumes window.Craft exists; it was relying on a module that happened to create it. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG-WIP.md | 1 + docs/uploads.md | 12 ++++---- .../js/common/slideouts/SlideoutPanel.vue | 4 +-- resources/js/cp-module.ts | 28 +++++++++++++++++++ resources/js/cp.ts | 1 - .../auth/components/auth-challenge-form.ts | 10 +------ .../modules/element-selector-modal/compat.ts | 6 ++++ .../modules/element-selector-modal/globals.ts | 27 ++---------------- .../element-selector-modal/index.test.ts | 17 ++++++++--- .../highlight/{globals.ts => compat.ts} | 2 +- resources/js/modules/highlight/index.ts | 4 --- resources/js/modules/uploader/index.ts | 1 - resources/js/uploads.ts | 2 ++ yii2-adapter/resources/js/cp-compat.ts | 2 ++ 14 files changed, 66 insertions(+), 51 deletions(-) create mode 100644 resources/js/modules/element-selector-modal/compat.ts rename resources/js/modules/highlight/{globals.ts => compat.ts} (74%) delete mode 100644 resources/js/modules/highlight/index.ts diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index c472b19fa5e..8d61a681696 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -747,6 +747,7 @@ Craft 6 now uses [Laravel's authorization system](https://laravel.com/docs/12.x/ - Removed the `Craft.ImageUpload` control panel JavaScript class. - `Craft.LivePreview` is now provided by the Yii2 adapter. - `Craft.AssetImageEditor`, `Craft.GroupedEntryTypeManager`, `Craft.RecoveryCodesSetup`, `Craft.SortableCheckboxSelect`, and `Craft.TagSelectInput` are now provided by the Yii2 adapter. +- Added `AuthChallengeForm`, `AssetSelectorController`, `ElementSelectorController`, `Uploads`, `createElementSelectorModal`, `highlighter`, and `registerElementSelectorController` to the `@craftcms/cp` import-map module. Control panel plugin code should import them from there; they’re no longer `Craft.*` globals, except `Craft.createElementSelectorModal` and `Craft.highlighter`, which the Yii2 adapter still provides. - Deprecated the `Craft.LightSwitch`, `Craft.InfoIcon`, `Craft.ColorInput`, `Craft.PasswordInput`, `Craft.IconPicker`, `Craft.SlidePicker`, `Craft.SlideRuleInput`, and `Craft.Tooltip` control panel JavaScript classes, along with the `.infoicon` jQuery plugin. The corresponding `@craftcms/ui` web components should be used instead. - Deprecated the `Craft.SortableCheckboxSelect` control panel JavaScript class. `` should be used instead. ([#20036](https://github.com/craftcms/cms/pull/20036)) diff --git a/docs/uploads.md b/docs/uploads.md index 81e68e449ed..b86727540d0 100644 --- a/docs/uploads.md +++ b/docs/uploads.md @@ -139,10 +139,12 @@ The contract has five methods: - `complete()` verifies the stored bytes and returns an `UploadedFile` on the temporary disk. It must tolerate retries after storage completion. - `abort()` removes incomplete transfers and completed temporary objects. It must tolerate retries. -Register the matching frontend transport in your plugin’s JavaScript, after the uploads entry has loaded. A plugin with a fixed endpoint only needs setup: +Register the matching frontend transport in your plugin’s JavaScript. In the control panel, import `Uploads` from `@craftcms/cp`. A plugin with a fixed endpoint only needs setup: ```js -Craft.Uploads.registerTransport('cloud', (uppy) => { +import {Uploads} from '@craftcms/cp'; + +Uploads.registerTransport('cloud', (uppy) => { uppy.use(CloudUploadPlugin, {endpoint: '/cloud/upload'}); }); ``` @@ -150,7 +152,7 @@ Craft.Uploads.registerTransport('cloud', (uppy) => { If configuration varies per file, return a preparation callback: ```js -Craft.Uploads.registerTransport('cloud', (uppy) => { +Uploads.registerTransport('cloud', (uppy) => { uppy.use(CloudUploadPlugin); return (fileId, {session}) => { @@ -179,7 +181,7 @@ The registration callback configures one shared Uppy instance per transport. It The request helper handles cancellation and normalizes backend errors; it only accepts same-origin URLs. Call `beginCompletion()` before an irreversible storage commit. Custom plugins advertise pause/resume support through Uppy’s `capabilities.resumableUploads` state and handle its per-file pause/resume events. Use the Uppy plugin lifecycle for transfer progress, failure, retry, and cancellation; Craft retains session creation and finalization. Uppy owns concurrency; queued files remain in the uploading state. Store session settings per file, since files with different destinations and headers share the instance. -`registerTransport` is also exported from `resources/js/uploads.ts`. The control panel exposes `Craft.Uploads` automatically; standalone pages must load the uploads entry. An existing registration can be replaced before its first use. Attempting to replace it after its Uppy instance has been created throws an error. An unregistered name fails explicitly. +Pages outside the control panel don’t have `@craftcms/cp`. They load the `resources/js/uploads.ts` entry and use `Craft.Uploads.registerTransport()` instead. An existing registration can be replaced before its first use. Attempting to replace it after its Uppy instance has been created throws an error. An unregistered name fails explicitly. The shared transfer endpoint loads and locks the session, verifies ownership and expiry, and reauthorizes the destination before calling `handleRequest()`. It persists the uploader’s session changes afterward. DELETE requests are reserved for cleanup: ownership is still checked, but expiry and destination authorization are skipped. Uploaders must reject non-cleanup operations sent with DELETE. The S3 transport uses DELETE to request an abort signature and POST for other signatures. Frontend request helpers should pass `{signal: null}` for cleanup after cancellation. @@ -189,7 +191,7 @@ Uploaders are cached by name. Keep upload-specific state on the session and reso Craft handles session ownership and expiry; Uppy handles transfer retries and progress. The built-in tus endpoint supports HEAD, PATCH, termination, and expiration. Craft creates sessions through its JSON endpoints, so the tus creation extension is not exposed. The selected handler authorizes the destination and processes the completed file. The control panel exposes its native picker through `Craft.Uploaders.Uploader`. Core modules import `Uploader` from `resources/js/modules/uploader/uploader.ts`. Its constructor receives an `HTMLElement` and plain `UploaderSettings`; file inputs, drop zones, and paste zones accept native elements or arrays of elements. Completion and progress use the `on` callbacks. -Legacy uploader subclasses, filesystem-specific picker overrides, and jQuery upload events are no longer supported. Integrations customize file transfers through the backend uploader contract and `Craft.Uploads.registerTransport()`. +Legacy uploader subclasses, filesystem-specific picker overrides, and jQuery upload events are no longer supported. Integrations customize file transfers through the backend uploader contract and `Uploads.registerTransport()`. ## Migrating existing integrations diff --git a/resources/js/common/slideouts/SlideoutPanel.vue b/resources/js/common/slideouts/SlideoutPanel.vue index e04cc83b7e4..33ea77bf17c 100644 --- a/resources/js/common/slideouts/SlideoutPanel.vue +++ b/resources/js/common/slideouts/SlideoutPanel.vue @@ -81,8 +81,8 @@ * panels slide in from, so `Craft.slideoutPosition` flips it. */ const positionProp = computed(() => - Object.getOwnPropertyDescriptor(window.Craft, 'slideoutPosition')?.value === - 'start' + Object.getOwnPropertyDescriptor(window.Craft ?? {}, 'slideoutPosition') + ?.value === 'start' ? 'inset-inline-end' : 'inset-inline-start' ); diff --git a/resources/js/cp-module.ts b/resources/js/cp-module.ts index e9890a2de7d..d4c6a5500df 100644 --- a/resources/js/cp-module.ts +++ b/resources/js/cp-module.ts @@ -1,4 +1,5 @@ import {defineAsyncComponent} from 'vue'; +import {FileUpload, UploadError, registerTransport} from './upload-client'; /** * The CP's element index and editor, its admin table, and the shared components @@ -96,3 +97,30 @@ export type { Table, Updater, } from '@tanstack/vue-table'; + +export {highlighter} from './common/utils/highlight-code'; + +export {CraftAuthChallengeForm as AuthChallengeForm} from './modules/auth/components/auth-challenge-form'; + +export { + AssetSelectorController, + ElementSelectorController, + registerElementSelectorController, +} from '@craftcms/ui'; + +export { + createElementSelectorModal, + type ElementSelectorModalHandle, + type ElementSelectorModalSettings, +} from './modules/element-selector-modal/create-element-selector-modal'; + +/** File uploads and the transport registry the control panel's uploader uses. */ +export const Uploads = {FileUpload, UploadError, registerTransport}; + +export type { + PrepareUpload, + UploadOptions, + UploadState, + UploadTransport, + UploadTransportContext, +} from './upload-client'; diff --git a/resources/js/cp.ts b/resources/js/cp.ts index f01fa2ee722..905840dc0f8 100644 --- a/resources/js/cp.ts +++ b/resources/js/cp.ts @@ -45,7 +45,6 @@ import './modules/link-field'; import './modules/cp-modal'; import './modules/tabs'; import './modules/grid'; -import './modules/highlight'; import './modules/chart'; import './modules/data-table-sorter'; import './modules/element-action-trigger'; diff --git a/resources/js/modules/auth/components/auth-challenge-form.ts b/resources/js/modules/auth/components/auth-challenge-form.ts index b6a844f112d..5c361f193ba 100644 --- a/resources/js/modules/auth/components/auth-challenge-form.ts +++ b/resources/js/modules/auth/components/auth-challenge-form.ts @@ -1,7 +1,6 @@ import {css, html, LitElement} from 'lit'; import {property, query, state} from 'lit/decorators.js'; import {actionClient, t} from '@craftcms/ui'; -import {registerCraftGlobals} from '@/common/craft-global'; /** @internal Module-private registry of PHP METHOD handle → element class. */ const _registry = new Map(); @@ -21,8 +20,7 @@ const _registry = new Map(); * @example * ```typescript * import {html} from 'lit'; - * - * const {AuthChallengeForm} = window.Craft; + * import {AuthChallengeForm} from '@craftcms/cp'; * * class MyPluginForm extends AuthChallengeForm { * static METHOD = 'my-method'; @@ -259,9 +257,3 @@ export abstract class CraftAuthChallengeForm extends LitElement { return _registry.has(method); } } - -// Plugin auth methods extend this class at runtime through the `Craft` global -// (bundling their own copy from an import would give them a separate, useless -// method registry). Assigned here so the global exists in any bundle that can -// render a challenge form. -registerCraftGlobals({AuthChallengeForm: CraftAuthChallengeForm}); diff --git a/resources/js/modules/element-selector-modal/compat.ts b/resources/js/modules/element-selector-modal/compat.ts new file mode 100644 index 00000000000..738d0a18a4b --- /dev/null +++ b/resources/js/modules/element-selector-modal/compat.ts @@ -0,0 +1,6 @@ +import {createElementSelectorModal} from './create-element-selector-modal'; +import {registerCraftGlobals} from '@/common/craft-global'; + +// Now async, resolving to a controller-backed handle rather than a modal +// object, since the modal is no longer a class anyone can subclass. +registerCraftGlobals({createElementSelectorModal}); diff --git a/resources/js/modules/element-selector-modal/globals.ts b/resources/js/modules/element-selector-modal/globals.ts index e6e8bd05e4c..6dc8cd75d6c 100644 --- a/resources/js/modules/element-selector-modal/globals.ts +++ b/resources/js/modules/element-selector-modal/globals.ts @@ -1,27 +1,6 @@ -import { - AssetSelectorController, - ElementSelectorController, - registerElementSelectorController, -} from '@craftcms/ui'; -import {createElementSelectorModal} from './create-element-selector-modal'; import {VolumeFolderSelectorModal} from './volume-folder-selector-modal'; import {registerCraftGlobals} from '@/common/craft-global'; -/** - * `Craft.*` stays the entry point for plugins and PHP-emitted boots - * (`new Craft.VolumeFolderSelectorModal(…)` in `MoveAssets.php` and the legacy - * `AssetIndex`). - * - * `createElementSelectorModal` is now **async** and resolves to a - * controller-backed handle rather than a modal object — a deliberate break, as - * the modal is no longer a class anyone can subclass. `registerElementSelectorController` - * replaces `registerElementSelectorModalClass`; plugins register a controller - * (business logic) and get both presentation layers for free. - */ -registerCraftGlobals({ - VolumeFolderSelectorModal, - createElementSelectorModal, - registerElementSelectorController, - ElementSelectorController, - AssetSelectorController, -}); +// `new Craft.VolumeFolderSelectorModal(…)` is emitted by `MoveAssets.php` and +// created by the legacy `AssetIndex`. +registerCraftGlobals({VolumeFolderSelectorModal}); diff --git a/resources/js/modules/element-selector-modal/index.test.ts b/resources/js/modules/element-selector-modal/index.test.ts index a86a2891f20..0a77620edd9 100644 --- a/resources/js/modules/element-selector-modal/index.test.ts +++ b/resources/js/modules/element-selector-modal/index.test.ts @@ -31,16 +31,25 @@ it('owns the registry rather than reaching into the legacy bundle', async () => await importModule(); const Craft = (window as any).Craft; - // The factory and the registration hook come from here, so the legacy bundle - // no longer has to ship either. - expect(typeof Craft.createElementSelectorModal).toBe('function'); - expect(typeof Craft.registerElementSelectorController).toBe('function'); // Still constructible from PHP-emitted boots. expect(typeof Craft.VolumeFolderSelectorModal).toBe('function'); + // Plugins import these from `@craftcms/cp`; the adapter restores the factory. + expect(Craft.createElementSelectorModal).toBeUndefined(); + expect(Craft.registerElementSelectorController).toBeUndefined(); expect(elementSelectorControllerClass(ASSET)).toBe(AssetSelectorController); }); +it('leaves the legacy factory global to the adapter', async () => { + vi.stubGlobal('Craft', {}); + + await import('./compat'); + + expect(typeof (window as any).Craft.createElementSelectorModal).toBe( + 'function' + ); +}); + it('yields to a controller the legacy bundle already registered for assets', async () => { class PluginAssetController {} vi.stubGlobal('Craft', { diff --git a/resources/js/modules/highlight/globals.ts b/resources/js/modules/highlight/compat.ts similarity index 74% rename from resources/js/modules/highlight/globals.ts rename to resources/js/modules/highlight/compat.ts index 5b51847f29b..a7b0964cce0 100644 --- a/resources/js/modules/highlight/globals.ts +++ b/resources/js/modules/highlight/compat.ts @@ -2,7 +2,7 @@ import {highlighter} from '@/common/utils/highlight-code'; import {registerCraftGlobals} from '@/common/craft-global'; /** - * Plugins highlight code with `Craft.highlighter`, as in Craft 5: + * The deprecated Prism and highlight asset bundles point Craft 5 plugins at * `Craft.highlighter.highlight(code, {lang: 'json'}).html`. */ registerCraftGlobals({highlighter}); diff --git a/resources/js/modules/highlight/index.ts b/resources/js/modules/highlight/index.ts deleted file mode 100644 index aa21bf08766..00000000000 --- a/resources/js/modules/highlight/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import './globals'; -import {highlighter} from '@/common/utils/highlight-code'; - -export {highlighter}; diff --git a/resources/js/modules/uploader/index.ts b/resources/js/modules/uploader/index.ts index cfc6058c5bd..abcd1788440 100644 --- a/resources/js/modules/uploader/index.ts +++ b/resources/js/modules/uploader/index.ts @@ -1,4 +1,3 @@ -import '@/uploads'; import './globals'; import {BaseUploader} from './base-uploader'; import {Uploader} from './uploader'; diff --git a/resources/js/uploads.ts b/resources/js/uploads.ts index d97853dc19b..4d9995646fc 100644 --- a/resources/js/uploads.ts +++ b/resources/js/uploads.ts @@ -10,5 +10,7 @@ export type { UploadTransportContext, } from './upload-client'; +// The standalone entry for pages outside the control panel, which don't get the +// `@craftcms/cp` import map. Control panel code imports `Uploads` from there. const Uploads = {FileUpload, UploadError, registerTransport}; registerCraftGlobals({Uploads}); diff --git a/yii2-adapter/resources/js/cp-compat.ts b/yii2-adapter/resources/js/cp-compat.ts index e3ec41ceeec..d1b5e328a3a 100644 --- a/yii2-adapter/resources/js/cp-compat.ts +++ b/yii2-adapter/resources/js/cp-compat.ts @@ -5,6 +5,8 @@ import './element-editor'; import './native-field-refresh'; import '@/modules/auth-method-setup/compat'; import '@/modules/element-select-input/compat'; +import '@/modules/element-selector-modal/compat'; import '@/modules/grouped-entry-type-manager/compat'; +import '@/modules/highlight/compat'; import '@/modules/image-editor/compat'; import '@/modules/sortable-checkbox-select/compat'; From 45fd9b9a74a6cf041146d71636f9ce86503ea35c Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Wed, 28 Oct 2026 16:46:36 -0500 Subject: [PATCH 05/51] Publish the command palette API from @craftcms/cp Craft.commandPalette is new in 6.0, so it moves to a CommandPalette export of the @craftcms/cp import-map module instead of becoming a global the adapter has to carry. Craft.charts dates from Craft 5 and core's legacy New Users widget still calls it, so it gets the globals.ts split. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG-WIP.md | 2 +- resources/js/common/types/globals.d.ts | 7 ----- resources/js/cp-module.ts | 23 ++++++++++++++++ resources/js/modules/chart/globals.ts | 9 +++++++ resources/js/modules/chart/index.ts | 15 +---------- resources/js/modules/command-palette/index.ts | 26 ++----------------- .../js/modules/command-palette/registry.ts | 2 +- 7 files changed, 37 insertions(+), 47 deletions(-) create mode 100644 resources/js/modules/chart/globals.ts diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md index 8d61a681696..6d36818d2aa 100644 --- a/CHANGELOG-WIP.md +++ b/CHANGELOG-WIP.md @@ -720,7 +720,7 @@ Craft 6 now uses [Laravel's authorization system](https://laravel.com/docs/12.x/ #### Control Panel - Added `CraftCms\Cms\Cp\CommandPalette\CommandPaletteItem`, `PageRegistry`, `ActionRegistry`, and `SearchProviderRegistry`, which plugins can use to add pages, actions, and search providers to the command palette, and `CraftCms\Cms\Cp\CommandPalette\Contracts\SearchProvider` and `CraftCms\Cms\Cp\CommandPalette\SearchProviders\ElementSearchProvider`, for searching custom content or element types. ([#20081](https://github.com/craftcms/cms/pull/20081)) -- Added `Craft.commandPalette`, with `open()`, `close()`, `registerActions()`, `registerProvider()`, and `registerView()` methods, for opening the command palette and adding page actions, client-side search providers, and views to it. A page’s own action menus are listed as command palette actions when they’re within an element with a `data-command-palette-actions` attribute. ([#20081](https://github.com/craftcms/cms/pull/20081)) +- Added `CommandPalette` to the `@craftcms/cp` import-map module, with `open()`, `close()`, `registerActions()`, `registerProvider()`, and `registerView()` methods, for opening the command palette and adding page actions, client-side search providers, and views to it. A page’s own action menus are listed as command palette actions when they’re within an element with a `data-command-palette-actions` attribute. ([#20081](https://github.com/craftcms/cms/pull/20081)) - Added a renderer-neutral control panel UI system with shared PHP and Vue rendering, extensible Nodes and Controls, nested and refreshable scopes, changed-only submission, and integration with configurable component settings, fields, and field layouts. ([#19384](https://github.com/craftcms/cms/pull/19384)) - Removed the `assets/upload` and `assets/replace-file` multipart endpoints and legacy uploader events and overrides, in favor of upload sessions. ([#19604](https://github.com/craftcms/cms/pull/19604)) - Removed HTMX from the control panel. Plugins that rely on it should bundle it themselves. ([#19836](https://github.com/craftcms/cms/pull/19836)) diff --git a/resources/js/common/types/globals.d.ts b/resources/js/common/types/globals.d.ts index 0502a7e93f8..34a4310093e 100644 --- a/resources/js/common/types/globals.d.ts +++ b/resources/js/common/types/globals.d.ts @@ -157,13 +157,6 @@ interface CraftStatic { openCustomizeSourcesModal( options: import('@/modules/customize-sources').CustomizeSourcesModalOptions ): Promise; - commandPalette: Pick< - typeof import('@/modules/command-palette/registry'), - 'registerProvider' | 'registerActions' | 'registerView' - > & { - open: typeof import('@/modules/command-palette/registry').openCommandPalette; - close: typeof import('@/modules/command-palette/registry').closeCommandPalette; - }; expandPostArray(arr: FormData | URLSearchParams): LegacyWidgetSettings; escapeHtml(str: string); sites: Site[]; diff --git a/resources/js/cp-module.ts b/resources/js/cp-module.ts index d4c6a5500df..99a939aa0c0 100644 --- a/resources/js/cp-module.ts +++ b/resources/js/cp-module.ts @@ -1,5 +1,12 @@ import {defineAsyncComponent} from 'vue'; import {FileUpload, UploadError, registerTransport} from './upload-client'; +import { + closeCommandPalette, + openCommandPalette, + registerActions, + registerProvider, + registerView, +} from './modules/command-palette/registry'; /** * The CP's element index and editor, its admin table, and the shared components @@ -124,3 +131,19 @@ export type { UploadTransport, UploadTransportContext, } from './upload-client'; + +/** + * Opens or closes the command palette, and adds client-side search providers, + * page actions and views to it. Server-side providers, pages and actions are + * registered in PHP. + */ +export const CommandPalette = { + open: openCommandPalette, + close: closeCommandPalette, + registerProvider, + registerActions, + registerView, +}; + +export type * from './modules/command-palette/types'; +export type {CommandPaletteRegistration} from './modules/command-palette/registry'; diff --git a/resources/js/modules/chart/globals.ts b/resources/js/modules/chart/globals.ts new file mode 100644 index 00000000000..82f3a41a404 --- /dev/null +++ b/resources/js/modules/chart/globals.ts @@ -0,0 +1,9 @@ +import {DataTable, Tip, BaseChart, Area, chartsUtils} from './chart'; +import {registerCraftGlobals} from '@/common/craft-global'; + +// Assign the legacy `Craft.charts` namespace: `new Craft.charts.Area(...)` / +// `new Craft.charts.DataTable(...)` are created by the chart widgets (e.g. +// NewUsersWidget). Plain modern ES classes — nothing subclasses them via the +// legacy `.extend()` API. +const charts = {DataTable, Tip, BaseChart, Area, utils: chartsUtils}; +registerCraftGlobals({charts}); diff --git a/resources/js/modules/chart/index.ts b/resources/js/modules/chart/index.ts index 85f817abe4a..2f586761656 100644 --- a/resources/js/modules/chart/index.ts +++ b/resources/js/modules/chart/index.ts @@ -1,17 +1,4 @@ +import './globals'; import {DataTable, Tip, BaseChart, Area, chartsUtils} from './chart'; -// Assign the legacy `Craft.charts` namespace: `new Craft.charts.Area(...)` / -// `new Craft.charts.DataTable(...)` are created by the chart widgets (e.g. -// NewUsersWidget). Plain modern ES classes — nothing subclasses them via the -// legacy `.extend()` API. -Object.assign(window.Craft, { - charts: { - DataTable, - Tip, - BaseChart, - Area, - utils: chartsUtils, - }, -}); - export {DataTable, Tip, BaseChart, Area, chartsUtils}; diff --git a/resources/js/modules/command-palette/index.ts b/resources/js/modules/command-palette/index.ts index 70a040afdaa..cb688e335c7 100644 --- a/resources/js/modules/command-palette/index.ts +++ b/resources/js/modules/command-palette/index.ts @@ -1,11 +1,4 @@ -import { - closeCommandPalette, - installCommandPaletteShortcut, - openCommandPalette, - registerActions, - registerProvider, - registerView, -} from './registry'; +import {installCommandPaletteShortcut, registerProvider} from './registry'; import {onThisPageProvider} from './sources/on-this-page'; export * from './registry'; @@ -13,24 +6,9 @@ export type * from './types'; export {PAGE_ACTIONS_ATTRIBUTE} from './sources/on-page-actions'; /** - * Installs the palette's page-level search provider and `Craft.commandPalette`. + * Installs the palette's page-level search provider and keyboard shortcut. * Imported once, by the control panel's entry point; the palette component * itself is rendered by `PageScreen`. */ registerProvider(onThisPageProvider); installCommandPaletteShortcut(); - -/** - * `Craft.commandPalette`, for plugins: open or close the palette, and add - * client-side search providers, page actions and views. Server-side search - * providers, pages and actions are registered in PHP. - */ -Object.assign(window.Craft, { - commandPalette: { - open: openCommandPalette, - close: closeCommandPalette, - registerProvider, - registerActions, - registerView, - }, -}); diff --git a/resources/js/modules/command-palette/registry.ts b/resources/js/modules/command-palette/registry.ts index 985e8463cd5..185f3046a9e 100644 --- a/resources/js/modules/command-palette/registry.ts +++ b/resources/js/modules/command-palette/registry.ts @@ -93,7 +93,7 @@ export interface CommandPaletteRegistration { * each time the palette opens, so it can read the page's current state. * * ```js - * const {unregister} = Craft.commandPalette.registerActions(() => [...]); + * const {unregister} = CommandPalette.registerActions(() => [...]); * ``` */ export function registerActions( From 2a61e4cb0a0cbcda97a650fc3699b010c9759b4d Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Wed, 28 Oct 2026 17:27:48 -0500 Subject: [PATCH 06/51] Port Craft.Queue to a TypeScript PromiseQueue The class now lives in resources/js/common/utils/promise-queue.ts on @craftcms/garnish Base, without jQuery. The legacy bundle imports it and still assigns Craft.Queue and Craft.queue where Queue.js did, since Craft.cp uses the queue while that bundle evaluates. A failed job no longer leaves an unhandled rejection behind. Co-Authored-By: Claude Opus 5.5 --- packages/craftcms-legacy/cp/src/js/Queue.js | 144 +----------------- .../js/common/utils/promise-queue.test.ts | 91 +++++++++++ resources/js/common/utils/promise-queue.ts | 123 +++++++++++++++ 3 files changed, 219 insertions(+), 139 deletions(-) create mode 100644 resources/js/common/utils/promise-queue.test.ts create mode 100644 resources/js/common/utils/promise-queue.ts diff --git a/packages/craftcms-legacy/cp/src/js/Queue.js b/packages/craftcms-legacy/cp/src/js/Queue.js index 70a88ae28fb..657b4587969 100644 --- a/packages/craftcms-legacy/cp/src/js/Queue.js +++ b/packages/craftcms-legacy/cp/src/js/Queue.js @@ -1,141 +1,7 @@ /** global: Craft */ -/** global: Garnish */ +import {PromiseQueue} from '../../../../../resources/js/common/utils/promise-queue'; -/** - * Queue - */ -Craft.Queue = Garnish.Base.extend({ - running: false, - paused: false, - pausedForVisibility: false, - jobs: null, - - get length() { - return this.jobs.length; - }, - - get isPaused() { - return this.paused || this.pausedForVisibility; - }, - - init: function () { - this.jobs = []; - - Garnish.$doc.on('visibilitychange', () => { - if (this.pausedForVisibility && Craft.isVisible()) { - this.pausedForVisibility = false; - this._resume(); - } - }); - }, - - /** - * Adds a job to the queue. - * @param {function} job - * @returns {Promise} - */ - push: function (job) { - return this._add(job, 'push'); - }, - - /** - * Adds a job to the beginning of the queue. - * @param {function} job - * @returns {Promise} - */ - unshift: function (job) { - return this._add(job, 'unshift'); - }, - - pause: function () { - if (!this.paused) { - this.paused = true; - if (!this.pausedForVisibility) { - this.trigger('pause'); - } - } - }, - - resume: function () { - if (this.paused) { - this.paused = false; - this._resume(); - } - }, - - /** - * Adds a job to the queue. - * @param {function} job - * @param {string} method - * @returns {Promise} - * @private - */ - _add: function (job, method) { - return new Promise((resolve, reject) => { - this.jobs[method](() => { - return new Promise((qResolve, qReject) => { - job() - .then((value) => { - // ...arguments doesn't work here :( - resolve(value); - qResolve(); - }) - .catch((value) => { - // ...arguments doesn't work here :( - reject(value); - qReject(); - }); - }); - }); - - if (!this.running) { - this.trigger('beforeRun'); - this.running = true; - this._exec(); - } - }); - }, - - clear: function () { - this.jobs.length = 0; - }, - - /** - * Runs the next job in the queue. - * @private - */ - _exec: function () { - if (!this.jobs.length) { - this.running = false; - this.trigger('afterRun'); - return; - } - - if (!this.pausedForVisibility && !Craft.isVisible()) { - this.pausedForVisibility = true; - if (!this.paused) { - this.trigger('pause'); - } - } - - if (this.paused || this.pausedForVisibility) { - return; - } - - this.trigger('beforeExec'); - const job = this.jobs.shift(); - job().finally(() => { - this.trigger('afterExec'); - this._exec(); - }); - }, - - _resume: function () { - if (!this.isPaused) { - this.trigger('resume'); - this._exec(); - } - }, -}); - -Craft.queue = new Craft.Queue(); +// Assigned here rather than from a Vite module: `Craft.cp` is constructed while +// this bundle evaluates, and it uses the queue straight away. +Craft.Queue = PromiseQueue; +Craft.queue = new PromiseQueue(); diff --git a/resources/js/common/utils/promise-queue.test.ts b/resources/js/common/utils/promise-queue.test.ts new file mode 100644 index 00000000000..b5d9af8a193 --- /dev/null +++ b/resources/js/common/utils/promise-queue.test.ts @@ -0,0 +1,91 @@ +import {afterEach, expect, it} from 'vite-plus/test'; +import {PromiseQueue} from './promise-queue'; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + + return {promise, resolve, reject}; +} + +function setVisibility(state: DocumentVisibilityState): void { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => state, + }); + document.dispatchEvent(new Event('visibilitychange')); +} + +afterEach(() => { + setVisibility('visible'); +}); + +it('runs one job at a time, in order, and keeps going after a failure', async () => { + const queue = new PromiseQueue(); + const log: string[] = []; + const first = deferred(); + + const a = queue.push(() => { + log.push('a'); + return first.promise; + }); + const b = queue.push(async () => { + log.push('b'); + throw new Error('b failed'); + }); + const c = queue.unshift(async () => { + log.push('c'); + return 'c done'; + }); + + expect(log).toEqual(['a']); + + first.resolve('a done'); + + await expect(a).resolves.toBe('a done'); + await expect(c).resolves.toBe('c done'); + await expect(b).rejects.toThrow('b failed'); + expect(log).toEqual(['a', 'c', 'b']); + expect(queue.running).toBe(false); +}); + +it('holds jobs while paused', async () => { + const queue = new PromiseQueue(); + const log: string[] = []; + + queue.pause(); + const job = queue.push(async () => { + log.push('ran'); + }); + await Promise.resolve(); + + expect(log).toEqual([]); + + queue.resume(); + await job; + + expect(log).toEqual(['ran']); +}); + +it('waits for the tab to become visible before running jobs', async () => { + const queue = new PromiseQueue(); + const log: string[] = []; + + setVisibility('hidden'); + const job = queue.push(async () => { + log.push('ran'); + }); + await Promise.resolve(); + + expect(queue.isPaused).toBe(true); + expect(log).toEqual([]); + + setVisibility('visible'); + await job; + + expect(log).toEqual(['ran']); +}); diff --git a/resources/js/common/utils/promise-queue.ts b/resources/js/common/utils/promise-queue.ts new file mode 100644 index 00000000000..fbfc66e5522 --- /dev/null +++ b/resources/js/common/utils/promise-queue.ts @@ -0,0 +1,123 @@ +import {Base} from '@craftcms/garnish'; + +type Job = () => Promise; + +/** + * Runs async jobs one at a time, in order. + * + * The queue pauses itself while the tab is hidden, so background work doesn't + * pile up requests nobody is looking at. Triggers `beforeRun`/`afterRun` around + * a run of jobs, `beforeExec`/`afterExec` around each one, and `pause`/`resume`. + */ +export class PromiseQueue extends Base { + running = false; + paused = false; + pausedForVisibility = false; + jobs: Job[] = []; + + constructor() { + super(); + if (new.target === PromiseQueue) { + this.init(); + } + } + + init(): void { + this.addListener(document, 'visibilitychange', () => { + if (this.pausedForVisibility && isVisible()) { + this.pausedForVisibility = false; + this.#resume(); + } + }); + } + + get length(): number { + return this.jobs.length; + } + + get isPaused(): boolean { + return this.paused || this.pausedForVisibility; + } + + /** Adds a job to the end of the queue. Resolves with the job's result. */ + push(job: Job): Promise { + return this.#add(job, 'push'); + } + + /** Adds a job to the front of the queue. Resolves with the job's result. */ + unshift(job: Job): Promise { + return this.#add(job, 'unshift'); + } + + pause(): void { + if (!this.paused) { + this.paused = true; + if (!this.pausedForVisibility) { + this.trigger('pause'); + } + } + } + + resume(): void { + if (this.paused) { + this.paused = false; + this.#resume(); + } + } + + clear(): void { + this.jobs.length = 0; + } + + #add(job: Job, method: 'push' | 'unshift'): Promise { + return new Promise((resolve, reject) => { + // The queue only needs to know when the job settles; the caller gets + // its outcome through the returned promise. + this.jobs[method](() => job().then(resolve, reject)); + + if (!this.running) { + this.trigger('beforeRun'); + this.running = true; + this.#exec(); + } + }); + } + + #exec(): void { + if (!this.jobs.length) { + this.running = false; + this.trigger('afterRun'); + return; + } + + if (!this.pausedForVisibility && !isVisible()) { + this.pausedForVisibility = true; + if (!this.paused) { + this.trigger('pause'); + } + } + + if (this.isPaused) { + return; + } + + this.trigger('beforeExec'); + const job = this.jobs.shift()!; + // Queued wrappers never reject; see `#add()`. + void job().then(() => { + this.trigger('afterExec'); + this.#exec(); + }); + } + + #resume(): void { + if (!this.isPaused) { + this.trigger('resume'); + this.#exec(); + } + } +} + +function isVisible(): boolean { + return document.visibilityState !== 'hidden'; +} From 9e25306054c3efd772ec74b115ef37107e9f77fb Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Wed, 28 Oct 2026 18:52:53 -0500 Subject: [PATCH 07/51] Move the relation field settings scripts into the Yii2 adapter Craft.AssetsFieldSettings and Craft.ElementFieldSettings only serve the legacy relation field settings templates, which only the adapter's LegacyRelationFieldSettings renders; core builds those settings with settingsUi(). They now ship unchanged in the cpcompat bundle, loaded CP-wide by CpCompatAsset. Co-Authored-By: Claude Opus 5.5 --- packages/craftcms-legacy/cp/src/Craft.js | 2 -- .../src/assets-field-settings.js} | 5 ++++- .../src/element-field-settings.js} | 7 +++++++ packages/craftcms-legacy/cpcompat/webpack.config.js | 2 ++ yii2-adapter/src/View/LegacyAssets/CpCompatAsset.php | 5 ++++- 5 files changed, 17 insertions(+), 4 deletions(-) rename packages/craftcms-legacy/{cp/src/js/AssetsFieldSettings.js => cpcompat/src/assets-field-settings.js} (91%) rename packages/craftcms-legacy/{cp/src/js/ElementFieldSettings.js => cpcompat/src/element-field-settings.js} (92%) diff --git a/packages/craftcms-legacy/cp/src/Craft.js b/packages/craftcms-legacy/cp/src/Craft.js index f5748a308cc..8805c1cd06e 100644 --- a/packages/craftcms-legacy/cp/src/Craft.js +++ b/packages/craftcms-legacy/cp/src/Craft.js @@ -24,12 +24,10 @@ import './js/BaseElementIndexView.js'; // Finally load the remaining files import './js/AdminTable.js'; import './js/AssetIndex.js'; -import './js/AssetsFieldSettings'; import './js/CP.js'; import './js/CardsElementIndexView.js'; import './js/CategoryIndex.js'; import './js/ElementEditor.js'; -import './js/ElementFieldSettings.js'; import './js/EntryIndex.js'; import './js/Preview.js'; import './js/ProgressBar.js'; diff --git a/packages/craftcms-legacy/cp/src/js/AssetsFieldSettings.js b/packages/craftcms-legacy/cpcompat/src/assets-field-settings.js similarity index 91% rename from packages/craftcms-legacy/cp/src/js/AssetsFieldSettings.js rename to packages/craftcms-legacy/cpcompat/src/assets-field-settings.js index 802538e09ed..6993ce27c3b 100644 --- a/packages/craftcms-legacy/cp/src/js/AssetsFieldSettings.js +++ b/packages/craftcms-legacy/cpcompat/src/assets-field-settings.js @@ -1,7 +1,10 @@ /** global: Craft */ /** global: Garnish */ /** - * Assets field settings + * Assets field settings for the legacy relation field settings template, which + * only the Yii2 adapter renders. Core builds these settings with `settingsUi()`. + * + * Loaded CP-wide via CraftCms\Yii2Adapter\View\LegacyAssets\CpCompatAsset. */ Craft.AssetsFieldSettings = Garnish.Base.extend({ $useSingleFolderInput: null, diff --git a/packages/craftcms-legacy/cp/src/js/ElementFieldSettings.js b/packages/craftcms-legacy/cpcompat/src/element-field-settings.js similarity index 92% rename from packages/craftcms-legacy/cp/src/js/ElementFieldSettings.js rename to packages/craftcms-legacy/cpcompat/src/element-field-settings.js index 061b801f0aa..032ace8b2a2 100644 --- a/packages/craftcms-legacy/cp/src/js/ElementFieldSettings.js +++ b/packages/craftcms-legacy/cpcompat/src/element-field-settings.js @@ -1,5 +1,12 @@ /** global: Craft */ /** global: Garnish */ +/** + * Relation field settings for the legacy relation field settings template, + * which only the Yii2 adapter renders. Core builds these settings with + * `settingsUi()`. + * + * Loaded CP-wide via CraftCms\Yii2Adapter\View\LegacyAssets\CpCompatAsset. + */ Craft.ElementFieldSettings = Garnish.Base.extend({ allowMultipleSources: null, $maintainHierarchyField: null, diff --git a/packages/craftcms-legacy/cpcompat/webpack.config.js b/packages/craftcms-legacy/cpcompat/webpack.config.js index d789401d19f..70042df2434 100644 --- a/packages/craftcms-legacy/cpcompat/webpack.config.js +++ b/packages/craftcms-legacy/cpcompat/webpack.config.js @@ -14,6 +14,8 @@ module.exports = getConfig({ 'cp-compat': './cp-compat.js', 'legacy-html-control': './legacy-html-control.js', 'live-preview': './live-preview.js', + 'assets-field-settings': './assets-field-settings.js', + 'element-field-settings': './element-field-settings.js', }, output: { path: __dirname + '/../../../cms-assets/resources/legacy/cpcompat/dist', diff --git a/yii2-adapter/src/View/LegacyAssets/CpCompatAsset.php b/yii2-adapter/src/View/LegacyAssets/CpCompatAsset.php index bd846d5a634..90b264fa182 100644 --- a/yii2-adapter/src/View/LegacyAssets/CpCompatAsset.php +++ b/yii2-adapter/src/View/LegacyAssets/CpCompatAsset.php @@ -27,7 +27,8 @@ * that bundle so the core CP bundle can drop it while the `componentSelect.twig` * `jsClass` escape hatch keeps working for plugin subclasses. It defines its * class at top-level eval, so it is registered ahead of cp-compat.js. So is - * live-preview.js, the legacy `Craft.LivePreview` that `Craft.Preview` replaced. + * live-preview.js, the legacy `Craft.LivePreview` that `Craft.Preview` replaced, + * and the relation field settings scripts for the legacy settings templates. * * @internal */ @@ -41,6 +42,8 @@ public function register(HtmlStack $htmlStack): void $htmlStack->jsFile(craftAsset('legacy/cpcompat/dist/component-select-input.js')); $htmlStack->jsFile(craftAsset('legacy/cpcompat/dist/legacy-html-control.js')); $htmlStack->jsFile(craftAsset('legacy/cpcompat/dist/live-preview.js')); + $htmlStack->jsFile(craftAsset('legacy/cpcompat/dist/assets-field-settings.js')); + $htmlStack->jsFile(craftAsset('legacy/cpcompat/dist/element-field-settings.js')); $htmlStack->jsFile(craftAsset('legacy/cpcompat/dist/cp-compat.js')); } } From 36e746f30db57d9a6a82fe26ff2307cd340e7713 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Wed, 28 Oct 2026 20:20:31 -0500 Subject: [PATCH 08/51] Move Craft.js string and namespacing helpers to TypeScript The trim/startsWith/remove* family, escapeRegex, escapeChars, upper/lowercaseFirst, uuid, randomString, formatInputId, namespaceInputName and namespaceId now live in resources/js/common/craft/. Craft.js assigns them, so Craft.trim() and the rest keep working for plugins, and the modern modules that reached through the global import them instead. Co-Authored-By: Claude Opus 5.5 --- packages/craftcms-legacy/cp/src/js/Craft.js | 283 +++--------------- .../js/common/craft/craft-helpers.test.ts | 42 +++ resources/js/common/craft/namespace.ts | 33 ++ resources/js/common/craft/strings.ts | 143 +++++++++ .../modules/editable-table/editable-table.ts | 5 +- .../modules/field-layout-designer/element.ts | 3 +- .../js/modules/field-layout-designer/tab.ts | 3 +- .../grouped-entry-type-manager.ts | 7 +- resources/js/modules/link-field/link-input.ts | 21 +- 9 files changed, 281 insertions(+), 259 deletions(-) create mode 100644 resources/js/common/craft/craft-helpers.test.ts create mode 100644 resources/js/common/craft/namespace.ts create mode 100644 resources/js/common/craft/strings.ts diff --git a/packages/craftcms-legacy/cp/src/js/Craft.js b/packages/craftcms-legacy/cp/src/js/Craft.js index a9fc29afe10..514f09558b7 100644 --- a/packages/craftcms-legacy/cp/src/js/Craft.js +++ b/packages/craftcms-legacy/cp/src/js/Craft.js @@ -2,6 +2,28 @@ import {formatMessage, t} from '@craftcms/ui/utilities/translate'; import {createHttpClient, isCancel} from '@craftcms/ui/utilities/api/http'; import {sendLegacyRequest} from '@craftcms/ui/utilities/api/legacyRequest'; import * as d3 from 'd3'; +import { + formatInputId, + namespaceId, + namespaceInputName, +} from '../../../../../resources/js/common/craft/namespace'; +import { + endsWith, + ensureEndsWith, + ensureStartsWith, + escapeChars, + escapeRegex, + lowercaseFirst, + ltrim, + randomString, + removeLeft, + removeRight, + rtrim, + startsWith, + trim, + uppercaseFirst, + uuid, +} from '../../../../../resources/js/common/craft/strings'; /** global: Craft */ /** global: Garnish */ @@ -139,16 +161,7 @@ $.extend(Craft, { return $('
').text(str).html(); }, - /** - * Escapes special regular expression characters. - * - * @param {string} str - * @returns {string} - */ - escapeRegex: function (str) { - // h/t https://stackoverflow.com/a/9310752 - return str.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); - }, + escapeRegex, /** * Returns the text in a string that might contain HTML tags. @@ -206,18 +219,7 @@ $.extend(Craft, { } }, - /** - * Formats an ID out of an input name. - * - * @param {string} inputName - * @returns {string} - */ - formatInputId: function (inputName) { - // IDs must begin with a letter - let id = inputName.replace(/^[^A-Za-z]+/, ''); - id = this.rtrim(id.replace(/[^A-Za-z0-9_.]+/g, '-'), '-'); - return id || this.randomString(10); - }, + formatInputId, /** * Sleeps for the given duration in milliseconds. @@ -1294,172 +1296,25 @@ $.extend(Craft, { return keys; }, - /** - * Takes an array or string of chars, and places a backslash before each one, returning the combined string. - * - * Userd by ltrim() and rtrim() - * - * @param {(string|Object)} chars - * @returns {string} - */ - escapeChars: function (chars) { - if (!Array.isArray(chars)) { - chars = chars.split(); - } + escapeChars, - var escaped = ''; + ltrim, - for (var i = 0; i < chars.length; i++) { - escaped += '\\' + chars[i]; - } + rtrim, - return escaped; - }, + trim, - /** - * Trim characters off of the beginning of a string. - * - * @param {string} str - * @param {(string|Object)} [chars] The characters to trim off. Defaults to a space if left blank. - * @returns {string} - */ - ltrim: function (str, chars) { - if (!str) { - return str; - } - if (typeof chars === 'undefined') { - return str.trimStart(); - } - const re = new RegExp('^[' + Craft.escapeChars(chars) + ']+'); - return str.replace(re, ''); - }, + startsWith, - /** - * Trim characters off of the end of a string. - * - * @param {string} str - * @param {(string|Object)} [chars] The characters to trim off. Defaults to a space if left blank. - * @returns {string} - */ - rtrim: function (str, chars) { - if (!str) { - return str; - } - if (typeof chars === 'undefined') { - return str.trimEnd(); - } - const re = new RegExp('[' + Craft.escapeChars(chars) + ']+$'); - return str.replace(re, ''); - }, + endsWith, - /** - * Trim characters off of the beginning and end of a string. - * - * @param {string} str - * @param {(string|Object)} [chars] The characters to trim off. Defaults to a space if left blank. - * @returns {string} - */ - trim: function (str, chars) { - if (!str) { - return str; - } - if (typeof chars === 'undefined') { - return str.trim(); - } - str = Craft.ltrim(str, chars); - str = Craft.rtrim(str, chars); - return str; - }, + ensureStartsWith, - /** - * Returns whether a string starts with another string. - * - * @param {string} str - * @param {string} substr - * @param {boolean} [caseInsensitive=false] - * @returns {boolean} - */ - startsWith: function (str, substr, caseInsensitive = false) { - if (caseInsensitive) { - return str.toLowerCase().startsWith(substr.toLowerCase()); - } - return str.startsWith(substr); - }, + ensureEndsWith, - /** - * Returns whether a string ends with another string. - * - * @param {string} str - * @param {string} substr - * @param {boolean} [caseInsensitive=false] - * @returns {boolean} - */ - endsWith: function (str, substr, caseInsensitive = false) { - if (caseInsensitive) { - return str.toLowerCase().endsWith(substr.toLowerCase()); - } - return str.endsWith(substr); - }, + removeLeft, - /** - * Ensures a string starts with another string. - * - * @param {string} str - * @param {string} substr - * @param {boolean} [caseInsensitive=false] - * @return {string} - */ - ensureStartsWith: function (str, substr, caseInsensitive = false) { - if (!Craft.startsWith(str, substr, caseInsensitive)) { - str = substr + str; - } - return str; - }, - - /** - * Ensures a string ends with another string. - * - * @param {string} str - * @param {string} substr - * @param {boolean} [caseInsensitive=false] - * @return {string} - */ - ensureEndsWith: function (str, substr, caseInsensitive = false) { - if (!Craft.endsWith(str, substr, caseInsensitive)) { - str += substr; - } - return str; - }, - - /** - * Removes a string from the beginning of another string. - * - * @param {string} str - * @param {string} substr - * @param {boolean} [caseInsensitive=false] - * @return {string} - */ - removeLeft: function (str, substr, caseInsensitive = false) { - if (Craft.startsWith(str, substr, caseInsensitive)) { - return str.slice(substr.length); - } - return str; - }, - - /** - * Removes a string from the end of another string. - * - * @param {string} str - * @param {string} substr - * @param {boolean} [caseInsensitive=false] - * @return {string} - */ - removeRight: function (str, substr, caseInsensitive = false) { - if (Craft.endsWith(str, substr, caseInsensitive)) { - return str.slice(0, -substr.length); - } - return str; - }, + removeRight, /** * @callback filterArrayCallback @@ -1570,25 +1425,9 @@ $.extend(Craft, { return [...new Set(arr)]; }, - /** - * Makes the first character of a string uppercase. - * - * @param {string} str - * @returns {string} - */ - uppercaseFirst: function (str) { - return str.charAt(0).toUpperCase() + str.slice(1); - }, + uppercaseFirst, - /** - * Makes the first character of a string lowercase. - * - * @param {string} str - * @returns {string} - */ - lowercaseFirst: function (str) { - return str.charAt(0).toLowerCase() + str.slice(1); - }, + lowercaseFirst, parseUrl: function (url) { var m = url.match( @@ -1751,55 +1590,13 @@ $.extend(Craft, { return asciiStr; }, - uuid: function () { - if (typeof crypto.randomUUID === 'function') { - return crypto.randomUUID(); - } - - // h/t https://stackoverflow.com/a/2117523/1688568 - return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, (c) => - ( - c ^ - (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4))) - ).toString(16) - ); - }, - - /** - * @param {string} name - * @param {string} [namespace] - * @returns {string} - */ - namespaceInputName: function (name, namespace) { - if (!namespace) { - return name; - } + uuid, - return name.replace(/([^'"\[\]]+)([^'"]*)/, `${namespace}[$1]$2`); - }, + namespaceInputName, - /** - * @param {string} id - * @param {string} [namespace] - * @returns {string} - */ - namespaceId: function (id, namespace) { - return ( - (namespace ? `${Craft.formatInputId(namespace)}-` : '') + - Craft.formatInputId(id) - ); - }, + namespaceId, - randomString: function (length) { - // h/t https://stackoverflow.com/a/1349426/1688568 - var result = ''; - var characters = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - for (var i = 0; i < length; i++) { - result += characters.charAt(Math.floor(Math.random() * 62)); - } - return result; - }, + randomString, /** * Creates a validation error list. diff --git a/resources/js/common/craft/craft-helpers.test.ts b/resources/js/common/craft/craft-helpers.test.ts new file mode 100644 index 00000000000..4c4cc7918e9 --- /dev/null +++ b/resources/js/common/craft/craft-helpers.test.ts @@ -0,0 +1,42 @@ +import {describe, expect, it} from 'vite-plus/test'; +import {formatInputId, namespaceId, namespaceInputName} from './namespace'; +import {removeLeft, removeRight, trim} from './strings'; + +describe('namespacing', () => { + it.each([ + ['title', 'fields', 'fields[title]'], + ['entries[0][title]', 'fields', 'fields[entries][0][title]'], + ['title', undefined, 'title'], + ])('namespaces the input name %s in %s', (name, namespace, expected) => { + expect(namespaceInputName(name, namespace)).toBe(expected); + }); + + it.each([ + ['title', 'fields', 'fields-title'], + ['entries[0][title]', null, 'entries-0-title'], + ['123abc', null, 'abc'], + ['fields[a b]', 'settings[x]', 'settings-x-fields-a-b'], + ])('builds an ID from %s in %s', (id, namespace, expected) => { + expect(namespaceId(id, namespace)).toBe(expected); + }); + + it('falls back to a random ID when nothing usable is left', () => { + expect(formatInputId('123')).toMatch(/^[A-Za-z0-9]{10}$/); + }); +}); + +describe('trimming', () => { + it('trims the given characters off both ends', () => { + expect(trim('\n pasted\r\n', ' \n\r')).toBe('pasted'); + }); + + it('removes a prefix case-insensitively when asked', () => { + expect(removeLeft('HTTPS://craftcms.com', 'https://', true)).toBe( + 'craftcms.com' + ); + expect(removeLeft('HTTPS://craftcms.com', 'https://')).toBe( + 'HTTPS://craftcms.com' + ); + expect(removeRight('craftcms.com/', '/')).toBe('craftcms.com'); + }); +}); diff --git a/resources/js/common/craft/namespace.ts b/resources/js/common/craft/namespace.ts new file mode 100644 index 00000000000..5d8f7b9c800 --- /dev/null +++ b/resources/js/common/craft/namespace.ts @@ -0,0 +1,33 @@ +import {randomString, rtrim} from './strings'; + +/** + * Input name and ID namespacing for markup built in the browser. The legacy + * bundle still exposes these on the `Craft` global for plugins. + */ + +/** Formats an HTML ID out of an input name. IDs must begin with a letter. */ +export function formatInputId(inputName: string): string { + const id = rtrim( + inputName.replace(/^[^A-Za-z]+/, '').replace(/[^A-Za-z0-9_.]+/g, '-'), + '-' + ); + + return id || randomString(10); +} + +/** Namespaces an input name: `title` in `fields` becomes `fields[title]`. */ +export function namespaceInputName( + name: string, + namespace?: string | null +): string { + if (!namespace) { + return name; + } + + return name.replace(/([^'"[\]]+)([^'"]*)/, `${namespace}[$1]$2`); +} + +/** Namespaces an input ID: `title` in `fields` becomes `fields-title`. */ +export function namespaceId(id: string, namespace?: string | null): string { + return (namespace ? `${formatInputId(namespace)}-` : '') + formatInputId(id); +} diff --git a/resources/js/common/craft/strings.ts b/resources/js/common/craft/strings.ts new file mode 100644 index 00000000000..6fd5563226e --- /dev/null +++ b/resources/js/common/craft/strings.ts @@ -0,0 +1,143 @@ +/** + * String helpers formerly defined on the legacy `Craft` global. The legacy + * bundle still exposes them there (`Craft.trim()` and so on) for plugins. + */ + +/** Escapes special regular expression characters. */ +export function escapeRegex(str: string): string { + return str.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); +} + +/** + * Places a backslash before each item, for use in a regex character class. + * A string is treated as a single item. + */ +export function escapeChars(chars: string | string[]): string { + const list = Array.isArray(chars) ? chars : [chars]; + + return list.map((chr) => `\\${chr}`).join(''); +} + +/** Trims whitespace, or the given characters, off the start of a string. */ +export function ltrim(str: string, chars?: string | string[]): string { + if (!str) { + return str; + } + if (chars === undefined) { + return str.replace(/^\s+/, ''); + } + + return str.replace(new RegExp(`^[${escapeChars(chars)}]+`), ''); +} + +/** Trims whitespace, or the given characters, off the end of a string. */ +export function rtrim(str: string, chars?: string | string[]): string { + if (!str) { + return str; + } + if (chars === undefined) { + return str.replace(/\s+$/, ''); + } + + return str.replace(new RegExp(`[${escapeChars(chars)}]+$`), ''); +} + +/** Trims whitespace, or the given characters, off both ends of a string. */ +export function trim(str: string, chars?: string | string[]): string { + if (!str) { + return str; + } + if (chars === undefined) { + return str.trim(); + } + + return rtrim(ltrim(str, chars), chars); +} + +export function startsWith( + str: string, + substr: string, + caseInsensitive = false +): boolean { + return caseInsensitive + ? str.toLowerCase().startsWith(substr.toLowerCase()) + : str.startsWith(substr); +} + +export function endsWith( + str: string, + substr: string, + caseInsensitive = false +): boolean { + return caseInsensitive + ? str.toLowerCase().endsWith(substr.toLowerCase()) + : str.endsWith(substr); +} + +export function ensureStartsWith( + str: string, + substr: string, + caseInsensitive = false +): string { + return startsWith(str, substr, caseInsensitive) ? str : substr + str; +} + +export function ensureEndsWith( + str: string, + substr: string, + caseInsensitive = false +): string { + return endsWith(str, substr, caseInsensitive) ? str : str + substr; +} + +export function removeLeft( + str: string, + substr: string, + caseInsensitive = false +): string { + return startsWith(str, substr, caseInsensitive) + ? str.slice(substr.length) + : str; +} + +export function removeRight( + str: string, + substr: string, + caseInsensitive = false +): string { + return endsWith(str, substr, caseInsensitive) + ? str.slice(0, -substr.length) + : str; +} + +export function uppercaseFirst(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +export function lowercaseFirst(str: string): string { + return str.charAt(0).toLowerCase() + str.slice(1); +} + +export function uuid(): string { + if (typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + + return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c) => { + const [byte = 0] = crypto.getRandomValues(new Uint8Array(1)); + + return (Number(c) ^ (byte & (15 >> (Number(c) / 4)))).toString(16); + }); +} + +/** A random string of ASCII letters and digits. Not for anything secret. */ +export function randomString(length: number): string { + const characters = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * 62)); + } + + return result; +} diff --git a/resources/js/modules/editable-table/editable-table.ts b/resources/js/modules/editable-table/editable-table.ts index 962bbbbf9ca..4559f7c84dc 100644 --- a/resources/js/modules/editable-table/editable-table.ts +++ b/resources/js/modules/editable-table/editable-table.ts @@ -19,6 +19,7 @@ import type { EditableTableSettings, } from './types'; import {type ReorderDirection} from '@craftcms/ui'; +import {trim, uuid} from '@/common/craft/strings'; // `Craft`, `$` (jQuery), and `Garnish` remain page globals. This class extends the // modern `@craftcms/garnish` `Base` but still orchestrates jQuery Craft/Garnish @@ -915,7 +916,7 @@ export class EditableTable extends Base { $('', { type: 'hidden', name: `${baseName}[${rowId}][rowId]`, - value: values.rowId ?? Craft.uuid(), + value: values.rowId ?? uuid(), }).appendTo($tr); } @@ -1305,7 +1306,7 @@ export class Row extends Base { handlePaste(ev: any): void { // Native listener, so the event is native — read `clipboardData` directly. - const data = Craft.trim(ev.clipboardData.getData('Text'), ' \n\r'); + const data = trim(ev.clipboardData.getData('Text'), ' \n\r'); if (!data.match(/[\t\r\n]/)) { return; } diff --git a/resources/js/modules/field-layout-designer/element.ts b/resources/js/modules/field-layout-designer/element.ts index 6f51e033bf1..2dab7200a13 100644 --- a/resources/js/modules/field-layout-designer/element.ts +++ b/resources/js/modules/field-layout-designer/element.ts @@ -8,6 +8,7 @@ import { import type {Tab} from './tab'; import {type ActionMenuItem, t} from '@craftcms/ui'; import type {UiValues} from '@/modules/ui/types'; +import {uuid} from '@/common/craft/strings'; declare const Craft: any; @@ -44,7 +45,7 @@ export class Element extends Base { // New element? const isNew = !this.uid; if (isNew) { - this.uid = Craft.uuid(); + this.uid = uuid(); this.config = Object.assign(JSON.parse($container.dataset.config), { uid: this.uid, }); diff --git a/resources/js/modules/field-layout-designer/tab.ts b/resources/js/modules/field-layout-designer/tab.ts index 907f8977f73..9a88039f37f 100644 --- a/resources/js/modules/field-layout-designer/tab.ts +++ b/resources/js/modules/field-layout-designer/tab.ts @@ -12,6 +12,7 @@ import { import {type ActionMenuItem} from '@craftcms/ui'; import type {UiValues} from '@/modules/ui/types'; import {createInitialIcon} from '@/common/utils/initialIcon'; +import {uuid} from '@/common/craft/strings'; declare const Craft: any; declare const $: any; @@ -40,7 +41,7 @@ export class Tab extends Base { // New tab? if (!this.uid) { - this.uid = Craft.uuid(); + this.uid = uuid(); this.config = { uid: this.uid, name: diff --git a/resources/js/modules/grouped-entry-type-manager/grouped-entry-type-manager.ts b/resources/js/modules/grouped-entry-type-manager/grouped-entry-type-manager.ts index eb4e246a020..5ee84bd01a3 100644 --- a/resources/js/modules/grouped-entry-type-manager/grouped-entry-type-manager.ts +++ b/resources/js/modules/grouped-entry-type-manager/grouped-entry-type-manager.ts @@ -15,6 +15,7 @@ import type { } from '@/modules/component-select'; import {editEntryTypeOverrides} from './entry-type-override-settings'; import {groupedEntryTypeManagerData} from './support'; +import {namespaceId, namespaceInputName} from '@/common/craft/namespace'; declare const Craft: any; declare const $: any; @@ -334,8 +335,8 @@ export class GroupedEntryTypeManager extends Base { hasPrefix(value: string): boolean { value = value.toLowerCase(); for (const prefix of this.settings!.prefixes ?? []) { - if (Craft.startsWith(value, prefix, true)) { + if (startsWith(value, prefix, true)) { return true; } } @@ -124,23 +130,20 @@ export class LinkInput extends Base { removePrefix(value: string): string { for (const prefix of this.settings!.prefixes ?? []) { - value = Craft.removeLeft(value, prefix, true); + value = removeLeft(value, prefix, true); } return value; } removeFirstPrefix(value: string): string { - const prefixes = this.settings!.prefixes ?? []; - if (prefixes.length) { - return Craft.removeLeft(value, prefixes[0], true); - } - return value; + const [prefix] = this.settings!.prefixes ?? []; + return prefix === undefined ? value : removeLeft(value, prefix, true); } createChip(value: string): void { let label = this.removePrefix(value); if (label.match(/^[^/]+\/$/)) { - label = Craft.removeRight(label, '/'); + label = removeRight(label, '/'); } this.reset(); @@ -220,7 +223,7 @@ export class LinkInput extends Base { } normalize(value: string): string { - value = Craft.trim(value); + value = trim(value); if (!value) { return ''; } From 54ea4bb8de1efd0a7069c2f2998ae26f6616932f Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Wed, 28 Oct 2026 20:26:49 -0500 Subject: [PATCH 09/51] Move Craft.js delta-data helpers to TypeScript findDeltaData, findModifiedDeltaNames, groupParams and the private _groupParamsByDeltaNames now live in resources/js/common/craft/delta.ts, with a jQuery-compatible param() serializer in params.ts that's tested against $.param itself. Craft.js assigns them for plugins, and the CP modal, CP screen slideout and inline editing import them directly. The inline editing test now runs the real delta logic instead of a stub that returned the expected result. Co-Authored-By: Claude Opus 5.5 --- packages/craftcms-legacy/cp/src/js/Craft.js | 236 +----------------- resources/js/common/craft/delta.test.ts | 67 +++++ resources/js/common/craft/delta.ts | 226 +++++++++++++++++ resources/js/common/craft/params.ts | 113 +++++++++ resources/js/modules/cp-modal/cp-modal.ts | 3 +- .../composables/useInlineEditing.test.ts | 9 +- .../index/composables/useInlineEditing.ts | 3 +- .../js/modules/slideout/cp-screen-slideout.ts | 3 +- 8 files changed, 423 insertions(+), 237 deletions(-) create mode 100644 resources/js/common/craft/delta.test.ts create mode 100644 resources/js/common/craft/delta.ts create mode 100644 resources/js/common/craft/params.ts diff --git a/packages/craftcms-legacy/cp/src/js/Craft.js b/packages/craftcms-legacy/cp/src/js/Craft.js index 514f09558b7..5219d04292a 100644 --- a/packages/craftcms-legacy/cp/src/js/Craft.js +++ b/packages/craftcms-legacy/cp/src/js/Craft.js @@ -2,6 +2,12 @@ import {formatMessage, t} from '@craftcms/ui/utilities/translate'; import {createHttpClient, isCancel} from '@craftcms/ui/utilities/api/http'; import {sendLegacyRequest} from '@craftcms/ui/utilities/api/legacyRequest'; import * as d3 from 'd3'; +import { + findDeltaData, + findModifiedDeltaNames, + groupParams, + groupParamsByDeltaNames, +} from '../../../../../resources/js/common/craft/delta'; import { formatInputId, namespaceId, @@ -890,235 +896,13 @@ $.extend(Craft, { return arr; }, - /** - * @callback findDeltaDataCallback - * @param {string} deltaName - * @param {Array} params - */ - /** - * Compares old and new post data, and removes any values that haven't - * changed within the given list of delta namespaces. - * - * @param {string} oldData - * @param {string} newData - * @param {Object} deltaNames - * @param {findDeltaDataCallback|null} [callback] Callback function that should be called whenever a new group of modified params has been found - * @param {Object} [initialDeltaValues] Initial delta values. If undefined, `Craft.initialDeltaValues` will be used. - * @param {Object} [forceModifiedDeltaNames] List of delta names that should be considered modified regardless of their param values - * @param {boolean} [asArray] Whether the params should be returned as an array - * @returns {string} - */ - findDeltaData: function ( - oldData, - newData, - deltaNames, - callback = null, - initialDeltaValues = {}, - forceModifiedDeltaNames = [], - asArray = false - ) { - const [modifiedDeltaNames, groupedNewParams] = this.findModifiedDeltaNames( - oldData, - newData, - deltaNames, - initialDeltaValues, - forceModifiedDeltaNames - ); - - // Figure out which of the new params should actually be posted - let params = groupedNewParams.__root__; - for (const name of modifiedDeltaNames) { - params = params.concat(groupedNewParams[name]); - params.push(`modifiedDeltaNames[]=${name}`); - if (callback) { - callback(name, groupedNewParams[name]); - } - } - - return asArray ? params : params.join('&'); - }, - - /** - * Returns the delta names that have been modified, given old and new form data. - * - * @param {string} oldData - * @param {string} newData - * @param {Object} deltaNames - * @param {Object} [initialDeltaValues] Initial delta values. If undefined, `Craft.initialDeltaValues` will be used. - * @param {Object} [modifiedDeltaNames] List of delta names that should be considered modified regardless of their param values - * @param {boolean} [mostSpecific] Whether the most specific modified delta names should be returned - * @returns {Array} - */ - findModifiedDeltaNames: function ( - oldData, - newData, - deltaNames, - initialDeltaValues = {}, - modifiedDeltaNames = [], - mostSpecific = false - ) { - // Make sure oldData and newData are always strings. This is important because further below String.split is called. - oldData = typeof oldData === 'string' ? oldData : ''; - newData = typeof newData === 'string' ? newData : ''; - deltaNames = Array.isArray(deltaNames) ? [...deltaNames] : []; - initialDeltaValues = $.isPlainObject(initialDeltaValues) - ? initialDeltaValues - : {}; - modifiedDeltaNames = Array.isArray(modifiedDeltaNames) - ? [...modifiedDeltaNames] - : []; - - // Sort the delta namespaces from least -> most specific - deltaNames.sort((a, b) => { - if (a.length === b.length) { - return 0; - } - if (mostSpecific) { - return a.length < b.length ? 1 : -1; - } - return a.length > b.length ? 1 : -1; - }); - - // Group all the old & new params by namespace - const groupedOldParams = this._groupParamsByDeltaNames( - oldData, - deltaNames, - false, - initialDeltaValues - ); - const groupedNewParams = this._groupParamsByDeltaNames( - newData, - deltaNames, - true, - false - ); - - for (const name of deltaNames) { - if ( - !modifiedDeltaNames.includes(name) && - typeof groupedNewParams[name] === 'object' && - (typeof groupedOldParams[name] !== 'object' || - JSON.stringify(groupedOldParams[name]) !== - JSON.stringify(groupedNewParams[name])) - ) { - modifiedDeltaNames.push(name); - } - } - - // Sort the delta namespaces from least -> most specific - modifiedDeltaNames.sort((a, b) => { - if (a.length === b.length) { - return 0; - } - if (mostSpecific) { - return a.length < b.length ? 1 : -1; - } - return a.length > b.length ? 1 : -1; - }); - - return [modifiedDeltaNames, groupedNewParams]; - }, - - /** - * @param {string|Object} params - * @param {Object} deltaNames - * @param {boolean} [withRoot] - * @returns {Object} - */ - groupParams: function (params, deltaNames, withRoot = false) { - if (typeof params === 'string') { - params = params.split('&'); - } + findDeltaData, - const grouped = {}; + findModifiedDeltaNames, - if (withRoot) { - grouped.__root__ = []; - } - - // sort delta names from most to least specific - deltaNames = deltaNames.sort((a, b) => b.length - a.length); - - for (const name of deltaNames) { - grouped[name] = []; - } + groupParams, - const encodeURIComponentExceptEqualChar = (o) => - encodeURIComponent(o).replace('%3D', '='); - - params = params.map((p) => decodeURIComponent(p)); - - paramLoop: for (const param of params) { - for (const name of deltaNames) { - const paramName = param.substring(0, name.length + 1); - if ([`${name}=`, `${name}[`].includes(paramName)) { - if (typeof grouped[name] === 'undefined') { - grouped[name] = []; - } - grouped[name].push(encodeURIComponentExceptEqualChar(param)); - continue paramLoop; - } - } - - if (withRoot) { - grouped.__root__.push(encodeURIComponentExceptEqualChar(param)); - } - } - - return grouped; - }, - - /** - * @param {string|Object} params - * @param {Object} deltaNames - * @param {boolean} withRoot - * @param {(boolean|Object)} initialValues - * @returns {Object} - * @private - */ - _groupParamsByDeltaNames: function ( - params, - deltaNames, - withRoot, - initialValues - ) { - const grouped = this.groupParams(params, deltaNames, withRoot); - - if (initialValues) { - const serializeParam = (name, value) => { - if (Array.isArray(value) || $.isPlainObject(value)) { - value = $.param(value); - } else if (typeof value === 'string') { - value = encodeURIComponent(value); - } else if (value === null) { - value = ''; - } - return `${encodeURIComponent(name)}=${value}`; - }; - - for (const name in initialValues) { - if (initialValues.hasOwnProperty(name)) { - if ($.isPlainObject(initialValues[name])) { - grouped[name] = []; - for (const subName in initialValues[name]) { - if (initialValues[name].hasOwnProperty(subName)) { - grouped[name].push( - serializeParam( - `${name}[${subName}]`, - initialValues[name][subName] - ) - ); - } - } - } else { - grouped[name] = [serializeParam(name, initialValues[name])]; - } - } - } - } - - return grouped; - }, + _groupParamsByDeltaNames: groupParamsByDeltaNames, /** * Expands an object of POST array-style strings into an actual array. diff --git a/resources/js/common/craft/delta.test.ts b/resources/js/common/craft/delta.test.ts new file mode 100644 index 00000000000..c487a825c24 --- /dev/null +++ b/resources/js/common/craft/delta.test.ts @@ -0,0 +1,67 @@ +import $ from 'jquery'; +import {describe, expect, it} from 'vite-plus/test'; +import {findDeltaData} from './delta'; +import {param} from './params'; + +describe('param', () => { + // Saved delta values and the server's bracketed param parsing were built + // against jQuery's encoding, so it's the reference. + it.each([ + {title: 'A & B', count: 3, empty: null, missing: undefined}, + {ids: [1, 2, 3], tags: ['a b', 'c=d']}, + {fields: {body: 'x', blocks: [{type: 'text', value: 'hi'}, ['nested']]}}, + {'already[]': [1, 2], flag: true, fn: () => 'called'}, + {date: new Date(0), regex: /x/}, + ])('encodes %o the way jQuery does', (value) => { + expect(param(value)).toBe($.param(value)); + }); + + it('matches jQuery for a list of form fields', () => { + const fields = [ + {name: 'a', value: '1'}, + {name: 'b[]', value: 'x y'}, + ]; + + expect(param(fields)).toBe($.param(fields)); + }); +}); + +describe('findDeltaData', () => { + const deltaNames = ['fields[body]', 'fields[summary]', 'title']; + + it('posts everything outside the delta names and only the groups that changed', () => { + const before = + 'action=save&title=Old&fields%5Bbody%5D=same&fields%5Bsummary%5D=old'; + const after = + 'action=save&title=New&fields%5Bbody%5D=same&fields%5Bsummary%5D=new'; + + expect( + findDeltaData(before, after, deltaNames, null, {}, [], true) + ).toEqual([ + 'action=save', + 'title=New', + 'modifiedDeltaNames[]=title', + 'fields%5Bsummary%5D=new', + 'modifiedDeltaNames[]=fields[summary]', + ]); + }); + + it('compares against initial values the form started with', () => { + const after = 'title=Old&fields%5Bbody%5D=changed'; + + expect( + findDeltaData('', after, deltaNames, null, { + title: 'Old', + 'fields[body]': 'original', + }) + ).toBe('fields%5Bbody%5D=changed&modifiedDeltaNames[]=fields[body]'); + }); + + it('posts a group the caller forces, even when unchanged', () => { + const data = 'title=Same'; + + expect(findDeltaData(data, data, deltaNames, null, {}, ['title'])).toBe( + 'title=Same&modifiedDeltaNames[]=title' + ); + }); +}); diff --git a/resources/js/common/craft/delta.ts b/resources/js/common/craft/delta.ts new file mode 100644 index 00000000000..71f85600212 --- /dev/null +++ b/resources/js/common/craft/delta.ts @@ -0,0 +1,226 @@ +import {isPlainObject, param} from './params'; + +/** + * Delta form submissions: posting only the parts of a form that changed. + * + * Form data is grouped by "delta names" (field or setting namespaces). A group + * whose serialized params changed is posted along with a `modifiedDeltaNames[]` + * entry for it; unchanged groups are left out. + */ + +export type GroupedParams = Record; + +export type DeltaCallback = (name: string, params: string[]) => void; + +function sortBySpecificity(names: string[], mostSpecific: boolean): void { + names.sort((a, b) => { + if (a.length === b.length) { + return 0; + } + if (mostSpecific) { + return a.length < b.length ? 1 : -1; + } + return a.length > b.length ? 1 : -1; + }); +} + +/** + * Groups serialized params by the delta name they belong to. Params outside + * every delta name go under `__root__` when `withRoot` is set. + * + * Sorts `deltaNames` in place, most specific first. + */ +export function groupParams( + params: string | string[], + deltaNames: string[], + withRoot = false +): GroupedParams { + const list = typeof params === 'string' ? params.split('&') : params; + const grouped: GroupedParams = {}; + + if (withRoot) { + grouped.__root__ = []; + } + + deltaNames.sort((a, b) => b.length - a.length); + + for (const name of deltaNames) { + grouped[name] = []; + } + + const encode = (o: string) => encodeURIComponent(o).replace('%3D', '='); + + paramLoop: for (const p of list.map((p) => decodeURIComponent(p))) { + for (const name of deltaNames) { + const paramName = p.substring(0, name.length + 1); + if ([`${name}=`, `${name}[`].includes(paramName)) { + (grouped[name] ??= []).push(encode(p)); + continue paramLoop; + } + } + + if (withRoot) { + grouped.__root__!.push(encode(p)); + } + } + + return grouped; +} + +/** + * {@link groupParams}, with the groups for `initialValues` replaced by those + * values, serialized the way the form would have posted them. + */ +export function groupParamsByDeltaNames( + params: string | string[], + deltaNames: string[], + withRoot: boolean, + initialValues: Record | false +): GroupedParams { + const grouped = groupParams(params, deltaNames, withRoot); + + if (!initialValues) { + return grouped; + } + + const serializeParam = (name: string, value: unknown): string => { + let serialized = value; + if (Array.isArray(value) || isPlainObject(value)) { + serialized = param(value); + } else if (typeof value === 'string') { + serialized = encodeURIComponent(value); + } else if (value === null) { + serialized = ''; + } + return `${encodeURIComponent(name)}=${String(serialized)}`; + }; + + for (const name in initialValues) { + if (!Object.prototype.hasOwnProperty.call(initialValues, name)) { + continue; + } + + const value = initialValues[name]; + if (isPlainObject(value)) { + grouped[name] = []; + for (const subName in value) { + if (Object.prototype.hasOwnProperty.call(value, subName)) { + grouped[name].push( + serializeParam(`${name}[${subName}]`, value[subName]) + ); + } + } + } else { + grouped[name] = [serializeParam(name, value)]; + } + } + + return grouped; +} + +/** + * Returns the delta names whose params differ between the old and new form + * data, and the new params grouped by delta name. + */ +export function findModifiedDeltaNames( + oldData: unknown, + newData: unknown, + deltaNames: unknown, + initialDeltaValues: unknown = {}, + modifiedDeltaNames: unknown = [], + mostSpecific = false +): [string[], GroupedParams] { + // Both are split on `&` below, so anything else counts as empty. + const oldParams = typeof oldData === 'string' ? oldData : ''; + const newParams = typeof newData === 'string' ? newData : ''; + const names = Array.isArray(deltaNames) ? [...deltaNames] : []; + const initialValues = isPlainObject(initialDeltaValues) + ? initialDeltaValues + : {}; + const modified: string[] = Array.isArray(modifiedDeltaNames) + ? [...modifiedDeltaNames] + : []; + + sortBySpecificity(names, mostSpecific); + + const groupedOldParams = groupParamsByDeltaNames( + oldParams, + names, + false, + initialValues + ); + const groupedNewParams = groupParamsByDeltaNames( + newParams, + names, + true, + false + ); + + for (const name of names) { + if ( + !modified.includes(name) && + typeof groupedNewParams[name] === 'object' && + (typeof groupedOldParams[name] !== 'object' || + JSON.stringify(groupedOldParams[name]) !== + JSON.stringify(groupedNewParams[name])) + ) { + modified.push(name); + } + } + + sortBySpecificity(modified, mostSpecific); + + return [modified, groupedNewParams]; +} + +/** + * Returns the params that should be posted: everything outside the delta + * names, plus the groups that changed and a `modifiedDeltaNames[]` entry for + * each. + */ +export function findDeltaData( + oldData: unknown, + newData: unknown, + deltaNames: unknown, + callback?: DeltaCallback | null, + initialDeltaValues?: unknown, + forceModifiedDeltaNames?: unknown, + asArray?: false +): string; +export function findDeltaData( + oldData: unknown, + newData: unknown, + deltaNames: unknown, + callback: DeltaCallback | null | undefined, + initialDeltaValues: unknown, + forceModifiedDeltaNames: unknown, + asArray: true +): string[]; +export function findDeltaData( + oldData: unknown, + newData: unknown, + deltaNames: unknown, + callback: DeltaCallback | null = null, + initialDeltaValues: unknown = {}, + forceModifiedDeltaNames: unknown = [], + asArray = false +): string | string[] { + const [modifiedDeltaNames, groupedNewParams] = findModifiedDeltaNames( + oldData, + newData, + deltaNames, + initialDeltaValues, + forceModifiedDeltaNames + ); + + let params = groupedNewParams.__root__!; + for (const name of modifiedDeltaNames) { + params = params.concat(groupedNewParams[name]!); + params.push(`modifiedDeltaNames[]=${name}`); + if (callback) { + callback(name, groupedNewParams[name]!); + } + } + + return asArray ? params : params.join('&'); +} diff --git a/resources/js/common/craft/params.ts b/resources/js/common/craft/params.ts new file mode 100644 index 00000000000..26d42bf4b1c --- /dev/null +++ b/resources/js/common/craft/params.ts @@ -0,0 +1,113 @@ +/** + * Query-string serialization compatible with jQuery's `$.param()`, which the + * server's bracketed param parsing and saved delta data both depend on. + */ + +const nonObjectTags = new Set([ + '[object Boolean]', + '[object Number]', + '[object String]', + '[object Function]', + '[object Array]', + '[object Date]', + '[object RegExp]', + '[object Error]', + '[object Symbol]', +]); + +/** Whether a value is a plain `{}` object, as jQuery's `isPlainObject()` sees it. */ +export function isPlainObject( + value: unknown +): value is Record { + if (Object.prototype.toString.call(value) !== '[object Object]') { + return false; + } + + const proto = Object.getPrototypeOf(value); + if (proto === null) { + return true; + } + + const ctor = + Object.prototype.hasOwnProperty.call(proto, 'constructor') && + proto.constructor; + + return ( + typeof ctor === 'function' && + Function.prototype.toString.call(ctor) === + Function.prototype.toString.call(Object) + ); +} + +function isObjectLike(value: unknown): value is Record { + return ( + value !== null && + typeof value === 'object' && + !nonObjectTags.has(Object.prototype.toString.call(value)) + ); +} + +type Add = (key: string, value: unknown) => void; + +function buildParams( + prefix: string, + obj: unknown, + traditional: boolean, + add: Add +): void { + if (Array.isArray(obj)) { + obj.forEach((v, i) => { + if (traditional || prefix.endsWith('[]')) { + add(prefix, v); + } else { + // Only non-scalar items get their index in the key. + const index = typeof v === 'object' && v != null ? i : ''; + buildParams(`${prefix}[${index}]`, v, traditional, add); + } + }); + } else if (!traditional && isObjectLike(obj)) { + for (const name in obj) { + buildParams(`${prefix}[${name}]`, obj[name], traditional, add); + } + } else { + add(prefix, obj); + } +} + +/** + * Serializes an object into a query string, nesting with brackets + * (`a[b][]=1`). An array is read as a list of `{name, value}` form fields. + */ +export function param(a: unknown, traditional = false): string { + const s: string[] = []; + const add: Add = (key, valueOrFunction) => { + const value = + typeof valueOrFunction === 'function' + ? valueOrFunction() + : valueOrFunction; + s.push( + `${encodeURIComponent(key)}=${encodeURIComponent(value == null ? '' : String(value))}` + ); + }; + + if (a == null) { + return ''; + } + + if (Array.isArray(a)) { + for (const field of a) { + add(field?.name, field?.value); + } + } else { + for (const prefix in a as Record) { + buildParams( + prefix, + (a as Record)[prefix], + traditional, + add + ); + } + } + + return s.join('&'); +} diff --git a/resources/js/modules/cp-modal/cp-modal.ts b/resources/js/modules/cp-modal/cp-modal.ts index 6e09f981e23..e84797fe4b4 100644 --- a/resources/js/modules/cp-modal/cp-modal.ts +++ b/resources/js/modules/cp-modal/cp-modal.ts @@ -3,6 +3,7 @@ import {inputName} from '@/modules/ui/runtime'; import type {UiValues} from '@/modules/ui/types'; import {isHttpError} from '@craftcms/ui/utilities/api/http'; import type {LegacyRequestOptions} from '@craftcms/ui/utilities/api/legacyRequest'; +import {findDeltaData} from '@/common/craft/delta'; declare const Craft: any; declare const $: any; @@ -298,7 +299,7 @@ export class CpModal extends Base { submit(): void { this.showSubmitSpinner(); - const data = Craft.findDeltaData( + const data = findDeltaData( this.#$container.data('initialSerializedValue'), this.#$container.serialize(), this.#$container.data('delta-names'), diff --git a/resources/js/modules/elements/index/composables/useInlineEditing.test.ts b/resources/js/modules/elements/index/composables/useInlineEditing.test.ts index e15df36c484..5972db0c64e 100644 --- a/resources/js/modules/elements/index/composables/useInlineEditing.test.ts +++ b/resources/js/modules/elements/index/composables/useInlineEditing.test.ts @@ -40,13 +40,7 @@ describe('useInlineEditing', () => { const load = vi.fn(async () => {}); const displayNotice = vi.fn(); const displayError = vi.fn(); - vi.stubGlobal('Craft', { - findDeltaData: vi.fn( - (_before: string, after: string) => - `${after}&modifiedDeltaNames[]=inline%5Belement-11%5D` - ), - cp: {displayNotice, displayError}, - }); + vi.stubGlobal('Craft', {cp: {displayNotice, displayError}}); const state = useInlineEditing({ container: ref(container), editableRows: [ @@ -77,7 +71,6 @@ describe('useInlineEditing', () => { it('closes without a request when no inputs changed', async () => { const {state, save, displayNotice} = setup(); await state.start(); - window.Craft.findDeltaData = vi.fn(() => ''); await state.save(); diff --git a/resources/js/modules/elements/index/composables/useInlineEditing.ts b/resources/js/modules/elements/index/composables/useInlineEditing.ts index b6ec81c974a..6c07645bc42 100644 --- a/resources/js/modules/elements/index/composables/useInlineEditing.ts +++ b/resources/js/modules/elements/index/composables/useInlineEditing.ts @@ -5,6 +5,7 @@ import type {CraftTableFeatures} from '@/common/table/craftTable'; import {h, nextTick, ref, toValue, type MaybeRefOrGetter, type Ref} from 'vue'; import DynamicHtmlRenderer from '@/common/components/DynamicHtmlRenderer.vue'; import type {InlineAttributeUiHost} from '@/modules/ui/inline-attribute-ui-host'; +import {findDeltaData} from '@/common/craft/delta'; export type InlineEditingErrors = Record< string | number, @@ -124,7 +125,7 @@ export function useInlineEditing(options: { const submittedInputs = serializeFormInputs(container); const body = new URLSearchParams( - window.Craft.findDeltaData( + findDeltaData( initialInputs.replaceAll('+', '%20'), submittedInputs.replaceAll('+', '%20'), editingIds.value.map((id) => `inline[element-${id}]`) diff --git a/resources/js/modules/slideout/cp-screen-slideout.ts b/resources/js/modules/slideout/cp-screen-slideout.ts index 62b7e3ed6f3..eda6ee39b7f 100644 --- a/resources/js/modules/slideout/cp-screen-slideout.ts +++ b/resources/js/modules/slideout/cp-screen-slideout.ts @@ -34,6 +34,7 @@ import { sendLegacyRequest, type LegacyRequestOptions, } from '@craftcms/ui/utilities/api/legacyRequest'; +import {findDeltaData} from '@/common/craft/delta'; declare const Craft: any; declare const $: any; @@ -690,7 +691,7 @@ export class CpScreenSlideout extends Slideout { submit(): void { this.showSubmitSpinner(); - const data = Craft.findDeltaData( + const data = findDeltaData( this.$container.data('initialSerializedValue'), this.$container.serialize(), this.$container.data('delta-names'), From daad1310c624073e556b6624b9e7232f58b81bc0 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Wed, 28 Oct 2026 20:37:52 -0500 Subject: [PATCH 10/51] Move Craft.js URL helpers to TypeScript getUrl, getCpUrl, getSiteUrl and getActionUrl now live in resources/js/common/craft/url.ts, read their base URLs from the CP config at call time, and encode params with the jQuery-compatible param(). The script-name and path-param branches are gone: Craft 6 never sends pathParam or scriptName, so they couldn't change the result. Output was checked against the legacy implementation. Craft.js assigns them for plugins, and the modern modules that called Craft.getCpUrl()/getActionUrl() import them. Their tests now set the base URLs rather than stubbing the helpers. Co-Authored-By: Claude Opus 5.5 --- packages/craftcms-legacy/cp/src/js/Craft.js | 166 ++---------------- resources/js/common/craft/url.test.ts | 44 +++++ resources/js/common/craft/url.ts | 136 ++++++++++++++ .../slideouts/create-element-editor.test.ts | 5 +- .../common/slideouts/create-element-editor.ts | 3 +- .../component-select/component-select.ts | 3 +- .../base-element-select-input.ts | 3 +- .../entry-select-input.ts | 3 +- .../composables/useElementActionMenu.test.ts | 2 +- .../composables/useElementActionMenu.ts | 3 +- .../field-layout-designer.test.ts | 8 +- .../field-layout-designer.ts | 3 +- resources/js/modules/fields/index.test.ts | 5 +- resources/js/modules/fields/index.ts | 5 +- .../components/cp-queue-indicator.ts | 3 +- .../resources/js/matrix/matrix-entry.ts | 5 +- 16 files changed, 215 insertions(+), 182 deletions(-) create mode 100644 resources/js/common/craft/url.test.ts create mode 100644 resources/js/common/craft/url.ts diff --git a/packages/craftcms-legacy/cp/src/js/Craft.js b/packages/craftcms-legacy/cp/src/js/Craft.js index 5219d04292a..d0acc98a8dd 100644 --- a/packages/craftcms-legacy/cp/src/js/Craft.js +++ b/packages/craftcms-legacy/cp/src/js/Craft.js @@ -2,6 +2,12 @@ import {formatMessage, t} from '@craftcms/ui/utilities/translate'; import {createHttpClient, isCancel} from '@craftcms/ui/utilities/api/http'; import {sendLegacyRequest} from '@craftcms/ui/utilities/api/legacyRequest'; import * as d3 from 'd3'; +import { + getActionUrl, + getCpUrl, + getSiteUrl, + getUrl, +} from '../../../../../resources/js/common/craft/url'; import { findDeltaData, findModifiedDeltaNames, @@ -240,165 +246,13 @@ $.extend(Craft, { }); }, - /** - * @param {string} [path] - * @param {(Object|string)} [params] - * @param {string} [baseUrl] - * @returns {string} - */ - getUrl: function (path, params, baseUrl) { - if (typeof path !== 'string') { - path = ''; - } - - // Normalize the params - let anchor = null; - if ($.isPlainObject(params)) { - if (typeof params['#'] !== 'undefined') { - anchor = params['#']; - delete params['#']; - } - } else if (typeof params === 'string') { - let anchorPos = params.indexOf('#'); - if (anchorPos !== -1) { - anchor = params.substring(anchorPos + 1); - params = params.substring(0, anchorPos); - } - params = Object.fromEntries(new URLSearchParams(params).entries()); - } else { - params = {}; - } - - // Was there already an anchor on the path? - let anchorPos = path.indexOf('#'); - if (anchorPos !== -1) { - // Only keep it if the params didn't specify a new anchor - if (!anchor) { - anchor = path.substring(anchorPos + 1); - } - path = path.substring(0, anchorPos); - } - - // Were there already any query string params in the path? - let qsPos = path.indexOf('?'); - if (qsPos !== -1) { - params = $.extend( - Object.fromEntries( - new URLSearchParams(path.substring(qsPos + 1)).entries() - ), - params - ); - path = path.substring(0, qsPos); - } - - // Return path if it appears to be an absolute URL. - if (path.search('://') !== -1 || path[0] === '/') { - return ( - path + - (!$.isEmptyObject(params) ? `?${$.param(params)}` : '') + - (anchor ? `#${anchor}` : '') - ); - } - - path = Craft.trim(path, '/'); - - // Put it all together - let url; - - if (baseUrl) { - url = baseUrl; + getUrl, - if (path && Craft.pathParam) { - // Does baseUrl already contain a path? - var pathMatch = url.match( - new RegExp('[&?]' + Craft.escapeRegex(Craft.pathParam) + '=[^&]+') - ); - if (pathMatch) { - url = url.replace( - pathMatch[0], - Craft.rtrim(pathMatch[0], '/') + '/' + path - ); - path = ''; - } - } - } else { - url = Craft.baseUrl; - } + getCpUrl, - // Does the base URL already have a query string? - qsPos = url.indexOf('?'); - let baseParams; - if (qsPos !== -1) { - baseParams = Object.fromEntries( - new URLSearchParams(url.substring(qsPos + 1)).entries() - ); - url = url.substring(0, qsPos); - params = Object.assign({}, baseParams, params); - } + getSiteUrl, - if (!Craft.omitScriptNameInUrls && path) { - if (Craft.usePathInfo || !Craft.pathParam) { - // Make sure that the script name is in the URL - if (url.search(Craft.scriptName) === -1) { - url = Craft.rtrim(url, '/') + '/' + Craft.scriptName; - } - } else { - // Move the path into the query string params - if (baseParams && baseParams[Craft.pathParam] !== undefined) { - path = - baseParams[Craft.pathParam].trimEnd() + (path ? '/' + path : ''); - } - params[Craft.pathParam] = path; - path = null; - } - } - - if (path) { - url = Craft.rtrim(url, '/') + '/' + path; - } - - if (!$.isEmptyObject(params)) { - url += `?${$.param(params)}`; - } - - if (anchor) { - url += `#${anchor}`; - } - - return url; - }, - - /** - * @param {string} [path] - * @param {(Object|string)} [params] - * @returns {string} - */ - getCpUrl: function (path, params) { - if (!Craft.baseCpUrl) { - throw 'Craft.baseCpUrl is undefined.'; - } - return this.getUrl(path, params, Craft.baseCpUrl); - }, - - /** - * @param {string} [path] - * @param {(Object|string)} [params] - * @returns {string} - */ - getSiteUrl: function (path, params) { - return this.getUrl(path, params, Craft.baseSiteUrl); - }, - - /** - * Returns an action URL. - * - * @param {string} action - * @param {(Object|string)} [params] - * @returns {string} - */ - getActionUrl: function (action, params) { - return Craft.getUrl(action, params, Craft.actionUrl); - }, + getActionUrl, /** * Redirects the window to a given URL. diff --git a/resources/js/common/craft/url.test.ts b/resources/js/common/craft/url.test.ts new file mode 100644 index 00000000000..91a0430acd4 --- /dev/null +++ b/resources/js/common/craft/url.test.ts @@ -0,0 +1,44 @@ +import {afterEach, beforeEach, expect, it, vi} from 'vite-plus/test'; +import {getActionUrl, getCpUrl, getUrl} from './url'; + +beforeEach(() => { + vi.stubGlobal('Craft', { + baseUrl: 'https://craft.test/', + baseCpUrl: 'https://craft.test/admin', + actionUrl: 'https://craft.test/index.php?p=actions', + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +it.each([ + ['blog/', undefined, 'https://craft.test/blog'], + [ + 'blog?page=2#top', + {sort: 'asc'}, + 'https://craft.test/blog?page=2&sort=asc#top', + ], + ['blog#old', {'#': 'new'}, 'https://craft.test/blog#new'], + ['blog', 'tag=a b#tags', 'https://craft.test/blog?tag=a%20b#tags'], + ['blog', {ids: [1, 2]}, 'https://craft.test/blog?ids%5B%5D=1&ids%5B%5D=2'], + ['/already/absolute', {a: 1}, '/already/absolute?a=1'], + ['https://elsewhere.test/x', undefined, 'https://elsewhere.test/x'], +])('builds %s with %o', (path, params, expected) => { + expect(getUrl(path, params)).toBe(expected); +}); + +it('keeps the base URL’s own query string', () => { + expect(getActionUrl('elements/edit', {elementId: 5})).toBe( + 'https://craft.test/index.php/elements/edit?p=actions&elementId=5' + ); +}); + +it('requires a control panel base URL', () => { + expect(getCpUrl('entries')).toBe('https://craft.test/admin/entries'); + + vi.stubGlobal('Craft', {}); + + expect(() => getCpUrl('entries')).toThrow('Craft.baseCpUrl is undefined.'); +}); diff --git a/resources/js/common/craft/url.ts b/resources/js/common/craft/url.ts new file mode 100644 index 00000000000..4d8dfeece96 --- /dev/null +++ b/resources/js/common/craft/url.ts @@ -0,0 +1,136 @@ +import {isPlainObject, param} from './params'; +import {rtrim, trim} from './strings'; + +type UrlParams = Record | string; + +interface UrlConfig { + baseUrl?: string; + baseCpUrl?: string; + baseSiteUrl?: string; + actionUrl?: string; +} + +function config(): UrlConfig { + return (window as {Craft?: UrlConfig}).Craft ?? {}; +} + +function queryParams(query: string): Record { + const params: Record = {}; + new URLSearchParams(query).forEach((value, key) => { + params[key] = value; + }); + + return params; +} + +function isEmpty(obj: Record): boolean { + for (const _ in obj) { + return false; + } + + return true; +} + +/** + * Builds a URL from a path, query params and a base URL (the site's base URL + * by default). Absolute paths and URLs are returned with just the params and + * anchor applied. A `#` key in object params sets the anchor. + */ +export function getUrl( + path?: string, + params?: UrlParams, + baseUrl?: string +): string { + if (typeof path !== 'string') { + path = ''; + } + + // Normalize the params + let anchor: string | null = null; + let normalized: Record; + if (isPlainObject(params)) { + if (params['#'] !== undefined) { + anchor = String(params['#']); + delete params['#']; + } + normalized = params; + } else if (typeof params === 'string') { + const anchorPos = params.indexOf('#'); + if (anchorPos !== -1) { + anchor = params.substring(anchorPos + 1); + params = params.substring(0, anchorPos); + } + normalized = queryParams(params); + } else { + normalized = {}; + } + + // An anchor on the path is kept unless the params set a new one + const anchorPos = path.indexOf('#'); + if (anchorPos !== -1) { + anchor ||= path.substring(anchorPos + 1); + path = path.substring(0, anchorPos); + } + + // Merge any query string already on the path + let qsPos = path.indexOf('?'); + if (qsPos !== -1) { + const pathParams = queryParams(path.substring(qsPos + 1)); + for (const [key, value] of Object.entries(normalized)) { + if (value !== undefined) { + pathParams[key] = value; + } + } + normalized = pathParams; + path = path.substring(0, qsPos); + } + + const suffix = () => + (!isEmpty(normalized) ? `?${param(normalized)}` : '') + + (anchor ? `#${anchor}` : ''); + + // Absolute URLs and paths only get the params and anchor + if (path.search('://') !== -1 || path[0] === '/') { + return path + suffix(); + } + + path = trim(path, '/'); + let url = baseUrl || config().baseUrl || ''; + + // Merge any query string already on the base URL + qsPos = url.indexOf('?'); + if (qsPos !== -1) { + normalized = Object.assign( + {}, + queryParams(url.substring(qsPos + 1)), + normalized + ); + url = url.substring(0, qsPos); + } + + if (path) { + url = `${rtrim(url, '/')}/${path}`; + } + + return url + suffix(); +} + +/** A control panel URL. */ +export function getCpUrl(path?: string, params?: UrlParams): string { + const {baseCpUrl} = config(); + if (!baseCpUrl) { + throw new Error('Craft.baseCpUrl is undefined.'); + } + + return getUrl(path, params, baseCpUrl); +} + +/** A front-end site URL. */ +export function getSiteUrl(path?: string, params?: UrlParams): string { + return getUrl(path, params, config().baseSiteUrl); +} + +/** A controller action URL, such as `getActionUrl('elements/edit')`. */ +export function getActionUrl(action: string, params?: UrlParams): string { + return getUrl(action, params, config().actionUrl); +} diff --git a/resources/js/common/slideouts/create-element-editor.test.ts b/resources/js/common/slideouts/create-element-editor.test.ts index 179710e0e62..e0e0ccf8576 100644 --- a/resources/js/common/slideouts/create-element-editor.test.ts +++ b/resources/js/common/slideouts/create-element-editor.test.ts @@ -16,10 +16,7 @@ function lastOptions(): OpenSlideoutOptions { describe('createElementEditor', () => { beforeEach(() => { vi.stubGlobal('$', $); - vi.stubGlobal('Craft', { - getActionUrl: (path: string, params: Record) => - `/actions/${path}?${new URLSearchParams(params as Record)}`, - }); + vi.stubGlobal('Craft', {actionUrl: '/actions'}); openSlideout.mockResolvedValue({id: 'slideout-1'}); }); diff --git a/resources/js/common/slideouts/create-element-editor.ts b/resources/js/common/slideouts/create-element-editor.ts index 0d7554b569b..74b5560e50a 100644 --- a/resources/js/common/slideouts/create-element-editor.ts +++ b/resources/js/common/slideouts/create-element-editor.ts @@ -3,6 +3,7 @@ import {elementEditorParams} from '@/modules/slideout/element-editor-params'; import type {UiValues} from '@/modules/ui/types'; import {closeSlideout, openSlideout} from './store'; import type {SlideoutInstance, SlideoutSaveResult} from './types'; +import {getActionUrl} from '@/common/craft/url'; declare const $: any; declare const Craft: any; @@ -76,7 +77,7 @@ export class ElementEditorPanel extends Base { ...settings.params, }; - this.opened = openSlideout(Craft.getActionUrl('elements/edit', params), { + this.opened = openSlideout(getActionUrl('elements/edit', params), { opener: $element[0] ?? undefined, onSaved: (result) => this.saved(result, settings), onClosed: () => { diff --git a/resources/js/modules/component-select/component-select.ts b/resources/js/modules/component-select/component-select.ts index bfabe9bee9a..cbdf8699679 100644 --- a/resources/js/modules/component-select/component-select.ts +++ b/resources/js/modules/component-select/component-select.ts @@ -10,6 +10,7 @@ import { import type {CraftActionMenu, ReorderDirection} from '@craftcms/ui'; import {openSlideout, type SlideoutSaveResult} from '@/common/slideouts'; import {componentSelectData} from './support'; +import {getActionUrl} from '@/common/craft/url'; // `Craft` and `$` (jQuery) remain page globals. The Choose menu and the // *chip* action menus are both modern, self-booting `craft-action-menu`s @@ -630,7 +631,7 @@ export class ComponentSelect extends Base { const action = this.settings.createAction ?? ''; const url = /^((https?:)?\/\/|\/)/.test(action) ? action - : Craft.getActionUrl(action); + : getActionUrl(action); void openSlideout(url, { opener: this.#createBtn, diff --git a/resources/js/modules/element-select-input/base-element-select-input.ts b/resources/js/modules/element-select-input/base-element-select-input.ts index 1d28dd9849e..a374bb14c18 100644 --- a/resources/js/modules/element-select-input/base-element-select-input.ts +++ b/resources/js/modules/element-select-input/base-element-select-input.ts @@ -15,6 +15,7 @@ import { type GarnishBaseSettings, } from '@craftcms/garnish'; import {elementSelectInputData} from './support'; +import {getActionUrl} from '@/common/craft/url'; declare const Craft: any; declare const $: any; @@ -876,7 +877,7 @@ export class BaseElementSelectInput extends Base }).filter(([, value]) => value !== null && value !== undefined) ); - void Craft.openSlideout(Craft.getActionUrl('elements/edit', params), { + void Craft.openSlideout(getActionUrl('elements/edit', params), { opener: $element[0], onSaved: ({data}: {data?: Record}) => { // An image saved as a new asset takes the edited asset's place. diff --git a/resources/js/modules/element-select-input/entry-select-input.ts b/resources/js/modules/element-select-input/entry-select-input.ts index 7a5cdd1c13c..7c629640210 100644 --- a/resources/js/modules/element-select-input/entry-select-input.ts +++ b/resources/js/modules/element-select-input/entry-select-input.ts @@ -1,4 +1,5 @@ import {BaseElementSelectInput} from './base-element-select-input'; +import {getActionUrl} from '@/common/craft/url'; declare const Craft: any; @@ -54,7 +55,7 @@ export class EntrySelectInput extends BaseElementSelectInput { let saved = false; void Craft.openSlideout( - Craft.getActionUrl('elements/edit', { + getActionUrl('elements/edit', { elementType: 'CraftCms\\Cms\\Entry\\Elements\\Entry', siteId: this.settings.criteria.siteId, elementId: entry.id, diff --git a/resources/js/modules/elements/composables/useElementActionMenu.test.ts b/resources/js/modules/elements/composables/useElementActionMenu.test.ts index d73bfb14676..7b1a06e56cb 100644 --- a/resources/js/modules/elements/composables/useElementActionMenu.test.ts +++ b/resources/js/modules/elements/composables/useElementActionMenu.test.ts @@ -137,7 +137,7 @@ describe('useElementActionMenu', () => { }); it("opens the sidebar's entry type rather than the saved one", () => { - window.Craft!.getCpUrl = vi.fn((path: string) => `/admin/${path}`); + window.Craft!.baseCpUrl = '/admin'; const toActionItems = createElementActionMenu({ currentEntryTypeId: () => 7, }); diff --git a/resources/js/modules/elements/composables/useElementActionMenu.ts b/resources/js/modules/elements/composables/useElementActionMenu.ts index 53d73954279..cbaff45b4ed 100644 --- a/resources/js/modules/elements/composables/useElementActionMenu.ts +++ b/resources/js/modules/elements/composables/useElementActionMenu.ts @@ -12,6 +12,7 @@ import { openImageEditorDialog, type ImageEditorSettings, } from '@/modules/image-editor/open-image-editor-dialog'; +import {getCpUrl} from '@/common/craft/url'; /** Identifies an element for the CP clipboard. */ interface ElementCopyRef { @@ -196,7 +197,7 @@ export function createElementActionDispatcher({ const entryTypeId = currentEntryTypeId?.(); const url = behavior.entryTypeFromField && entryTypeId - ? Craft.getCpUrl(`settings/entry-types/${entryTypeId}`) + ? getCpUrl(`settings/entry-types/${entryTypeId}`) : behavior.url; // The store sends focus to the menu's button once the item is hidden. void openSlideout(url, trigger ? {opener: trigger} : {}); diff --git a/resources/js/modules/field-layout-designer/field-layout-designer.test.ts b/resources/js/modules/field-layout-designer/field-layout-designer.test.ts index e3e39d3b100..af1a1b4f0f4 100644 --- a/resources/js/modules/field-layout-designer/field-layout-designer.test.ts +++ b/resources/js/modules/field-layout-designer/field-layout-designer.test.ts @@ -138,9 +138,7 @@ it('adds a field returned by the field editor slideout', () => { designer.initLibraryElements = vi.fn(); designer.addLibraryElementToActiveTab = addLibraryElementToActiveTab; designer.getActiveHud = vi.fn(() => undefined); - window.Craft = Object.assign(Object.create(null), { - getCpUrl: vi.fn(() => '/admin/settings/fields/edit'), - }); + window.Craft = Object.assign(Object.create(null), {baseCpUrl: '/admin'}); designer.createField(); @@ -185,9 +183,7 @@ it('opens the field editor from the library HUD trigger and closes the HUD', () designer.initLibraryElements = vi.fn(); designer.addLibraryElementToActiveTab = vi.fn(); designer.getActiveHud = vi.fn(() => ({$trigger, hide})); - window.Craft = Object.assign(Object.create(null), { - getCpUrl: vi.fn(() => '/admin/settings/fields/edit'), - }); + window.Craft = Object.assign(Object.create(null), {baseCpUrl: '/admin'}); designer.createField(); diff --git a/resources/js/modules/field-layout-designer/field-layout-designer.ts b/resources/js/modules/field-layout-designer/field-layout-designer.ts index 71ba8e6f353..e61b162a73b 100644 --- a/resources/js/modules/field-layout-designer/field-layout-designer.ts +++ b/resources/js/modules/field-layout-designer/field-layout-designer.ts @@ -21,6 +21,7 @@ import { import type {FieldLayoutConfig, FieldLayoutDesignerSettings} from './types'; import {ButtonVariant, t} from '@craftcms/ui'; import {openSlideout} from '@/common/slideouts'; +import {getCpUrl} from '@/common/craft/url'; // `Craft` and jQuery (`$`) are still globals on the page. FLD is native; `$` is // used ONLY at the Craft-interop seams (Craft.ui/Grid/listbox/Slideout return or @@ -615,7 +616,7 @@ export class FieldLayoutDesigner extends Base { // place to send focus back to, since the HUD closes below. const hud = this.getActiveHud(); - void openSlideout(Craft.getCpUrl('settings/fields/edit'), { + void openSlideout(getCpUrl('settings/fields/edit'), { opener: hud?.$trigger ?? this.$createFieldBtn, onSaved: ({data}) => { // add the library selector diff --git a/resources/js/modules/fields/index.test.ts b/resources/js/modules/fields/index.test.ts index b61242f1f60..64332f8f8b7 100644 --- a/resources/js/modules/fields/index.test.ts +++ b/resources/js/modules/fields/index.test.ts @@ -19,8 +19,7 @@ describe('field action listeners', () => { window.Craft = { cp: {copyElements}, openSlideout, - getCpUrl: (path: string, params?: Record) => - `/admin/${path}?${new URLSearchParams(params as never).toString()}`, + baseCpUrl: '/admin', } as never; await import('./index'); @@ -90,7 +89,7 @@ describe('field input action listeners', () => { openSlideout.mockReset(); createCopyTextPrompt.mockReset(); copyElements.mockReset(); - window.Craft = {cp: {copyElements}, getCpUrl: () => ''} as never; + window.Craft = {cp: {copyElements}, baseCpUrl: '/admin'} as never; await import('./index'); }); diff --git a/resources/js/modules/fields/index.ts b/resources/js/modules/fields/index.ts index b9bf502a343..bccd871f27f 100644 --- a/resources/js/modules/fields/index.ts +++ b/resources/js/modules/fields/index.ts @@ -2,6 +2,7 @@ import {createCopyTextPrompt} from '@craftcms/ui/factory'; import {openSlideout} from '@/common/slideouts'; import type {SlideoutSaveResult} from '@/common/slideouts/types'; import {matrixField} from '@/modules/matrix/selection-menu'; +import {getCpUrl} from '@/common/craft/url'; /** * Window listeners for the declarative actions carried by a field's "⋮" action @@ -33,7 +34,7 @@ window.addEventListener('craft:edit-field', ((ev: CustomEvent) => { ); }; - const url = Craft.getCpUrl('settings/fields/edit', {fieldId}); + const url = getCpUrl('settings/fields/edit', {fieldId}); void openSlideout(url, { opener: trigger instanceof HTMLElement ? trigger : null, @@ -57,7 +58,7 @@ window.addEventListener('craft:edit-entry-type', ((ev: CustomEvent) => { return; } - const url = Craft.getCpUrl(`settings/entry-types/${entryTypeId}`); + const url = getCpUrl(`settings/entry-types/${entryTypeId}`); void openSlideout(url, { opener: trigger instanceof HTMLElement ? trigger : null, diff --git a/resources/js/modules/navigation/components/cp-queue-indicator.ts b/resources/js/modules/navigation/components/cp-queue-indicator.ts index 5a8a08554cd..33ea82ae27e 100644 --- a/resources/js/modules/navigation/components/cp-queue-indicator.ts +++ b/resources/js/modules/navigation/components/cp-queue-indicator.ts @@ -5,6 +5,7 @@ import type {JobInfo} from '@/modules/queue/types'; import '@craftcms/ui/components/progress/progress'; import {QueueService} from '@/modules/queue/queue'; +import {getCpUrl} from '@/common/craft/url'; @customElement('cp-queue-indicator') class CpQueueIndicator extends LitElement { @@ -98,7 +99,7 @@ class CpQueueIndicator extends LitElement { return null; } - return Craft.getCpUrl('utilities/queue-manager'); + return getCpUrl('utilities/queue-manager'); } protected override render() { diff --git a/yii2-adapter/resources/js/matrix/matrix-entry.ts b/yii2-adapter/resources/js/matrix/matrix-entry.ts index bd009a965cb..c156c379b8b 100644 --- a/yii2-adapter/resources/js/matrix/matrix-entry.ts +++ b/yii2-adapter/resources/js/matrix/matrix-entry.ts @@ -20,6 +20,7 @@ import { legacyGarnish, setJqData, } from './interop'; +import {getCpUrl} from '@/common/craft/url'; type JsonValue = | string @@ -680,9 +681,7 @@ export class MatrixEntry extends Base { case 'editEntryType': { new Craft.CpScreenSlideout( - Craft.getCpUrl( - `settings/entry-types/${this.container.dataset.typeId}` - ) + getCpUrl(`settings/entry-types/${this.container.dataset.typeId}`) ); break; } From 50ab89d793f99ebb37bb02360e6c912e144acd20 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Thu, 29 Oct 2026 09:08:59 -0500 Subject: [PATCH 11/51] Resolve drag handle selectors that start with a child combinator Relation inputs pass handles like '> .element > .chip-content > ...', which jQuery's find() treated as relative to the item but native querySelector() rejects, so a sortable relation input with existing items threw while initializing. A leading '>' is now scoped to the item. Co-Authored-By: Claude Opus 5.5 --- packages/craftcms-garnish/src/drag/base-drag.ts | 4 +++- packages/craftcms-garnish/tests/drag.test.ts | 11 +++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/craftcms-garnish/src/drag/base-drag.ts b/packages/craftcms-garnish/src/drag/base-drag.ts index 656fe2554ee..8e2a5a6db87 100644 --- a/packages/craftcms-garnish/src/drag/base-drag.ts +++ b/packages/craftcms-garnish/src/drag/base-drag.ts @@ -408,9 +408,11 @@ export class BaseDrag< // Legacy split commas and forced `:first` per selector to dodge // craftcms/cms#12896. `querySelector` is inherently first-match, so the // `:first` hack is dropped. + // A leading `>` is relative to the item, as with jQuery's `find()`. return handle .split(',') - .map((s) => item.querySelector(s.trim())) + .map((s) => s.trim()) + .map((s) => item.querySelector(s.startsWith('>') ? `:scope ${s}` : s)) .filter((el): el is Element => el !== null); } diff --git a/packages/craftcms-garnish/tests/drag.test.ts b/packages/craftcms-garnish/tests/drag.test.ts index e02b22b0b4a..d76bb7982eb 100644 --- a/packages/craftcms-garnish/tests/drag.test.ts +++ b/packages/craftcms-garnish/tests/drag.test.ts @@ -222,6 +222,17 @@ describe('BaseDrag handle resolution', () => { expect(d.$targetItem).toBe(a); }); + it('resolves a string handle that starts with a child combinator', () => { + const a = makeItem(); + a.innerHTML = + '
'; + const handle = a.querySelector('.move-btn')!; + const d = new BaseDrag([a], {handle: '> .element > .actions > .move-btn'}); + mockRect(a, 0, 0); + firePointer(handle, 'pointerdown', {pageX: 1, pageY: 1}); + expect(d.$targetItem).toBe(a); + }); + it('resolves a function handle', () => { const a = makeItem(); const handle = document.createElement('div'); From 8a370979d6f21a4397790d0c9ad6a8708da61802 Mon Sep 17 00:00:00 2001 From: Brian Hanson Date: Thu, 29 Oct 2026 09:09:03 -0500 Subject: [PATCH 12/51] Use for asset field uploads Asset fields drew their upload progress with the legacy Craft.ProgressBar, whose stylesheet Craft 6 never loads, so the bar rendered unstyled and the shade over the field never appeared. They now use the element, which exposes a labelled progressbar to assistive tech, with a small stylesheet for the overlay. Progress is still announced every few seconds while an upload runs. Craft.ProgressBar stays in the legacy bundle for the legacy asset index. Co-Authored-By: Claude Opus 5.5 --- resources/js/common/types/globals.d.ts | 20 ------ .../asset-select-input/asset-select-input.css | 25 ++++++++ .../asset-select-input/asset-select-input.ts | 61 +++++++++++++------ .../interval-manager/interval-manager.ts | 6 +- resources/translations/en/app.php | 1 + 5 files changed, 73 insertions(+), 40 deletions(-) create mode 100644 resources/js/modules/asset-select-input/asset-select-input.css diff --git a/resources/js/common/types/globals.d.ts b/resources/js/common/types/globals.d.ts index 34a4310093e..6068946d6c6 100644 --- a/resources/js/common/types/globals.d.ts +++ b/resources/js/common/types/globals.d.ts @@ -33,25 +33,6 @@ interface LegacyModalInstance { } // Set up interfaces and types -interface ProgressBarInterface { - // oxlint-disable-next-line @typescript-eslint/no-misused-new - new ( - $element: JQuery, - displaySteps?: boolean, - settings?: LegacyWidgetSettings - ): ProgressBarInterface; - - $progressBar: JQuery; - - setItemCount(count: number): void; - - setProcessedItemCount(count: number): void; - - updateProgressBar(): void; - - showProgressBar(): void; -} - interface IntervalManagerInterface { // oxlint-disable-next-line @typescript-eslint/no-misused-new new (settings?: LegacyWidgetSettings): IntervalManagerInterface; @@ -139,7 +120,6 @@ interface ElementSelectorModalSettings { interface CraftStatic { csrfTokenName?: string; csrfTokenValue?: string; - ProgressBar: ProgressBarInterface; IntervalManager: IntervalManagerInterface; t(message: string, params?: LegacyWidgetSettings, category?: string): string; sendActionRequest( diff --git a/resources/js/modules/asset-select-input/asset-select-input.css b/resources/js/modules/asset-select-input/asset-select-input.css new file mode 100644 index 00000000000..5424fde6bf2 --- /dev/null +++ b/resources/js/modules/asset-select-input/asset-select-input.css @@ -0,0 +1,25 @@ +/* Covers the field while an upload runs, with the progress bar centered on it. */ +.elementselect.uploading { + position: relative; +} + +.elementselect .progress-shade { + display: none; +} + +.elementselect.uploading .progress-shade { + display: grid; + place-items: center; + position: absolute; + inset: 0; + z-index: 2; + background-color: color-mix( + in srgb, + var(--c-surface-default) 80%, + transparent + ); +} + +.elementselect .progress-shade craft-progress-bar { + inline-size: 60%; +} diff --git a/resources/js/modules/asset-select-input/asset-select-input.ts b/resources/js/modules/asset-select-input/asset-select-input.ts index 82cf3757cec..cd921dfa289 100644 --- a/resources/js/modules/asset-select-input/asset-select-input.ts +++ b/resources/js/modules/asset-select-input/asset-select-input.ts @@ -3,6 +3,10 @@ import {UploadError} from '@/upload-client'; import type {UploaderCallbacks} from '@/modules/uploader/base-uploader'; import {BaseElementSelectInput} from '@/modules/element-select-input/base-element-select-input'; import {openAssetImageEditor} from '@/modules/image-editor'; +import {IntervalManager} from '@/modules/interval-manager/interval-manager'; +import '@craftcms/ui/components/progress-bar/progress-bar'; +import type CraftProgressBar from '@craftcms/ui/components/progress-bar/progress-bar'; +import './asset-select-input.css'; declare const Craft: any; declare const Garnish: any; @@ -12,7 +16,7 @@ declare const $: any; * AssetSelectInput — a port of `Craft.AssetSelectInput` onto * {@link BaseElementSelectInput}. Extends the base element select with: * - An upload button + hidden file input wired to the native uploader - * - A `ProgressBar` overlay during uploads + * - A `` overlay during uploads, announced to screen readers * - Shift-Space keyboard shortcut to open the file preview modal * * Notes: @@ -25,7 +29,8 @@ export class AssetSelectInput extends BaseElementSelectInput { $uploadBtn: any = null; $fileInput: any = null; uploader: any = null; - progressBar: any = null; + progressBar: CraftProgressBar | null = null; + #progressAnnouncer: IntervalManager | null = null; openPreviewTimeout: ReturnType | null = null; constructor(settings?: any) { @@ -115,9 +120,19 @@ export class AssetSelectInput extends BaseElementSelectInput { } _attachUploader(): void { - this.progressBar = new Craft.ProgressBar( - $('
').appendTo(this.$container) - ); + const shade = document.createElement('div'); + shade.className = 'progress-shade'; + this.progressBar = document.createElement('craft-progress-bar'); + this.progressBar.label = Craft.t('app', 'Upload progress'); + this.progressBar.hidden = true; + shade.append(this.progressBar); + this.$container[0].append(shade); + + // The bar has no live region of its own, so progress is read out + // periodically while an upload runs. + this.#progressAnnouncer = new IntervalManager({ + onInterval: () => Craft.cp.announce(this.#progressMessage()), + }); if (this.$addElementBtn) { this.$uploadBtn = $('