Browse Source

Allow SwapController to use the form element's method

Sage Abdullah 8 months ago
parent
commit
e7dac3e18d

+ 2 - 2
client/src/components/Draftail/decorators/__snapshots__/TooltipEntity.test.js.snap

@@ -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"

+ 143 - 0
client/src/controllers/SwapController.test.js

@@ -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');

+ 29 - 19
client/src/controllers/SwapController.ts

@@ -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) {

+ 4 - 1
client/tests/stubs.js

@@ -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';