Allow SwapController to use the form element's method

pull/12185/head
Sage Abdullah 2024-07-04 10:53:46 +01:00 zatwierdzone przez Thibaud Colas
rodzic 3c1ba47566
commit e7dac3e18d
4 zmienionych plików z 178 dodań i 22 usunięć
client
src
components/Draftail/decorators/__snapshots__

Wyświetl plik

@ -25,7 +25,7 @@ exports[`TooltipEntity #openTooltip 1`] = `
id="wagtail-config"
type="application/json"
>
{"CSRF_TOKEN":"potato"}
{"CSRF_HEADER_NAME":"x-xsrf-token","CSRF_TOKEN":"potato"}
</script>
<div
data-draftail-trigger="true"
@ -45,7 +45,7 @@ exports[`TooltipEntity #openTooltip 1`] = `
id="wagtail-config"
type="application/json"
>
{"CSRF_TOKEN":"potato"}
{"CSRF_HEADER_NAME":"x-xsrf-token","CSRF_TOKEN":"potato"}
</script>
<div
data-draftail-trigger="true"

Wyświetl plik

@ -696,6 +696,66 @@ describe('SwapController', () => {
expect(window.location.search).toEqual('');
});
it('should support using the form method as the fetch request method', async () => {
const expectedRequestUrl = '/path/to-src-value/?with=param';
expect(window.location.search).toEqual('');
expect(handleError).not.toHaveBeenCalled();
expect(global.fetch).not.toHaveBeenCalled();
formElement.setAttribute('data-w-swap-src-value', expectedRequestUrl);
formElement.setAttribute('method', 'post');
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.objectContaining({
headers: {
'x-requested-with': 'XMLHttpRequest',
'x-xsrf-token': 'potato',
},
method: 'post',
}),
);
// We are using #replace, not #submit, so we should not have a body
expect(global.fetch.mock.lastCall[1].body).toBeUndefined();
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 reflect the query params of the request URL if reflect-value is true', async () => {
const expectedRequestUrl = '/path/to-src-value/?foo=bar&abc=&xyz=123';
@ -1023,6 +1083,89 @@ describe('SwapController', () => {
expect(window.location.search).toEqual('');
});
it('should support using the form method as the fetch request method', async () => {
const input = document.getElementById('search');
const formElement = document.querySelector('form');
const expectedRequestUrl = '/custom/to-src-value/?with=param';
formElement.setAttribute('data-w-swap-src-value', expectedRequestUrl);
formElement.setAttribute('method', 'post');
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.mockResponseSuccessText(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: expectedRequestUrl,
});
// visual loading state should be active
await Promise.resolve(); // trigger next rendering
expect(handleError).not.toHaveBeenCalled();
expect(global.fetch).toHaveBeenCalledWith(
// The form data should be serialized and sent as the body,
// not as query params
expectedRequestUrl,
expect.objectContaining({
headers: {
'x-requested-with': 'XMLHttpRequest',
'x-xsrf-token': 'potato',
},
method: 'post',
body: expect.any(FormData),
}),
);
expect(
Object.fromEntries(global.fetch.mock.lastCall[1].body.entries()),
).toEqual({
// eslint-disable-next-line id-length
q: 'alpha',
type: 'some-type',
other: 'something on other',
});
const successEvent = await onSuccess;
// should dispatch success event
expect(successEvent.detail).toEqual({
requestUrl: expectedRequestUrl,
results: expect.any(String),
});
// should update HTML
expect(
document.getElementById('task-results').querySelectorAll('li').length,
).toBeTruthy();
await flushPromises();
// should NOT update the current URL
// as the reflect-value attribute is not set
expect(window.location.search).toEqual('');
});
it('should reflect the query params of the request URL if reflect-value is true', async () => {
const formElement = document.querySelector('form');
formElement.setAttribute('data-w-swap-reflect-value', 'true');

Wyświetl plik

@ -1,6 +1,7 @@
import { Controller } from '@hotwired/stimulus';
import { debounce } from '../utils/debounce';
import { WAGTAIL_CONFIG } from '../config/wagtailConfig';
/**
* Allow for an element to trigger an async query that will
@ -78,11 +79,8 @@ export class SwapController extends Controller<
submitLazy?: { (...args: any[]): void; cancel(): void };
connect() {
const formContainer = this.hasInputTarget
? this.inputTarget.form
: this.element;
this.srcValue =
this.srcValue || formContainer?.getAttribute('action') || '';
this.srcValue || this.formElement.getAttribute('action') || '';
const target = this.target;
// set up icons
@ -200,24 +198,29 @@ export class SwapController extends Controller<
});
}
get formElement() {
return (
this.hasInputTarget ? this.inputTarget.form || this.element : this.element
) as HTMLFormElement;
}
/**
* 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 form = this.formElement;
const data = new FormData(form);
// serialise the form to a query string if it's a GET request
// cast as any to avoid https://github.com/microsoft/TypeScript/issues/43797
const searchParams = new URLSearchParams(data as any);
const queryString = '?' + searchParams.toString();
const url = this.srcValue;
const url =
form.method === 'get' ? this.srcValue + queryString : this.srcValue;
this.replace(url + queryString);
this.replace(url, data);
}
reflectParams(url: string) {
@ -241,16 +244,18 @@ export class SwapController extends Controller<
* a faster response does not replace an in flight request.
*/
async replace(
data?:
urlSource?:
| string
| (CustomEvent<{ url: string }> & { params?: { url?: string } }),
data?: FormData,
) {
const target = this.target;
/** Parse a request URL from the supplied param, as a string or inside a custom event */
const requestUrl =
(typeof data === 'string'
? data
: data?.detail?.url || data?.params?.url || '') || this.srcValue;
(typeof urlSource === 'string'
? urlSource
: urlSource?.detail?.url || urlSource?.params?.url || '') ||
this.srcValue;
if (this.abortController) this.abortController.abort();
this.abortController = new AbortController();
@ -265,10 +270,15 @@ export class SwapController extends Controller<
}) as CustomEvent<{ requestUrl: string }>;
if (beginEvent.defaultPrevented) return Promise.resolve();
const formMethod = this.formElement.getAttribute('method') || undefined;
return fetch(requestUrl, {
headers: { 'x-requested-with': 'XMLHttpRequest' },
headers: {
'x-requested-with': 'XMLHttpRequest',
[WAGTAIL_CONFIG.CSRF_HEADER_NAME]: WAGTAIL_CONFIG.CSRF_TOKEN,
},
signal,
method: formMethod,
body: formMethod !== 'get' ? data : undefined,
})
.then(async (response) => {
if (!response.ok) {

Wyświetl plik

@ -39,7 +39,10 @@ global.wagtailConfig = {
const script = document.createElement('script');
script.type = 'application/json';
script.id = 'wagtail-config';
script.textContent = JSON.stringify({ CSRF_TOKEN: 'potato' });
script.textContent = JSON.stringify({
CSRF_HEADER_NAME: 'x-xsrf-token',
CSRF_TOKEN: 'potato',
});
document.body.appendChild(script);
global.wagtailVersion = '1.6a1';