diff --git a/.agents/skills/testing-guidelines/SKILL.md b/.agents/skills/testing-guidelines/SKILL.md index 72d217a2a69..898816c9cdf 100644 --- a/.agents/skills/testing-guidelines/SKILL.md +++ b/.agents/skills/testing-guidelines/SKILL.md @@ -37,6 +37,8 @@ uses(UnitTestCase::class)->in('Unit'); ## Core Rules - Read nearby tests first and follow their declaration and organization conventions. +- Before writing a test, identify the smallest regression it must catch and reuse an existing fixture, factory, or mount helper. For a small behavior change, prefer extending an existing test or adding one focused case. +- Keep setup and assertions proportional to the change's complexity and risk. If a short change needs a much larger test, simplify the setup before adding custom payloads or nested scenarios; retain extra coverage only for a distinct regression the change could cause. - Use Boost's `search-docs` for version-specific Pest and Laravel testing syntax. Confirm an assertion or feature before using it. - Test observable behavior and application contracts. Cover each changed decision and applicable high-value failure path, but leave framework behavior to framework tests. - Run the narrowest relevant test file or filter. Rerun a test after changing it. diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a8e8117136..fee3a9bba7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ > [!IMPORTANT] > This update contains breaking changes for plugins. See [#19574](https://github.com/craftcms/cms/pull/19574), [#19563](https://github.com/craftcms/cms/pull/19563), [#19588](https://github.com/craftcms/cms/pull/19588), and [#19585](https://github.com/craftcms/cms/pull/19585) for details. +- Added support for nested condition groups. ([#19587](https://github.com/craftcms/cms/pull/19587)) - Moved legacy relation-field settings HTML and entry-title input HTML into the Yii adapter. ([#19591](https://github.com/craftcms/cms/pull/19591)) - Migrated the reassign entries, replace relations, and replace references modals to the Form API. ([#19589](https://github.com/craftcms/cms/pull/19589)) - Added support for refreshable standard plugin settings forms and conditional configuration of core form nodes. ([#19545](https://github.com/craftcms/cms/pull/19545)) @@ -17,12 +18,22 @@ - Replaced core inline element editing inputs with Form API controls rendered by Vue, with plugin field HTML compatibility handled by the Yii adapter. ([#19590](https://github.com/craftcms/cms/pull/19590)) - Added an optional `$mode` argument to core thumbnail APIs, defaulting to `Fit` for thumbnail HTML and `Crop` for `CraftCms\Cms\Asset\Assets::getThumbUrl()`. Implementations of `CraftCms\Cms\Component\Contracts\Thumbable` and `CraftCms\Cms\Field\Contracts\ThumbableFieldInterface`, and overrides of thumbnail layout methods and `thumbUrl()`, must update their signatures for Craft 6, including through existing Yii aliases; existing calls remain valid. The Yii Assets service wrapper and legacy thumbnail event are unchanged. - Added `crop`, `fit`, `stretch`, and `letterbox` modes to `craft-thumbnail`, and removed size-dependent asset thumbnail cropping. +- Added `CraftCms\Cms\Condition\BaseConditionGroup`. ([#19587](https://github.com/craftcms/cms/pull/19587)) +- Added `CraftCms\Cms\Condition\ConditionBuilder`. ([#19587](https://github.com/craftcms/cms/pull/19587)) +- Added `CraftCms\Cms\Condition\ConditionBuilderPayload`. ([#19587](https://github.com/craftcms/cms/pull/19587)) +- Added `CraftCms\Cms\Condition\ConditionBuilderRenderer`. ([#19587](https://github.com/craftcms/cms/pull/19587)) +- Added `CraftCms\Cms\Condition\ConditionRulePayload`. ([#19587](https://github.com/craftcms/cms/pull/19587)) +- Added `CraftCms\Cms\Condition\Contracts\ConditionComponentInterface`. ([#19587](https://github.com/craftcms/cms/pull/19587)) +- Added `CraftCms\Cms\Condition\Contracts\ConditionGroupInterface`. ([#19587](https://github.com/craftcms/cms/pull/19587)) +- Added `CraftCms\Cms\Condition\Contracts\ConditionInterface::createGroup()`. ([#19587](https://github.com/craftcms/cms/pull/19587)) - Added `CraftCms\Cms\Condition\Contracts\ConditionRuleInterface::getForm()`, which replaces `getHtml()`. ([#19588](https://github.com/craftcms/cms/pull/19588)) - Added `CraftCms\Cms\Condition\Contracts\ConditionRuleInterface::isSelectableForCondition()`. ([#19563](https://github.com/craftcms/cms/pull/19563)) +- Added `CraftCms\Cms\Condition\Enums\GroupOperator`. ([#19587](https://github.com/craftcms/cms/pull/19587)) - Added `CraftCms\Cms\Contracts\PluginInterface::createSettings()`, which replaces `createSettingsModel()`. ([#19574](https://github.com/craftcms/cms/pull/19574)) - Added `CraftCms\Cms\Dashboard\Widgets\Widget::component()` and `props()`, which replace `getBodyHtml()`. ([#19564](https://github.com/craftcms/cms/pull/19564)) - Added `CraftCms\Cms\Element\Conditions\Contracts\ElementQueryConditionRuleInterface`, which element condition rules that modify element queries should now implement. ([#19563](https://github.com/craftcms/cms/pull/19563)) - Added `CraftCms\Cms\Element\Conditions\ElementCondition::$forQuery`. ([#19563](https://github.com/craftcms/cms/pull/19563)) +- Added `CraftCms\Cms\Element\Conditions\ElementCondition`. ([#19587](https://github.com/craftcms/cms/pull/19587)) - Added `CraftCms\Cms\ProjectConfig\ProjectConfig::getPendingChanges()`. - `CraftCms\Cms\Field\Contracts\FieldInterface::modifyQuery()` now accepts an `Illuminate\Database\Query\Builder` object for its `$query` argument, and has a new `CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface $elementQuery` argument, and a `void` return type. ([#19562](https://github.com/craftcms/cms/pull/19562), [#19585](https://github.com/craftcms/cms/pull/19585)) - Removed `CraftCms\Cms\Condition\Contracts\ConditionRuleInterface::getHtml()`. `getForm()` must be implemented instead. ([#19588](https://github.com/craftcms/cms/pull/19588)) @@ -30,6 +41,7 @@ - Removed `CraftCms\Cms\Dashboard\Widgets\Widget::getBodyHtml()`. `component()` and `props()` must be implemented instead. (`getBodyHtml()` remains supported through the Yii adapter.) ([#19564](https://github.com/craftcms/cms/pull/19564)) - Removed `CraftCms\Cms\Element\Conditions\Contracts\ElementConditionRuleInterface::getExclusiveQueryParams()` and `modifyQuery()`. `ElementQueryConditionRuleInterface::modifyQuery()` should be implemented instead, which now accepts the underlying query builder directly. ([#19563](https://github.com/craftcms/cms/pull/19563)) - Removed `CraftCms\Cms\Element\Conditions\ElementCondition::$queryParams`. ([#19563](https://github.com/craftcms/cms/pull/19563)) +- Removed HTMX. - Fixed a bug where removing false, zero, or empty-string project config values could leave their database rows behind. - Fixed an error that could occur when creating relation fields. ([#19571](https://github.com/craftcms/cms/pull/19571)) - Fixed a bug where failed structure moves could leave locks held and block subsequent operations. ([#19568](https://github.com/craftcms/cms/pull/19568)) diff --git a/package-lock.json b/package-lock.json index 90950245c7a..3e176f07220 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14138,10 +14138,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/htmx.org": { - "version": "1.9.12", - "license": "0BSD" - }, "node_modules/http-cache-semantics": { "version": "4.2.0", "license": "BSD-2-Clause" @@ -24682,7 +24678,6 @@ "codemirror": "^5.65.21", "d3": "^7.9.0", "fabric": "^1.7.22", - "htmx.org": "^1.9.12", "iframe-resizer": "^4.4.5", "inputmask": "^5.0.9", "jquery": "^3.7.1", diff --git a/packages/craftcms-legacy/conditionbuilder/src/ConditionBuilder.js b/packages/craftcms-legacy/conditionbuilder/src/ConditionBuilder.js deleted file mode 100644 index 9f19b13de59..00000000000 --- a/packages/craftcms-legacy/conditionbuilder/src/ConditionBuilder.js +++ /dev/null @@ -1,16 +0,0 @@ -htmx.on('htmx:load', function (evt) { - if (evt.detail.elt === document.body) { - return; - } - - const container = evt.detail.elt.querySelector('.condition'); - if (container && container.classList.contains('sortable')) { - const sortItems = container.querySelectorAll('.condition-rule'); - if (sortItems.length) { - new Garnish.DragSort(sortItems, { - axis: Garnish.Y_AXIS, - handle: '.draggable-handle', - }); - } - } -}); diff --git a/packages/craftcms-legacy/conditionbuilder/webpack.config.js b/packages/craftcms-legacy/conditionbuilder/webpack.config.js deleted file mode 100644 index 304049a3f27..00000000000 --- a/packages/craftcms-legacy/conditionbuilder/webpack.config.js +++ /dev/null @@ -1,17 +0,0 @@ -/* jshint esversion: 6 */ -/* globals module, require, webpack */ -const {getConfig} = require('@craftcms/webpack'); - -module.exports = getConfig({ - context: __dirname, - config: { - entry: { - ConditionBuilder: './ConditionBuilder.js', - }, - output: { - path: - __dirname + - '/../../../cms-assets/resources/legacy/conditionbuilder/dist', - }, - }, -}); diff --git a/packages/craftcms-legacy/cp/src/css/_main.scss b/packages/craftcms-legacy/cp/src/css/_main.scss index df444831853..e0f11de8290 100644 --- a/packages/craftcms-legacy/cp/src/css/_main.scss +++ b/packages/craftcms-legacy/cp/src/css/_main.scss @@ -4677,66 +4677,6 @@ table.data tbody tr:not(.disabled).active-drop-target { } } -/* ---------------------------------------- -/* Condition builders -/* ---------------------------------------- */ - -.condition-rule, -.condition-footer { - padding: 7px; -} - -.condition-footer { - border: 1px dashed var(--border-hairline-medium); - border-radius: var(--radius-lg); - - .condition:not(:empty) + & { - border-block-start-width: 0; - border-start-start-radius: 0; - border-start-end-radius: 0; - } - - .spinner:not(.loading) { - display: none; - } -} - -.condition-rule { - margin: 0; - border: 1px solid var(--border-hairline); - background-color: var(--gray-050); - - &:first-child { - border-start-start-radius: var(--radius-lg); - border-start-end-radius: var(--radius-lg); - } - - & + .condition-rule { - border-block-start-width: 0; - } - - & > .rule-move, - & > .rule-actions { - margin-block-start: 5px; - } - - & > .rule-body { - .lightswitch { - margin-block-start: 6px; - display: block; - } - - .selectize { - min-width: 100px; - } - - .text.fullwidth { - min-width: 100px; - max-width: 100%; - } - } -} - /* ---------------------------------------- /* Progress bar /* ---------------------------------------- */ diff --git a/packages/craftcms-legacy/cp/src/js/BaseElementIndex.js b/packages/craftcms-legacy/cp/src/js/BaseElementIndex.js index 1c3323b1481..630b6a95a0e 100644 --- a/packages/craftcms-legacy/cp/src/js/BaseElementIndex.js +++ b/packages/craftcms-legacy/cp/src/js/BaseElementIndex.js @@ -4851,6 +4851,7 @@ const FilterHud = Garnish.HUD.extend({ serialized: null, $clearBtn: null, cleared: false, + applied: false, get isActive() { return this.showing || this.conditionConfig || this.serialized; @@ -4904,8 +4905,14 @@ const FilterHud = Garnish.HUD.extend({ this.$tip.remove(); this.$tip = null; - this.$body.on('submit', (ev) => { + this.$body.on('submit', async (ev) => { ev.preventDefault(); + + if (!(await this.$main.find('craft-condition-builder')[0].validate())) { + return; + } + + this.applied = true; this.hide(); }); @@ -4952,12 +4959,15 @@ const FilterHud = Garnish.HUD.extend({ this.clear(); }); - this.$hud.find('.condition-container').on('htmx:beforeRequest', () => { - this.setBusy(); + this.$hud.on('condition-builder-valid', (event) => { + const valid = event.originalEvent.detail.valid; + this.conditionValid = valid; + + this.$main.find('button[type="submit"]').prop('disabled', !valid); + valid ? this.setReady() : this.setBusy(); }); - this.$hud.find('.condition-container').on('htmx:load', () => { - this.setReady(); + this.$hud.on('condition-builder-change', () => { this.updateSizeAndPosition(true); }); this.setFocus(); @@ -4989,10 +4999,7 @@ const FilterHud = Garnish.HUD.extend({ setBusy: function () { this.$hud.attr('aria-busy', 'true'); - $('
', { - class: 'visually-hidden', - text: Craft.t('app', 'Loading'), - }).insertAfter(this.$main.find('.htmx-indicator')); + Craft.cp.announce(Craft.t('app', 'Loading')); }, setReady: function () { @@ -5072,10 +5079,15 @@ const FilterHud = Garnish.HUD.extend({ this.base(); // If something changed, update the elements - if (this.serialized !== (this.serialized = this.serialize())) { + if ( + (this.applied || this.cleared) && + this.serialized !== (this.serialized = this.serialize()) + ) { this.elementIndex.updateElements(); } + this.applied = false; + if (this.cleared) { this.destroy(); } else { @@ -5092,6 +5104,10 @@ const FilterHud = Garnish.HUD.extend({ }, serialize: function () { + if (!this.cleared && this.conditionValid === false) { + return this.serialized; + } + return !this.cleared && this.hasRules() ? this.$body.serialize() : null; }, diff --git a/packages/craftcms-legacy/htmx/src/htmx.js b/packages/craftcms-legacy/htmx/src/htmx.js deleted file mode 100644 index 80d11f362d3..00000000000 --- a/packages/craftcms-legacy/htmx/src/htmx.js +++ /dev/null @@ -1,85 +0,0 @@ -htmx.defineExtension('craft-cp', { - onEvent: function (name, evt) { - switch (name) { - case 'htmx:configRequest': - this.configureRequest(evt); - break; - case 'htmx:load': - this.onLoad(evt); - break; - } - }, - configureRequest: function (evt) { - // Add the standard Craft headers - Object.assign(evt.detail.headers, Craft._actionHeaders()); - }, - - // The best place to do this, until an event like `htmx:newContent` is introduced. - transformResponse: function (text, xhr, elt) { - const parser = new DOMParser(); - const doc = parser.parseFromString(text, 'text/html'); - - if (doc.body === document.body) { - return; - } - - const allHeadHtml = doc.querySelectorAll('template.hx-head-html'); - const allBodyHtml = doc.querySelectorAll('template.hx-body-html'); - - for (let i = 0; i < allHeadHtml.length; i++) { - const headHtml = allHeadHtml[i].innerHTML; - if (headHtml) { - Craft.appendHeadHtml(headHtml); - } - } - - for (let i = 0; i < allBodyHtml.length; i++) { - const bodyHtml = allBodyHtml[i].innerHTML; - if (bodyHtml) { - Craft.appendBodyHtml(bodyHtml); - } - } - - return text; - }, - onLoad: function (evt) { - Craft.initUiElements(evt.detail.elt); - }, -}); - -htmx.defineExtension('craft-condition', { - onEvent: function (name, evt) { - switch (name) { - case 'htmx:configRequest': - this.configureRequest(evt); - break; - } - }, - - configureRequest: function (evt) { - let $conditionContainer = $(evt.detail.target).children('.condition-main'); - if (!$conditionContainer.length) { - $conditionContainer = $(evt.detail.target).closest('.condition-main'); - } - const config = $conditionContainer.data('condition-config'); - if (config && config.name) { - const vals = - evt.detail.elt.getAttribute('hx-vals') || - evt.detail.elt.getAttribute('data-hx-vals'); - const valNames = vals ? Object.keys(JSON.parse(vals)) : []; - evt.detail.parameters = Object.fromEntries( - Object.entries(evt.detail.parameters).filter( - ([n]) => valNames.includes(n) || n.indexOf(config.name) === 0 - ) - ); - } - evt.detail.parameters.config = JSON.stringify(config || {}); - }, -}); - -if (typeof Craft !== 'undefined') { - Object.assign(htmx.config, { - indicatorClass: 'spinner', - requestClass: 'loading', - }); -} diff --git a/packages/craftcms-legacy/htmx/webpack.config.js b/packages/craftcms-legacy/htmx/webpack.config.js deleted file mode 100644 index 57f125ce4aa..00000000000 --- a/packages/craftcms-legacy/htmx/webpack.config.js +++ /dev/null @@ -1,24 +0,0 @@ -/* jshint esversion: 6 */ -/* globals module, require */ -const {getConfig} = require('@craftcms/webpack'); -const MergeIntoSingleFilePlugin = require('webpack-merge-and-include-globally'); - -module.exports = getConfig({ - context: __dirname, - config: { - entry: {}, - output: { - path: __dirname + '/../../../cms-assets/resources/legacy/htmx/dist', - }, - plugins: [ - new MergeIntoSingleFilePlugin({ - files: { - 'htmx.min.js': [ - require.resolve('htmx.org/dist/htmx.js'), - require.resolve('./src/htmx.js'), - ], - }, - }), - ], - }, -}); diff --git a/packages/craftcms-legacy/package.json b/packages/craftcms-legacy/package.json index 83e6852eff4..484d21a8e39 100644 --- a/packages/craftcms-legacy/package.json +++ b/packages/craftcms-legacy/package.json @@ -48,7 +48,6 @@ "codemirror": "^5.65.21", "d3": "^7.9.0", "fabric": "^1.7.22", - "htmx.org": "^1.9.12", "iframe-resizer": "^4.4.5", "inputmask": "^5.0.9", "jquery": "^3.7.1", diff --git a/packages/craftcms-ui/src/components/card/card.styles.ts b/packages/craftcms-ui/src/components/card/card.styles.ts index 97c0abd4712..628664ebec5 100644 --- a/packages/craftcms-ui/src/components/card/card.styles.ts +++ b/packages/craftcms-ui/src/components/card/card.styles.ts @@ -24,7 +24,7 @@ export default css` var(--c-color-fill-quiet, var(--c-color-neutral-fill-quiet)), transparent 70% ); - border: 1px solid + border: var(--c-card-border-width, 1px) solid var(--c-color-border-quiet, var(--c-color-neutral-border-quiet)); border-radius: var(--c-card-radius, var(--c-radius-md)); box-shadow: var(--c-card-shadow, var(--c-shadow-sm)); diff --git a/packages/craftcms-ui/src/components/card/card.ts b/packages/craftcms-ui/src/components/card/card.ts index 06587130569..b0a22f45b9d 100644 --- a/packages/craftcms-ui/src/components/card/card.ts +++ b/packages/craftcms-ui/src/components/card/card.ts @@ -33,6 +33,7 @@ import {classMap} from 'lit/directives/class-map.js'; * * @csspart label - The label slot within the header. * + * @cssproperty --c-card-border-width - Border width. Defaults to `1px`. * @cssproperty --c-card-radius - Corner radius. Defaults to `--c-radius-md`. * @cssproperty --c-card-shadow - Box shadow. Defaults to `--c-shadow-sm`. * @cssproperty --c-card-padding-inline - Inline (horizontal) padding of the @@ -100,11 +101,11 @@ export default class CraftCard extends LitElement { private _syncSlotPresence() { this._hasSlottedHeader = - !!this.querySelector('[slot="header"]') || - !!this.querySelector('[slot="label"]') || - !!this.querySelector('[slot="actions"]'); - this._hasSlottedFooter = !!this.querySelector('[slot="footer"]'); - this._hasThumbnail = !!this.querySelector('[slot="thumbnail"]'); + !!this.querySelector(':scope > [slot="header"]') || + !!this.querySelector(':scope > [slot="label"]') || + !!this.querySelector(':scope > [slot="actions"]'); + this._hasSlottedFooter = !!this.querySelector(':scope > [slot="footer"]'); + this._hasThumbnail = !!this.querySelector(':scope > [slot="thumbnail"]'); } private _handleThumbnailSlotChange(event: Event) { diff --git a/packages/craftcms-ui/src/components/combobox/combobox.ts b/packages/craftcms-ui/src/components/combobox/combobox.ts index c72c15d9abe..49e2146c96a 100644 --- a/packages/craftcms-ui/src/components/combobox/combobox.ts +++ b/packages/craftcms-ui/src/components/combobox/combobox.ts @@ -621,7 +621,7 @@ export default class CraftCombobox extends LionCombobox { this.inputs.setAttribute('data-combobox-inputs', ''); this.append(this.inputs); } - // HTMX also serializes condition builders that are not inside a form. + // Detached controls can still be serialized by their host. render( !this.name || this.disabled || this.fieldsetDisabled ? nothing diff --git a/packages/craftcms-ui/src/components/pane/pane.styles.ts b/packages/craftcms-ui/src/components/pane/pane.styles.ts index 38d8eefd629..0af71b78168 100644 --- a/packages/craftcms-ui/src/components/pane/pane.styles.ts +++ b/packages/craftcms-ui/src/components/pane/pane.styles.ts @@ -167,8 +167,7 @@ export default css` } .cp-pane__body { - padding-inline: var(--_pane-spacing); - padding-block: var(--_pane-spacing) calc(var(--_pane-spacing) * 1.5); + padding: var(--_pane-spacing); } .cp-pane__footer { diff --git a/resources/js/common/composables/useDelayedLoading.test.ts b/resources/js/common/composables/useDelayedLoading.test.ts new file mode 100644 index 00000000000..d3119920dff --- /dev/null +++ b/resources/js/common/composables/useDelayedLoading.test.ts @@ -0,0 +1,29 @@ +import {effectScope, shallowRef} from 'vue'; +import {afterEach, expect, it, vi} from 'vite-plus/test'; +import {useDelayedLoading} from './useDelayedLoading'; + +afterEach(() => vi.useRealTimers()); + +it('shows only sustained loading and hides immediately when it finishes', () => { + vi.useFakeTimers(); + const scope = effectScope(); + const loading = shallowRef(true); + const visible = scope.run(() => useDelayedLoading(loading))!; + + vi.advanceTimersByTime(199); + expect(visible.value).toBe(false); + loading.value = false; + vi.advanceTimersByTime(200); + expect(visible.value).toBe(false); + + loading.value = true; + vi.advanceTimersByTime(200); + expect(visible.value).toBe(true); + loading.value = false; + expect(visible.value).toBe(false); + + loading.value = true; + scope.stop(); + vi.advanceTimersByTime(200); + expect(visible.value).toBe(false); +}); diff --git a/resources/js/common/composables/useDelayedLoading.ts b/resources/js/common/composables/useDelayedLoading.ts new file mode 100644 index 00000000000..429ad4b6cf9 --- /dev/null +++ b/resources/js/common/composables/useDelayedLoading.ts @@ -0,0 +1,28 @@ +import {readonly, shallowRef, watch, type Ref} from 'vue'; + +/** Delay showing progress, but hide it immediately when loading finishes. */ +export function useDelayedLoading( + loading: Readonly>, + delay = 200 +) { + const visible = shallowRef(false); + + watch( + loading, + (loading, _, onCleanup) => { + if (!loading) { + visible.value = false; + + return; + } + + const timeout = setTimeout(() => { + visible.value = true; + }, delay); + onCleanup(() => clearTimeout(timeout)); + }, + {immediate: true, flush: 'sync'} + ); + + return readonly(visible); +} diff --git a/resources/js/common/composables/useFetch.test.ts b/resources/js/common/composables/useFetch.test.ts new file mode 100644 index 00000000000..cba2ae39472 --- /dev/null +++ b/resources/js/common/composables/useFetch.test.ts @@ -0,0 +1,65 @@ +import axios from 'axios'; +import {expect, it, vi} from 'vite-plus/test'; +import {useFetch} from './useFetch'; + +it('ignores a superseded HTTP response', async () => { + const client = axios.create(); + let finish!: () => void; + + vi.spyOn(client, 'request') + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = () => resolve({data: 'Old'}); + }) + ) + .mockResolvedValueOnce({data: 'New'}); + + const request = useFetch('/example', { + immediate: false, + axiosInstance: client, + }); + + const first = request.execute(); + expect(request.isLoading.value).toBe(true); + + await request.execute(); + finish(); + + expect(await first).toBeUndefined(); + expect(request.isSuccess.value).toBe(true); + expect(request.data.value).toBe('New'); +}); + +it('stays loading during a transform and discards its superseded result', async () => { + const client = axios.create(); + vi.spyOn(client, 'request').mockResolvedValue({data: 'Raw'}); + + let finish!: () => void; + const transform = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = () => resolve('Old'); + }) + ) + .mockResolvedValueOnce('New'); + + const request = useFetch('/example', { + immediate: false, + axiosInstance: client, + transform, + }); + + const first = request.execute(); + await vi.waitFor(() => expect(transform).toHaveBeenCalledOnce()); + expect(request.isLoading.value).toBe(true); + + expect(await request.execute()).toBe('New'); + finish(); + + expect(await first).toBeUndefined(); + expect(request.isSuccess.value).toBe(true); + expect(request.data.value).toBe('New'); +}); diff --git a/resources/js/common/composables/useFetch.ts b/resources/js/common/composables/useFetch.ts index 3fac532acbf..13337a41cb2 100644 --- a/resources/js/common/composables/useFetch.ts +++ b/resources/js/common/composables/useFetch.ts @@ -38,7 +38,7 @@ interface UseAxiosOptions extends Omit< immediate?: boolean; refetch?: boolean; params?: MaybeRef; - transform?: (data: T) => T; + transform?: (data: T) => T | Promise; enabled?: MaybeRef; debounce?: number; onSuccess?: (data: T, response: AxiosResponse) => void; @@ -52,11 +52,11 @@ interface UseAxiosReturn { data: Ref; error: Ref; state: Ref; - execute: (postData?: RequestData) => Promise; + execute: (postData?: RequestData) => Promise; isLoading: ComputedRef; isSuccess: ComputedRef; isError: ComputedRef; - refetch: () => Promise; + refetch: () => Promise; abort: () => void; } @@ -109,7 +109,7 @@ export function useFetch( let debounceTimer: ReturnType | null = null; // The actual fetch function - const execute = async (postData?: RequestData): Promise => { + const execute = async (postData?: RequestData): Promise => { if (!computedUrl.value || !computedEnabled.value) return; // Cancel previous request @@ -117,27 +117,35 @@ export function useFetch( cancelTokenSource.cancel('Request superseded by new request'); } - cancelTokenSource = axios.CancelToken.source(); + const request = axios.CancelToken.source(); + cancelTokenSource = request; state.value = 'loading'; error.value = null; try { - const response = await axiosInstance({ + const response = await axiosInstance.request({ method: computedMethod.value, url: computedUrl.value, params: computedParams.value, - cancelToken: cancelTokenSource.token, + cancelToken: request.token, data: computedMethod.value === 'get' ? undefined : postData, ...axiosOptions, }); + request.token.throwIfRequested(); const transformedData = transform - ? transform(response.data) + ? await transform(response.data) : response.data; + request.token.throwIfRequested(); + state.value = 'success'; data.value = transformedData; onSuccess?.(transformedData, response); + + return transformedData; } catch (err: unknown) { + if (request !== cancelTokenSource) return; + if (axios.isCancel(err)) { state.value = 'aborted'; } else if (axios.isAxiosError(err)) { @@ -196,7 +204,7 @@ export function useFetch( } // Manual refetch function - const refetch = (): Promise => execute(); + const refetch = (): Promise => execute(); // Cancel function const abort = (): void => { diff --git a/resources/js/cp.ts b/resources/js/cp.ts index 585d0f64731..9f83b5d7c4e 100644 --- a/resources/js/cp.ts +++ b/resources/js/cp.ts @@ -1,6 +1,7 @@ import '@craftcms/ui'; import '../../packages/craftcms-legacy/cp/src/js/UI.js'; import Cp from './bootstrap/cp.js'; +import {defineConditionBuilderHost} from './modules/conditions/condition-builder-host'; import {defineEntryFieldLayoutFormHost} from './modules/forms/entry-field-layout-form-host'; import {defineInlineAttributeFormHost} from './modules/forms/inline-attribute-form-host'; import {defineLayoutComponentSettingsFormHost} from './modules/forms/layout-component-settings-form-host'; @@ -55,5 +56,6 @@ import './modules/ui'; window.Cp = Cp; defineEntryFieldLayoutFormHost(Cp.$components); +defineConditionBuilderHost(Cp.$components); defineInlineAttributeFormHost(Cp.$components); defineLayoutComponentSettingsFormHost(Cp.$components); diff --git a/resources/js/legacy.ts b/resources/js/legacy.ts index 7a481743edd..11f81f29776 100644 --- a/resources/js/legacy.ts +++ b/resources/js/legacy.ts @@ -23,6 +23,7 @@ import './modules/auth/components/totp/totp-form.js'; import './modules/auth/components/recovery-codes/recovery-code-form.js'; import {mountElevatedSessionHost} from './modules/auth/elevated-session'; import {defineDashboardWidgetSettingsFormHost} from './modules/forms/dashboard-widget-settings-form-host'; +import {defineConditionBuilderHost} from './modules/conditions/condition-builder-host'; import {defineEntryFieldLayoutFormHost} from './modules/forms/entry-field-layout-form-host'; import {defineInlineAttributeFormHost} from './modules/forms/inline-attribute-form-host'; import {defineLayoutComponentSettingsFormHost} from './modules/forms/layout-component-settings-form-host'; @@ -79,6 +80,7 @@ Cp.init(); defineDashboardWidgetSettingsFormHost(Cp.$components); defineEntryFieldLayoutFormHost(Cp.$components); +defineConditionBuilderHost(Cp.$components); defineInlineAttributeFormHost(Cp.$components); defineLayoutComponentSettingsFormHost(Cp.$components); diff --git a/resources/js/modules/conditions/ConditionBuilder.test.ts b/resources/js/modules/conditions/ConditionBuilder.test.ts new file mode 100644 index 00000000000..c66ee97b035 --- /dev/null +++ b/resources/js/modules/conditions/ConditionBuilder.test.ts @@ -0,0 +1,469 @@ +import {createApp, nextTick, type App} from 'vue'; +import {afterEach, beforeEach, expect, it, vi} from 'vite-plus/test'; +import {actionClient} from '@craftcms/ui'; +import type CraftActionMenu from '@craftcms/ui/components/action-menu/action-menu'; +import {createCpComponentRegistry} from '@/bootstrap/components'; +import {registerFormComponents} from '@/modules/forms/register'; +import {expandFormData} from '@/common/utils/forms'; +import type {BuilderPayload, RulePayload, GroupConfig} from './types'; +import ConditionBuilder from './ConditionBuilder.vue'; +import {defineConditionBuilderHost} from './condition-builder-host'; + +function rule( + value = 'Alpha', + uid: string = crypto.randomUUID(), + type = 'Title' +): RulePayload { + const scope = ['_conditionRules', uid]; + + return { + config: {class: type, uid, operator: '=', value}, + label: type, + hint: null, + showHint: false, + form: { + scope, + refreshable: true, + nodes: [ + { + type: 'Field', + component: 'craft:field', + props: {label: 'Operator'}, + control: { + type: 'Choice', + component: 'craft:choice', + props: { + options: [ + {label: 'equals', value: '='}, + {label: 'contains', value: '**'}, + ], + multiple: false, + presentation: 'select', + }, + path: [...scope, 'operator'], + mode: 'editable', + deltaGroup: [...scope, 'operator'], + reactive: true, + }, + }, + { + type: 'Field', + component: 'craft:field', + props: {label: type}, + control: { + type: 'Text', + component: 'craft:text', + props: {}, + path: [...scope, 'value'], + mode: 'editable', + deltaGroup: [...scope, 'value'], + }, + }, + ], + values: {_conditionRules: {[uid]: {operator: '=', value}}}, + errors: [], + globalErrors: [], + }, + }; +} + +function response(payload = rule()) { + return {data: {rule: payload, headHtml: '', bodyHtml: ''}}; +} + +function builder(rules: RulePayload[] = []): BuilderPayload { + return { + config: {class: 'Entry'}, + value: { + class: 'Entry', + conditionRules: { + operator: 'and', + rules: rules.map((rule) => rule.config), + }, + }, + rules: Object.fromEntries(rules.map((rule) => [rule.config.uid, rule])), + ruleTypes: ['Title', 'Slug'].map((value) => ({ + value, + label: value, + hint: null, + showHint: false, + group: null, + })), + addRuleLabel: 'Add a rule', + }; +} + +const attachInternals = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'attachInternals' +); + +let app: App | undefined; +let editor: {validate: () => Promise}; +let form: HTMLFormElement; +let container: HTMLElement; + +const components = createCpComponentRegistry(); +registerFormComponents(components); +defineConditionBuilderHost(components); + +beforeEach(() => { + Object.defineProperty(HTMLElement.prototype, 'attachInternals', { + configurable: true, + value: () => ({setFormValue: vi.fn()}), + }); + vi.stubGlobal('confirm', vi.fn().mockReturnValue(true)); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ok: false})); + + form = document.createElement('form'); + container = document.createElement('div'); + form.append(container); + document.body.append(form); +}); + +afterEach(async () => { + if (app) { + components.uninstall(app); + app.unmount(); + app = undefined; + } + + form.remove(); + await nextTick(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + + if (attachInternals) { + Object.defineProperty( + HTMLElement.prototype, + 'attachInternals', + attachInternals + ); + } else { + Reflect.deleteProperty(HTMLElement.prototype, 'attachInternals'); + } +}); + +async function mount(payload = builder(), editable = true): Promise { + app = createApp(ConditionBuilder, {payload, name: 'condition', editable}); + components.install(app); + editor = app.mount(container) as unknown as typeof editor; + await nextTick(); +} + +function button(label: string, parent: Element = container): HTMLElement { + const result = [...parent.querySelectorAll('craft-button')].find( + (element) => + element.textContent?.trim() === label || + element.getAttribute('aria-label') === label + ); + + if (!result) throw new Error(`Missing button: ${label}`); + + return result; +} + +async function selectType(label: string, menuIndex = 0): Promise { + const menu = + container.querySelectorAll('craft-action-menu')[ + menuIndex + ]!; + await vi.waitFor(() => + expect(menu.querySelector('craft-action-item')).not.toBeNull() + ); + + const item = [ + ...menu.querySelectorAll('craft-action-item'), + ].find((item) => item.textContent === label)!; + item.click(); + await nextTick(); +} + +async function changeOperator(): Promise { + const operator = container.querySelector( + '.condition-rule select' + )!; + operator.value = '**'; + operator.dispatchEvent(new Event('change', {bubbles: true})); + await nextTick(); +} + +function submitted(): GroupConfig { + return ( + expandFormData(new FormData(form)).condition as { + conditionRules: GroupConfig; + } + ).conditionRules; +} + +it('edits nested operators locally and prunes empty groups only in submitted values', async () => { + const post = vi.spyOn(actionClient, 'request'); + await mount(); + button('Any').click(); + button('Add a group').click(); + await nextTick(); + + expect(container.querySelectorAll('.condition-group')).toHaveLength(2); + expect(submitted().operator).toBe('or'); + expect(submitted().rules).toBeUndefined(); + expect(post).not.toHaveBeenCalled(); + + const confirm = vi.spyOn(window, 'confirm'); + button('Remove group').click(); + await nextTick(); + expect(container.querySelectorAll('.condition-group')).toHaveLength(1); + expect(confirm).not.toHaveBeenCalled(); +}); + +it('adds repeated rules inside a group and confirms removal of a populated group', async () => { + vi.spyOn(actionClient, 'request').mockImplementation(async () => response()); + await mount(); + button('Add a group').click(); + await nextTick(); + await selectType('Title'); + await vi.waitFor(() => + expect(container.querySelectorAll('.condition-rule')).toHaveLength(1) + ); + await selectType('Title', 1); + await vi.waitFor(() => + expect(container.querySelectorAll('.condition-rule')).toHaveLength(2) + ); + + const nested = submitted().rules[0] as GroupConfig; + expect(nested.rules).toHaveLength(2); + expect(nested.rules[0]!.uid).not.toBe(nested.rules[1]!.uid); + + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + button('Remove group').click(); + await nextTick(); + expect(container.querySelectorAll('.condition-rule')).toHaveLength(2); + + confirm.mockReturnValue(true); + button('Remove group').click(); + await nextTick(); + expect(container.querySelectorAll('.condition-rule')).toHaveLength(0); +}); + +it('retains values while switching type and replaces the menu label', async () => { + const initial = rule(); + const post = vi + .spyOn(actionClient, 'request') + .mockResolvedValue(response(rule('Alpha', initial.config.uid, 'Slug'))); + await mount(builder([initial])); + await selectType('Slug'); + await vi.waitFor(() => expect(submitted().rules[0]?.class).toBe('Slug')); + expect(post.mock.calls[0]?.[0]?.data).toMatchObject({ + rule: {class: 'Title', type: 'Slug', value: 'Alpha'}, + }); + expect(button('Slug')).toBeDefined(); + expect(submitted().rules[0]?.value).toBe('Alpha'); +}); + +it.each([ + [new Error('Offline'), 'Couldn’t update the condition rule.'], + [ + { + isAxiosError: true, + response: {data: {message: 'The selected condition rule is invalid.'}}, + }, + 'The selected condition rule is invalid.', + ], +])( + 'keeps edits and blocks submission after %s until a subsequent update succeeds', + async (failure, message) => { + const initial = rule(); + const post = vi + .spyOn(actionClient, 'request') + .mockRejectedValueOnce(failure) + .mockResolvedValueOnce( + response(rule('Alpha', initial.config.uid, 'Slug')) + ); + await mount(builder([initial])); + await selectType('Slug'); + await vi.waitFor(() => + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + message + ) + ); + expect(submitted().rules[0]?.class).toBe('Title'); + expect( + form.dispatchEvent(new Event('submit', {bubbles: true, cancelable: true})) + ).toBe(false); + + await selectType('Slug'); + await vi.waitFor(() => expect(submitted().rules[0]?.class).toBe('Slug')); + expect(post).toHaveBeenCalledTimes(2); + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(form.dispatchEvent(new Event('submit', {cancelable: true}))).toBe( + true + ); + } +); + +it('preserves added rules and typed values when the native host disconnects and reconnects', async () => { + vi.spyOn(actionClient, 'request').mockResolvedValue(response()); + const host = document.createElement('craft-condition-builder'); + host.dataset.payload = JSON.stringify(builder()); + host.dataset.name = 'condition'; + container.append(host); + await nextTick(); + await selectType('Title'); + await vi.waitFor(() => + expect(host.querySelector('craft-input input')).not.toBeNull() + ); + + const input = host.querySelector('craft-input input')!; + input.value = 'Retained'; + input.dispatchEvent(new Event('input', {bubbles: true})); + await vi.waitFor(() => expect(submitted().rules[0]?.value).toBe('Retained')); + + host.remove(); + await nextTick(); + container.append(host); + await nextTick(); + await vi.waitFor(() => + expect( + host.querySelector('craft-input input')?.value + ).toBe('Retained') + ); + expect(submitted().rules[0]?.value).toBe('Retained'); +}); + +it('keeps later input while a reactive operator request is pending and becomes submittable again', async () => { + const initial = rule(); + let resolve!: (response: unknown) => void; + vi.spyOn(actionClient, 'request').mockImplementation( + () => + new Promise((done) => { + resolve = done; + }) + ); + await mount(builder([initial])); + await changeOperator(); + expect(form.dispatchEvent(new Event('submit', {cancelable: true}))).toBe( + false + ); + + const input = container.querySelector('craft-input input')!; + input.value = 'Later input'; + input.dispatchEvent(new Event('input', {bubbles: true})); + await vi.waitFor(() => + expect(submitted().rules[0]?.value).toBe('Later input') + ); + + resolve(response(initial)); + await vi.waitFor(() => + expect(container.querySelector('[aria-busy="true"]')).toBeNull() + ); + expect(submitted().rules[0]?.operator).toBe('**'); + expect(input.value).toBe('Later input'); + expect(form.dispatchEvent(new Event('submit', {cancelable: true}))).toBe( + true + ); +}); + +it('does not allow edits or submit values in disabled mode', async () => { + await mount(builder([rule()]), false); + await vi.waitFor(() => + expect( + container.querySelector('craft-input input')?.disabled + ).toBe(true) + ); + expect([...new FormData(form).entries()]).toHaveLength(0); + expect( + container.querySelector('craft-button[aria-label="Remove"]') + ).toBeNull(); +}); + +it('blocks native submission when a rule Form provider cannot render', async () => { + const initial = rule(); + initial.form.nodes[0] = { + ...initial.form.nodes[0]!, + control: {...initial.form.nodes[0]!.control!, component: 'missing:control'}, + }; + await mount(builder([initial])); + await vi.waitFor(() => + expect(container.querySelector('[role="alert"]')).not.toBeNull() + ); + expect(form.dispatchEvent(new Event('submit', {cancelable: true}))).toBe( + false + ); +}); + +it('aborts an operator refresh when switching type and ignores its late response', async () => { + const initial = rule(); + let finish!: () => void; + const post = vi + .spyOn(actionClient, 'request') + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = () => resolve(response(initial)); + }) + ) + .mockResolvedValueOnce(response(rule('Alpha', initial.config.uid, 'Slug'))); + await mount(builder([initial])); + await changeOperator(); + + await selectType('Slug'); + await vi.waitFor(() => expect(submitted().rules[0]?.class).toBe('Slug')); + expect(post.mock.calls[0]?.[0]?.cancelToken?.reason).toBeDefined(); + + finish(); + await post.mock.results[0]!.value; + await nextTick(); + expect(submitted().rules[0]?.class).toBe('Slug'); + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(form.dispatchEvent(new Event('submit', {cancelable: true}))).toBe( + true + ); +}); + +it('aborts a pending refresh when its rule is removed', async () => { + let reject!: (reason: unknown) => void; + const pending = new Promise((_resolve, fail) => { + reject = fail; + }); + const post = vi.spyOn(actionClient, 'request').mockReturnValueOnce(pending); + await mount(builder([rule()])); + await changeOperator(); + const cancelToken = post.mock.calls[0]?.[0]?.cancelToken; + button('Remove').click(); + await nextTick(); + expect(cancelToken?.reason).toBeDefined(); + + reject(new DOMException('Aborted', 'AbortError')); + await vi.waitFor(() => + expect(container.querySelector('.condition-rule')).toBeNull() + ); + expect(container.querySelector('[role="alert"]')).toBeNull(); + expect(form.dispatchEvent(new Event('submit', {cancelable: true}))).toBe( + true + ); +}); + +it('shows rule validation errors on apply and allows a successful retry', async () => { + const initial = rule(); + vi.spyOn(actionClient, 'request') + .mockRejectedValueOnce({ + isAxiosError: true, + response: { + data: { + errors: { + [`_conditionRules.${initial.config.uid}.value`]: ['Invalid value.'], + }, + }, + }, + }) + .mockResolvedValueOnce({data: {valid: true}}); + await mount(builder([initial])); + + expect(await editor.validate()).toBe(false); + await nextTick(); + expect(container.textContent).toContain('Invalid value.'); + expect(submitted().rules[0]?.value).toBe('Alpha'); + + expect(await editor.validate()).toBe(true); + await nextTick(); + expect(container.textContent).not.toContain('Invalid value.'); +}); diff --git a/resources/js/modules/conditions/ConditionBuilder.vue b/resources/js/modules/conditions/ConditionBuilder.vue new file mode 100644 index 00000000000..3bff4f6357b --- /dev/null +++ b/resources/js/modules/conditions/ConditionBuilder.vue @@ -0,0 +1,296 @@ + + + diff --git a/resources/js/modules/conditions/ConditionGroup.vue b/resources/js/modules/conditions/ConditionGroup.vue new file mode 100644 index 00000000000..6b5083405e1 --- /dev/null +++ b/resources/js/modules/conditions/ConditionGroup.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/resources/js/modules/conditions/ConditionRule.vue b/resources/js/modules/conditions/ConditionRule.vue new file mode 100644 index 00000000000..c2043c1bf17 --- /dev/null +++ b/resources/js/modules/conditions/ConditionRule.vue @@ -0,0 +1,188 @@ + + + + + diff --git a/resources/js/modules/conditions/ConditionRulePicker.vue b/resources/js/modules/conditions/ConditionRulePicker.vue new file mode 100644 index 00000000000..e8acd2a7788 --- /dev/null +++ b/resources/js/modules/conditions/ConditionRulePicker.vue @@ -0,0 +1,59 @@ + + + diff --git a/resources/js/modules/conditions/condition-builder-host.ts b/resources/js/modules/conditions/condition-builder-host.ts new file mode 100644 index 00000000000..94f5f0495c6 --- /dev/null +++ b/resources/js/modules/conditions/condition-builder-host.ts @@ -0,0 +1,75 @@ +import type {CpComponentRegistry} from '@/bootstrap/components'; +import {createApp, h, shallowRef, type App} from 'vue'; +import ConditionBuilder from './ConditionBuilder.vue'; +import type {BuilderPayload, ConditionConfig} from './types'; + +type ConditionBuilderInstance = { + validate(): Promise; + snapshot(): BuilderPayload; +}; + +export function defineConditionBuilderHost( + components: CpComponentRegistry +): void { + if (customElements.get('craft-condition-builder')) return; + + customElements.define( + 'craft-condition-builder', + class extends HTMLElement { + #app: App | null = null; + readonly #builder = shallowRef(); + #payload: BuilderPayload | null = null; + + async validate(): Promise { + return (await this.#builder.value?.validate()) ?? false; + } + + connectedCallback(): void { + if (this.#app) return; + + this.#payload ??= JSON.parse(this.dataset.payload!); + this.#app = createApp({ + render: () => + h(ConditionBuilder, { + ref: this.#builder, + payload: this.#payload!, + name: this.dataset.name, + editable: this.dataset.editable !== '0', + autofocus: this.dataset.autofocus === '1', + onChange: (value: ConditionConfig) => { + this.dispatchEvent( + new CustomEvent('condition-builder-change', { + bubbles: true, + detail: {value}, + }) + ); + this.dispatchEvent(new Event('change', {bubbles: true})); + }, + onValid: (valid: boolean) => + this.dispatchEvent( + new CustomEvent('condition-builder-valid', { + bubbles: true, + detail: {valid}, + }) + ), + }), + }); + + components.install(this.#app); + this.#app.mount(this); + } + + disconnectedCallback(): void { + queueMicrotask(() => { + if (this.isConnected || !this.#app) return; + + this.#payload = this.#builder.value!.snapshot(); + + components.uninstall(this.#app); + this.#app.unmount(); + this.#app = null; + }); + } + } + ); +} diff --git a/resources/js/modules/conditions/types.ts b/resources/js/modules/conditions/types.ts new file mode 100644 index 00000000000..4a94595bcae --- /dev/null +++ b/resources/js/modules/conditions/types.ts @@ -0,0 +1,54 @@ +import type {FormPayload, FormValues} from '@/modules/forms/types'; +import type {InjectionKey} from 'vue'; + +export type RuleConfig = {class: string; uid?: string} & FormValues; + +export type GroupConfig = { + operator: CraftCms.Cms.Condition.Enums.GroupOperator; + rules: Array; +} & FormValues; + +export type ConditionConfig = { + class: string; + conditionRules?: GroupConfig | RuleConfig[]; +} & FormValues; + +export type RulePayload = Omit< + CraftCms.Cms.Condition.ConditionRulePayload, + 'config' | 'form' +> & { + config: RuleConfig & {uid: string}; + form: FormPayload; +}; + +export type BuilderPayload = Omit< + CraftCms.Cms.Condition.ConditionBuilderPayload, + 'config' | 'value' | 'rules' +> & { + config: FormValues; + value: ConditionConfig; + rules: Record; +}; + +export type RuleDraft = {kind: 'rule'; id: string}; + +export type GroupDraft = { + kind: 'group'; + id: string; + operator: CraftCms.Cms.Condition.Enums.GroupOperator; + rules: Array; +}; + +export const ConditionEditor: InjectionKey<{ + payload: () => BuilderPayload; + rules: Record; + errors: () => FormPayload['errors']; + editable: () => boolean; + value: () => ConditionConfig; + changed: () => void; + status: (id: string, valid: boolean) => void; + registerRule: ( + id: string, + rule?: {snapshot: () => RulePayload; canSubmit: () => boolean} + ) => void; +}> = Symbol('ConditionEditor'); diff --git a/resources/js/modules/conditions/useConditionRuleRequest.ts b/resources/js/modules/conditions/useConditionRuleRequest.ts new file mode 100644 index 00000000000..a5869e002f4 --- /dev/null +++ b/resources/js/modules/conditions/useConditionRuleRequest.ts @@ -0,0 +1,62 @@ +import {computed, inject, onBeforeUnmount, watch} from 'vue'; +import {actionClient, appendBodyHtml, appendHeadHtml} from '@craftcms/ui'; +import ConditionsController from '@actions/ConditionsController'; +import {useFetch} from '@/common/composables/useFetch'; +import type {FormValues} from '@/modules/forms/types'; +import {ConditionEditor, type RulePayload} from './types'; + +export function useConditionRuleRequest(id: string, fallbackMessage: string) { + const editor = inject(ConditionEditor)!; + const request = useFetch<{ + rule: RulePayload; + headHtml: string; + bodyHtml: string; + }>(ConditionsController.rule().url, { + method: 'post', + immediate: false, + axiosInstance: actionClient, + transform: async (data) => { + await appendHeadHtml(data.headHtml); + await appendBodyHtml(data.bodyHtml); + + return data; + }, + }); + + const error = computed(() => { + if (!request.isError.value) return; + + const failure = request.error.value; + + return typeof failure === 'object' && + failure !== null && + 'message' in failure && + typeof failure.message === 'string' + ? failure.message || fallbackMessage + : fallbackMessage; + }); + + watch( + () => !request.isLoading.value && !request.isError.value, + (valid) => editor.status(id, valid), + {flush: 'sync'} + ); + + onBeforeUnmount(() => { + request.abort(); + editor.status(id, true); + }); + + async function execute(rule: FormValues): Promise { + const data = await request.execute({ + config: editor.payload().config, + value: editor.value(), + rule, + editable: editor.editable(), + }); + + return data?.rule; + } + + return {execute, isLoading: request.isLoading, error}; +} diff --git a/resources/js/modules/element-selector-modal/useModalElementIndex.ts b/resources/js/modules/element-selector-modal/useModalElementIndex.ts index 8360f1e7314..2fad1170009 100644 --- a/resources/js/modules/element-selector-modal/useModalElementIndex.ts +++ b/resources/js/modules/element-selector-modal/useModalElementIndex.ts @@ -2,7 +2,7 @@ import {actionClient, type ElementInfo} from '@craftcms/ui'; import {getCoreRowModel, useVueTable} from '@tanstack/vue-table'; import type {RowSelectionState} from '@tanstack/table-core'; import {computed, ref, shallowRef, watch} from 'vue'; -import {useConditionBuilder} from '@/modules/elements/composables/useConditionBuilder'; +import type {ConditionConfig} from '@/modules/conditions/types'; import { useContentIndexData, type ContentIndexData, @@ -96,9 +96,9 @@ export function useModalElementIndex(options: Options) { const elementIndex = useContentIndexData(undefined, payload); const viewState = useElementIndexViewState(elementIndex); - const {conditions} = useConditionBuilder({ - initialState: elementIndex.currentCondition ?? null, - }); + const conditions = shallowRef( + elementIndex.currentCondition ?? null + ); // Not `useElementIndexFilters`: it submits through an Inertia form, which // would navigate the page behind the modal. Same params, sent the modal's way. const search = ref(elementIndex.search ?? ''); diff --git a/resources/js/modules/elements/components/ElementIndexToolbar.vue b/resources/js/modules/elements/components/ElementIndexToolbar.vue index 2a64b39d31f..e432a28ba4e 100644 --- a/resources/js/modules/elements/components/ElementIndexToolbar.vue +++ b/resources/js/modules/elements/components/ElementIndexToolbar.vue @@ -7,7 +7,7 @@ import type {CheckboxOption} from '@/common/types'; import type {SortOption, ViewMode} from '@/modules/elements/types/view-state'; import FilterHud from './FilterHud.vue'; - import type {ConditionConfig} from '@/modules/elements/composables/useConditionBuilder'; + import type {ConditionConfig} from '@/modules/conditions/types'; import {ref} from 'vue'; defineProps<{ @@ -38,6 +38,7 @@ }>(); const filterActive = ref(false); + const filterAnchor = ref();