0
0
mirror of https://github.com/wagtail/wagtail.git synced 2024-11-29 01:22:07 +01:00

Adopt Stimulus SwapController for task-chooser-modal use case

- Builds on #9952
- Create a new method `submit` and `submitLazy` to serialise a form's inputs and submit (GET) async to replace content
- Create a lazy version of `replace` and add unit tests for it
- Partial progress on #9950
This commit is contained in:
LB Johnston 2023-02-04 11:06:43 +10:00 committed by LB (Ben Johnston)
parent a6c9409e03
commit 4afe01104b
4 changed files with 474 additions and 38 deletions

View File

@ -612,4 +612,391 @@ describe('SwapController', () => {
document.removeEventListener('w-swap:begin', beginEventHandler); 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 = `
<main>
<form
id="form"
action="/path/to/form/action/"
method="get"
data-controller="w-swap"
data-action="custom:event->w-swap#replaceLazy submit:prevent->w-swap#replace"
data-w-swap-target-value="#content"
>
<button type="submit">Submit<button>
</form>
<div id="content"></div>
</main>
`;
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 = `
<main>
<form
class="search-form"
action="/path/to/form/action/"
method="get"
role="search"
data-controller="w-swap"
data-action="change->w-swap#submitLazy submit:prevent->w-swap#submitLazy"
data-w-swap-target-value="#task-results"
>
<input id="search" type="text" name="q"/>
<input name="type" type="hidden" value="some-type" />
<input name="other" type="text" />
<button type="submit">Submit<button>
</form>
<div id="task-results"></div>
</main>
`;
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);
});
});
}); });

View File

@ -24,9 +24,22 @@ const getGlobalHeaderSearchOptions = (): {
* patch the results into a results DOM container. The query * patch the results into a results DOM container. The query
* input can be the controlled element or the containing form. * input can be the controlled element or the containing form.
* It supports the ability to update the URL with the query * 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
* <div id="results"></div>
* <form
* data-controller="w-swap"
* data-action="input->w-swap#submitLazy"
* data-w-swap-src-value="path/to/search"
* data-w-swap-target-value="#results"
* >
* <input id="search" type="text" name="query" />
* <input id="filter" type="text" name="filter" />
* </form>
*
* @example - A single input that will update the results & the URL
* <div id="results"></div> * <div id="results"></div>
* <input * <input
* id="search" * id="search"
@ -37,6 +50,7 @@ const getGlobalHeaderSearchOptions = (): {
* data-w-swap-src-value="path/to/search" * data-w-swap-src-value="path/to/search"
* data-w-swap-target-value="#results" * data-w-swap-target-value="#results"
* /> * />
*
*/ */
export class SwapController extends Controller< export class SwapController extends Controller<
HTMLFormElement | HTMLInputElement HTMLFormElement | HTMLInputElement
@ -68,8 +82,12 @@ export class SwapController extends Controller<
abortController?: AbortController; abortController?: AbortController;
/** The related icon element to attach the spinner to */ /** The related icon element to attach the spinner to */
iconElement?: SVGUseElement | null; 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 */ /** Debounced function to search results and then replace the DOM */
searchLazy?: { (...args: any[]): void; cancel(): void }; 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 */ /** Element that receives the fetch result HTML output */
targetElement?: HTMLElement; targetElement?: HTMLElement;
@ -131,8 +149,13 @@ export class SwapController extends Controller<
// set up initial loading state (if set originally in the HTML) // set up initial loading state (if set originally in the HTML)
this.loadingValue = false; 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.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) { 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 * Perform a URL search param update based on the input's value with a comparison against the
* differs from the current matching URL param. Once complete, update the URL param. * matching URL search params. Will replace the target element's content with the results
* Additionally, clear the `'p'` pagination param in the URL if present, can be overridden * of the async search request based on the query.
* via action params if needed. *
* 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( search(
data?: CustomEvent<{ clear: string }> & { 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 * Abort any existing requests & set up new abort controller, then fetch and replace
* the HTML target with the new results. * 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. * a faster response does not replace an in flight request.
*/ */
async replace( async replace(
data: data?:
| string | string
| (CustomEvent<{ url: string }> & { params?: { url?: string } }), | (CustomEvent<{ url: string }> & { params?: { url?: string } }),
) { ) {
/** Parse a request URL from the supplied param, as a string or inside a custom event */ /** Parse a request URL from the supplied param, as a string or inside a custom event */
const requestUrl = const requestUrl =
typeof data === 'string' (typeof data === 'string'
? data ? data
: data.detail.url || data.params?.url || ''; : data?.detail?.url || data?.params?.url || '') || this.srcValue;
if (this.abortController) this.abortController.abort(); if (this.abortController) this.abortController.abort();
this.abortController = new AbortController(); this.abortController = new AbortController();
@ -297,6 +344,8 @@ export class SwapController extends Controller<
*/ */
disconnect() { disconnect() {
this.loadingValue = false; this.loadingValue = false;
this.replaceLazy?.cancel();
this.searchLazy?.cancel(); this.searchLazy?.cancel();
this.submitLazy?.cancel();
} }
} }

View File

@ -1,9 +1,6 @@
import $ from 'jquery'; import $ from 'jquery';
import { initTabs } from '../../includes/tabs'; import { initTabs } from '../../includes/tabs';
import { import { submitCreationForm } from '../../includes/chooserModal';
submitCreationForm,
SearchController,
} from '../../includes/chooserModal';
const ajaxifyTaskCreateTab = (modal) => { const ajaxifyTaskCreateTab = (modal) => {
$( $(
@ -24,36 +21,30 @@ const ajaxifyTaskCreateTab = (modal) => {
const TASK_CHOOSER_MODAL_ONLOAD_HANDLERS = { const TASK_CHOOSER_MODAL_ONLOAD_HANDLERS = {
chooser(modal, jsonData) { chooser(modal, jsonData) {
function ajaxifyLinks(context) { const form = $('form.task-search', modal.body)[0];
$('a.task-choice', context)
// eslint-disable-next-line func-names
.on('click', function () {
modal.loadUrl(this.href);
return false;
});
// eslint-disable-next-line func-names function ajaxifyLinks(context) {
$('.pagination a', context).on('click', function () { $('a.task-choice', context).on('click', function handleClick() {
// eslint-disable-next-line @typescript-eslint/no-use-before-define modal.loadUrl(this.href);
searchController.fetchResults(this.href); return false;
});
$('.pagination a', context).on('click', function handleClick() {
const url = this.href;
form.dispatchEvent(new CustomEvent('navigate', { detail: { url } }));
return false; return false;
}); });
// Reinitialize tabs to hook up tab event listeners in the modal // Reinitialize tabs to hook up tab event listeners in the modal
initTabs(); initTabs();
}
const searchController = new SearchController({ // set up success handling when new results are returned for next search
form: $('form.task-search', modal.body), modal.body[0].addEventListener(
containerElement: modal.body, 'w-swap:success',
resultsContainerSelector: '#search-results', ({ srcElement }) => ajaxifyLinks($(srcElement)),
onLoadResults: (context) => { { once: true },
ajaxifyLinks(context); );
}, }
inputDelay: 50,
});
searchController.attachSearchInput('#id_q');
searchController.attachSearchFilter('#id_task_type');
ajaxifyLinks(modal.body); ajaxifyLinks(modal.body);
ajaxifyTaskCreateTab(modal, jsonData); ajaxifyTaskCreateTab(modal, jsonData);

View File

@ -30,7 +30,16 @@
aria-labelledby="tab-label-existing" aria-labelledby="tab-label-existing"
hidden hidden
> >
<form class="task-search" action="{% url 'wagtailadmin_workflows:task_chooser_results' %}" method="GET" novalidate> <form
class="task-search"
action="{% url 'wagtailadmin_workflows:task_chooser_results' %}"
method="GET"
novalidate
data-controller="w-swap"
data-action="navigate->w-swap#replace submit->w-swap#submit:stop:prevent input->w-swap#submitLazy"
data-w-swap-target-value="#search-results"
data-w-swap-wait-value="50"
>
<ul class="fields"> <ul class="fields">
{% for field in search_form %} {% for field in search_form %}
<li> <li>