diff --git a/client/src/controllers/SwapController.test.js b/client/src/controllers/SwapController.test.js
index 76bd6c3ce8..5f263762ac 100644
--- a/client/src/controllers/SwapController.test.js
+++ b/client/src/controllers/SwapController.test.js
@@ -612,4 +612,391 @@ describe('SwapController', () => {
document.removeEventListener('w-swap:begin', beginEventHandler);
});
});
+
+ describe('performing a content update via actions on a controlled form using form values', () => {
+ let beginEventHandler;
+ let formElement;
+ let onSuccess;
+ const results = getMockResults({ total: 2 });
+
+ beforeEach(() => {
+ document.body.innerHTML = `
+
+
+
+
+ `;
+
+ window.history.replaceState(null, '', '?');
+
+ formElement = document.getElementById('form');
+
+ onSuccess = new Promise((resolve) => {
+ document.addEventListener('w-swap:success', resolve);
+ });
+
+ beginEventHandler = jest.fn();
+ document.addEventListener('w-swap:begin', beginEventHandler);
+
+ fetch.mockImplementationOnce(() =>
+ Promise.resolve({
+ ok: true,
+ status: 200,
+ text: () => Promise.resolve(results),
+ }),
+ );
+ });
+
+ it('should allow for actions to call the replace method directly, defaulting to the form action url', async () => {
+ const expectedRequestUrl = '/path/to/form/action/';
+
+ expect(window.location.search).toEqual('');
+ expect(handleError).not.toHaveBeenCalled();
+ expect(global.fetch).not.toHaveBeenCalled();
+
+ formElement.dispatchEvent(
+ new CustomEvent('custom:event', { bubbles: false }),
+ );
+
+ expect(beginEventHandler).not.toHaveBeenCalled();
+
+ jest.runAllTimers(); // search is debounced
+
+ // should fire a begin event before the request is made
+ expect(beginEventHandler).toHaveBeenCalledTimes(1);
+ expect(beginEventHandler.mock.calls[0][0].detail).toEqual({
+ requestUrl: expectedRequestUrl,
+ });
+
+ // visual loading state should be active
+ await Promise.resolve(); // trigger next rendering
+
+ expect(handleError).not.toHaveBeenCalled();
+ expect(global.fetch).toHaveBeenCalledWith(
+ expectedRequestUrl,
+ expect.any(Object),
+ );
+
+ const successEvent = await onSuccess;
+
+ // should dispatch success event
+ expect(successEvent.detail).toEqual({
+ requestUrl: expectedRequestUrl,
+ results: expect.any(String),
+ });
+
+ // should update HTML
+ expect(
+ document.getElementById('content').querySelectorAll('li'),
+ ).toHaveLength(5);
+
+ await flushPromises();
+
+ // should NOT update the current URL
+ expect(window.location.search).toEqual('');
+ });
+
+ it('should support replace with a src value', async () => {
+ const expectedRequestUrl = '/path/to-src-value/';
+
+ expect(window.location.search).toEqual('');
+ expect(handleError).not.toHaveBeenCalled();
+ expect(global.fetch).not.toHaveBeenCalled();
+
+ formElement.setAttribute('data-w-swap-src-value', expectedRequestUrl);
+
+ formElement.dispatchEvent(
+ new CustomEvent('custom:event', { bubbles: false }),
+ );
+
+ expect(beginEventHandler).not.toHaveBeenCalled();
+
+ jest.runAllTimers(); // search is debounced
+
+ // should fire a begin event before the request is made
+ expect(beginEventHandler).toHaveBeenCalledTimes(1);
+ expect(beginEventHandler.mock.calls[0][0].detail).toEqual({
+ requestUrl: expectedRequestUrl,
+ });
+
+ // visual loading state should be active
+ await Promise.resolve(); // trigger next rendering
+
+ expect(handleError).not.toHaveBeenCalled();
+ expect(global.fetch).toHaveBeenCalledWith(
+ expectedRequestUrl,
+ expect.any(Object),
+ );
+
+ const successEvent = await onSuccess;
+
+ // should dispatch success event
+ expect(successEvent.detail).toEqual({
+ requestUrl: expectedRequestUrl,
+ results: expect.any(String),
+ });
+
+ // should update HTML
+ expect(
+ document.getElementById('content').querySelectorAll('li'),
+ ).toHaveLength(2);
+
+ await flushPromises();
+
+ // should NOT update the current URL
+ expect(window.location.search).toEqual('');
+ });
+
+ it('should support replace with a url value provided via the Custom event detail', async () => {
+ const expectedRequestUrl = '/path/to/url-in-event-detail/?q=alpha';
+
+ expect(window.location.search).toEqual('');
+ expect(handleError).not.toHaveBeenCalled();
+ expect(global.fetch).not.toHaveBeenCalled();
+
+ formElement.dispatchEvent(
+ new CustomEvent('custom:event', {
+ bubbles: false,
+ detail: { url: expectedRequestUrl },
+ }),
+ );
+
+ expect(beginEventHandler).not.toHaveBeenCalled();
+
+ jest.runAllTimers(); // search is debounced
+
+ // should fire a begin event before the request is made
+ expect(beginEventHandler).toHaveBeenCalledTimes(1);
+ expect(beginEventHandler.mock.calls[0][0].detail).toEqual({
+ requestUrl: expectedRequestUrl,
+ });
+
+ // visual loading state should be active
+ await Promise.resolve(); // trigger next rendering
+
+ expect(handleError).not.toHaveBeenCalled();
+ expect(global.fetch).toHaveBeenCalledWith(
+ expectedRequestUrl,
+ expect.any(Object),
+ );
+
+ const successEvent = await onSuccess;
+
+ // should dispatch success event
+ expect(successEvent.detail).toEqual({
+ requestUrl: expectedRequestUrl,
+ results: expect.any(String),
+ });
+
+ // should update HTML
+ expect(
+ document.getElementById('content').querySelectorAll('li'),
+ ).toHaveLength(2);
+
+ await flushPromises();
+
+ // should NOT update the current URL
+ expect(window.location.search).toEqual('');
+ });
+
+ it('should support replace with a url value provided via an action param', async () => {
+ const expectedRequestUrl = '/path/to/url-in-action-param/#hash';
+
+ expect(window.location.search).toEqual('');
+ expect(handleError).not.toHaveBeenCalled();
+ expect(global.fetch).not.toHaveBeenCalled();
+
+ formElement.setAttribute('data-w-swap-url-param', expectedRequestUrl);
+
+ formElement.dispatchEvent(
+ new CustomEvent('custom:event', { bubbles: false }),
+ );
+
+ expect(beginEventHandler).not.toHaveBeenCalled();
+
+ jest.runAllTimers(); // search is debounced
+
+ // should fire a begin event before the request is made
+ expect(beginEventHandler).toHaveBeenCalledTimes(1);
+ expect(beginEventHandler.mock.calls[0][0].detail).toEqual({
+ requestUrl: expectedRequestUrl,
+ });
+
+ // visual loading state should be active
+ await Promise.resolve(); // trigger next rendering
+
+ expect(handleError).not.toHaveBeenCalled();
+ expect(global.fetch).toHaveBeenCalledWith(
+ expectedRequestUrl,
+ expect.any(Object),
+ );
+
+ const successEvent = await onSuccess;
+
+ // should dispatch success event
+ expect(successEvent.detail).toEqual({
+ requestUrl: expectedRequestUrl,
+ results: expect.any(String),
+ });
+
+ // should update HTML
+ expect(
+ document.getElementById('content').querySelectorAll('li'),
+ ).toHaveLength(2);
+
+ await flushPromises();
+
+ // should NOT update the current URL
+ expect(window.location.search).toEqual('');
+ });
+ });
+
+ describe('performing a content update via actions on a controlled form using form values', () => {
+ beforeEach(() => {
+ // intentionally testing without target input (icon not needed & should work without this)
+
+ document.body.innerHTML = `
+
+
+
+
+ `;
+
+ window.history.replaceState(null, '', '?');
+ });
+
+ it('should allow for searching via a declared action on input changes', async () => {
+ const input = document.getElementById('search');
+
+ const results = getMockResults({ total: 5 });
+
+ const onSuccess = new Promise((resolve) => {
+ document.addEventListener('w-swap:success', resolve);
+ });
+
+ const beginEventHandler = jest.fn();
+ document.addEventListener('w-swap:begin', beginEventHandler);
+
+ fetch.mockImplementationOnce(() =>
+ Promise.resolve({
+ ok: true,
+ status: 200,
+ text: () => Promise.resolve(results),
+ }),
+ );
+
+ expect(window.location.search).toEqual('');
+ expect(handleError).not.toHaveBeenCalled();
+ expect(global.fetch).not.toHaveBeenCalled();
+
+ input.value = 'alpha';
+ document.querySelector('[name="other"]').value = 'something on other';
+ input.dispatchEvent(new CustomEvent('change', { bubbles: true }));
+
+ expect(beginEventHandler).not.toHaveBeenCalled();
+
+ jest.runAllTimers(); // search is debounced
+
+ // should fire a begin event before the request is made
+ expect(beginEventHandler).toHaveBeenCalledTimes(1);
+ expect(beginEventHandler.mock.calls[0][0].detail).toEqual({
+ requestUrl:
+ '/path/to/form/action/?q=alpha&type=some-type&other=something+on+other',
+ });
+
+ // visual loading state should be active
+ await Promise.resolve(); // trigger next rendering
+
+ expect(handleError).not.toHaveBeenCalled();
+ expect(global.fetch).toHaveBeenCalledWith(
+ '/path/to/form/action/?q=alpha&type=some-type&other=something+on+other',
+ expect.any(Object),
+ );
+
+ const successEvent = await onSuccess;
+
+ // should dispatch success event
+ expect(successEvent.detail).toEqual({
+ requestUrl:
+ '/path/to/form/action/?q=alpha&type=some-type&other=something+on+other',
+ results: expect.any(String),
+ });
+
+ // should update HTML
+ expect(
+ document.getElementById('task-results').querySelectorAll('li').length,
+ ).toBeTruthy();
+
+ await flushPromises();
+
+ // should NOT update the current URL
+ expect(window.location.search).toEqual('');
+ });
+
+ it('should allow for blocking the request with custom events', async () => {
+ const input = document.getElementById('search');
+
+ const results = getMockResults({ total: 5 });
+
+ const beginEventHandler = jest.fn((event) => {
+ event.preventDefault();
+ });
+
+ document.addEventListener('w-swap:begin', beginEventHandler);
+
+ fetch.mockImplementationOnce(() =>
+ Promise.resolve({
+ ok: true,
+ status: 200,
+ text: () => Promise.resolve(results),
+ }),
+ );
+
+ expect(window.location.search).toEqual('');
+ expect(handleError).not.toHaveBeenCalled();
+ expect(global.fetch).not.toHaveBeenCalled();
+
+ input.value = 'alpha';
+ document.querySelector('[name="other"]').value = 'something on other';
+ input.dispatchEvent(new CustomEvent('change', { bubbles: true }));
+
+ expect(beginEventHandler).not.toHaveBeenCalled();
+
+ jest.runAllTimers(); // search is debounced
+ await Promise.resolve(requestAnimationFrame);
+
+ // should fire a begin event before the request is made
+ expect(beginEventHandler).toHaveBeenCalledTimes(1);
+ expect(beginEventHandler.mock.calls[0][0].detail).toEqual({
+ requestUrl:
+ '/path/to/form/action/?q=alpha&type=some-type&other=something+on+other',
+ });
+
+ expect(global.fetch).not.toHaveBeenCalled();
+
+ document.removeEventListener('w-swap:begin', beginEventHandler);
+ });
+ });
});
diff --git a/client/src/controllers/SwapController.ts b/client/src/controllers/SwapController.ts
index 4bac441e5c..fa3661eed8 100644
--- a/client/src/controllers/SwapController.ts
+++ b/client/src/controllers/SwapController.ts
@@ -24,9 +24,22 @@ const getGlobalHeaderSearchOptions = (): {
* patch the results into a results DOM container. The query
* input can be the controlled element or the containing form.
* It supports the ability to update the URL with the query
- * when processed.
+ * when processed or simply make a query based on a form's
+ * values.
*
- * @example
+ * @example - A form that will update the results based on the form's input
+ *
+ *
+ *
+ * @example - A single input that will update the results & the URL
*
*
+ *
*/
export class SwapController extends Controller<
HTMLFormElement | HTMLInputElement
@@ -68,8 +82,12 @@ export class SwapController extends Controller<
abortController?: AbortController;
/** The related icon element to attach the spinner to */
iconElement?: SVGUseElement | null;
+ /** Debounced function to request a URL and then replace the DOM with the results */
+ replaceLazy?: { (...args: any[]): void; cancel(): void };
/** Debounced function to search results and then replace the DOM */
searchLazy?: { (...args: any[]): void; cancel(): void };
+ /** Debounced function to submit the serialised form and then replace the DOM */
+ submitLazy?: { (...args: any[]): void; cancel(): void };
/** Element that receives the fetch result HTML output */
targetElement?: HTMLElement;
@@ -131,8 +149,13 @@ export class SwapController extends Controller<
// set up initial loading state (if set originally in the HTML)
this.loadingValue = false;
- // set up debounced method
+ // set up debounced methods
+ this.replaceLazy = debounce(this.replace.bind(this), this.waitValue);
this.searchLazy = debounce(this.search.bind(this), this.waitValue);
+ this.submitLazy = debounce(this.submit.bind(this), this.waitValue);
+
+ // dispatch event for any initial action usage
+ this.dispatch('ready', { cancelable: false });
}
getTarget(targetValue = this.targetValue) {
@@ -173,10 +196,14 @@ export class SwapController extends Controller<
}
/**
- * Perform a search based on a single input query, and only if that query's value
- * differs from the current matching URL param. Once complete, update the URL param.
- * Additionally, clear the `'p'` pagination param in the URL if present, can be overridden
- * via action params if needed.
+ * Perform a URL search param update based on the input's value with a comparison against the
+ * matching URL search params. Will replace the target element's content with the results
+ * of the async search request based on the query.
+ *
+ * Search will only be performed with the URL param value is different to the input value.
+ * Cleared params will be removed from the URL if present.
+ *
+ * `clear` can be provided as Event detail or action param to override the default of 'p'.
*/
search(
data?: CustomEvent<{ clear: string }> & {
@@ -220,6 +247,26 @@ export class SwapController extends Controller<
});
}
+ /**
+ * Update the target element's content with the response from a request based on the input's form
+ * values serialised. Do not account for anything in the main location/URL, simply replace the content within
+ * the target element.
+ */
+ submit() {
+ const form = (
+ this.hasInputTarget ? this.inputTarget.form : this.element
+ ) as HTMLFormElement;
+
+ // serialise the form to a query string
+ // https://github.com/microsoft/TypeScript/issues/43797
+ const searchParams = new URLSearchParams(new FormData(form) as any);
+
+ const queryString = '?' + searchParams.toString();
+ const url = this.srcValue;
+
+ this.replace(url + queryString);
+ }
+
/**
* Abort any existing requests & set up new abort controller, then fetch and replace
* the HTML target with the new results.
@@ -227,15 +274,15 @@ export class SwapController extends Controller<
* a faster response does not replace an in flight request.
*/
async replace(
- data:
+ data?:
| string
| (CustomEvent<{ url: string }> & { params?: { url?: string } }),
) {
/** Parse a request URL from the supplied param, as a string or inside a custom event */
const requestUrl =
- typeof data === 'string'
+ (typeof data === 'string'
? data
- : data.detail.url || data.params?.url || '';
+ : data?.detail?.url || data?.params?.url || '') || this.srcValue;
if (this.abortController) this.abortController.abort();
this.abortController = new AbortController();
@@ -297,6 +344,8 @@ export class SwapController extends Controller<
*/
disconnect() {
this.loadingValue = false;
+ this.replaceLazy?.cancel();
this.searchLazy?.cancel();
+ this.submitLazy?.cancel();
}
}
diff --git a/client/src/entrypoints/admin/task-chooser-modal.js b/client/src/entrypoints/admin/task-chooser-modal.js
index d28d60853b..747f5e60c7 100644
--- a/client/src/entrypoints/admin/task-chooser-modal.js
+++ b/client/src/entrypoints/admin/task-chooser-modal.js
@@ -1,9 +1,6 @@
import $ from 'jquery';
import { initTabs } from '../../includes/tabs';
-import {
- submitCreationForm,
- SearchController,
-} from '../../includes/chooserModal';
+import { submitCreationForm } from '../../includes/chooserModal';
const ajaxifyTaskCreateTab = (modal) => {
$(
@@ -24,36 +21,30 @@ const ajaxifyTaskCreateTab = (modal) => {
const TASK_CHOOSER_MODAL_ONLOAD_HANDLERS = {
chooser(modal, jsonData) {
- function ajaxifyLinks(context) {
- $('a.task-choice', context)
- // eslint-disable-next-line func-names
- .on('click', function () {
- modal.loadUrl(this.href);
- return false;
- });
+ const form = $('form.task-search', modal.body)[0];
- // eslint-disable-next-line func-names
- $('.pagination a', context).on('click', function () {
- // eslint-disable-next-line @typescript-eslint/no-use-before-define
- searchController.fetchResults(this.href);
+ function ajaxifyLinks(context) {
+ $('a.task-choice', context).on('click', function handleClick() {
+ modal.loadUrl(this.href);
+ return false;
+ });
+
+ $('.pagination a', context).on('click', function handleClick() {
+ const url = this.href;
+ form.dispatchEvent(new CustomEvent('navigate', { detail: { url } }));
return false;
});
// Reinitialize tabs to hook up tab event listeners in the modal
initTabs();
- }
- const searchController = new SearchController({
- form: $('form.task-search', modal.body),
- containerElement: modal.body,
- resultsContainerSelector: '#search-results',
- onLoadResults: (context) => {
- ajaxifyLinks(context);
- },
- inputDelay: 50,
- });
- searchController.attachSearchInput('#id_q');
- searchController.attachSearchFilter('#id_task_type');
+ // set up success handling when new results are returned for next search
+ modal.body[0].addEventListener(
+ 'w-swap:success',
+ ({ srcElement }) => ajaxifyLinks($(srcElement)),
+ { once: true },
+ );
+ }
ajaxifyLinks(modal.body);
ajaxifyTaskCreateTab(modal, jsonData);
diff --git a/wagtail/admin/templates/wagtailadmin/workflows/task_chooser/chooser.html b/wagtail/admin/templates/wagtailadmin/workflows/task_chooser/chooser.html
index 0b987262f1..fa813a458b 100644
--- a/wagtail/admin/templates/wagtailadmin/workflows/task_chooser/chooser.html
+++ b/wagtail/admin/templates/wagtailadmin/workflows/task_chooser/chooser.html
@@ -30,7 +30,16 @@
aria-labelledby="tab-label-existing"
hidden
>
-