From 4fedf3e2d4a8393f92096294783e76641016df98 Mon Sep 17 00:00:00 2001 From: Sage Abdullah Date: Thu, 25 Jan 2024 11:46:11 +0000 Subject: [PATCH] Add the ability to reflect query params in SwapController This is useful when the controller is used in views that would produce an identical response if the page is hard-reloaded with the same params, e.g. listings --- client/src/controllers/SwapController.test.js | 298 ++++++++++++++++++ client/src/controllers/SwapController.ts | 29 ++ 2 files changed, 327 insertions(+) diff --git a/client/src/controllers/SwapController.test.js b/client/src/controllers/SwapController.test.js index 94f07398cb..03b9fad2e4 100644 --- a/client/src/controllers/SwapController.test.js +++ b/client/src/controllers/SwapController.test.js @@ -718,6 +718,138 @@ describe('SwapController', () => { 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'; + + expect(window.location.search).toEqual(''); + expect(handleError).not.toHaveBeenCalled(); + expect(global.fetch).not.toHaveBeenCalled(); + + const reflectEventHandler = new Promise((resolve) => { + document.addEventListener('w-swap:reflect', resolve); + }); + + formElement.setAttribute('data-w-swap-src-value', expectedRequestUrl); + formElement.setAttribute('data-w-swap-reflect-value', 'true'); + + 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 reflectEvent = await reflectEventHandler; + + // should dispatch reflect event + expect(reflectEvent.detail).toEqual({ + requestUrl: expectedRequestUrl, + }); + + 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 update the current URL to have the query params from requestUrl + // (except for those that are empty) + // as the reflect-value attribute is set to true + expect(window.location.search).toEqual('?foo=bar&xyz=123'); + }); + + it('should allow for blocking the reflection of query params with event handlers', async () => { + const expectedRequestUrl = '/path/to-src-value/?foo=bar&abc=&xyz=123'; + + expect(window.location.search).toEqual(''); + expect(handleError).not.toHaveBeenCalled(); + expect(global.fetch).not.toHaveBeenCalled(); + + const reflectEventHandler = jest.fn((event) => { + event.preventDefault(); + }); + + document.addEventListener('w-swap:reflect', reflectEventHandler); + + formElement.setAttribute('data-w-swap-src-value', expectedRequestUrl); + formElement.setAttribute('data-w-swap-reflect-value', 'true'); + + 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 reflect event + expect(reflectEventHandler).toHaveBeenCalledTimes(1); + expect(reflectEventHandler.mock.calls[0][0].detail).toEqual({ + requestUrl: expectedRequestUrl, + }); + + // 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 + // as the reflect-value attribute is set to false + expect(window.location.search).toEqual(''); + + document.removeEventListener('w-swap:reflect', reflectEventHandler); + }); + 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'; @@ -767,6 +899,7 @@ describe('SwapController', () => { await flushPromises(); // should NOT update the current URL + // as the reflect-value attribute is not set expect(window.location.search).toEqual(''); }); @@ -914,9 +1047,174 @@ describe('SwapController', () => { 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'); + + 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'; + document.querySelector('[name="type"]').value = ''; + 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=&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=&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=&other=something+on+other', + results: expect.any(String), + }); + + // should update HTML + expect( + document.getElementById('task-results').querySelectorAll('li').length, + ).toBeTruthy(); + + await flushPromises(); + + // should update the current URL to have the query params from requestUrl + // (except for those that are empty) + // as the reflect-value attribute is set to true + expect(window.location.search).toEqual( + '?q=alpha&other=something+on+other', + ); + }); + + it('should allow for blocking the reflection of query params with event handlers', async () => { + const formElement = document.querySelector('form'); + formElement.setAttribute('data-w-swap-reflect-value', 'true'); + + const reflectEventHandler = jest.fn((event) => { + event.preventDefault(); + }); + + document.addEventListener('w-swap:reflect', reflectEventHandler); + + 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'; + document.querySelector('[name="type"]').value = ''; + 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=&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=&other=something+on+other', + expect.any(Object), + ); + + const successEvent = await onSuccess; + + // should dispatch reflect event + expect(reflectEventHandler).toHaveBeenCalledTimes(1); + expect(reflectEventHandler.mock.calls[0][0].detail).toEqual({ + requestUrl: + '/path/to/form/action/?q=alpha&type=&other=something+on+other', + }); + + // should dispatch success event + expect(successEvent.detail).toEqual({ + requestUrl: + '/path/to/form/action/?q=alpha&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 + // as the reflect-value attribute is set to false + expect(window.location.search).toEqual(''); + + document.removeEventListener('w-swap:reflect', reflectEventHandler); + }); + it('should allow for blocking the request with custom events', async () => { const input = document.getElementById('search'); diff --git a/client/src/controllers/SwapController.ts b/client/src/controllers/SwapController.ts index 8e3a097370..07faa63793 100644 --- a/client/src/controllers/SwapController.ts +++ b/client/src/controllers/SwapController.ts @@ -46,6 +46,7 @@ export class SwapController extends Controller< icon: { default: '', type: String }, loading: { default: false, type: Boolean }, src: { default: '', type: String }, + reflect: { default: false, type: Boolean }, target: { default: '#listing-results', type: String }, wait: { default: 200, type: Number }, }; @@ -58,6 +59,7 @@ export class SwapController extends Controller< declare iconValue: string; declare loadingValue: boolean; declare srcValue: string; + declare reflectValue: boolean; declare targetValue: string; declare waitValue: number; @@ -215,6 +217,20 @@ export class SwapController extends Controller< this.replace(url + queryString); } + reflectParams(url: string) { + const params = new URL(url, window.location.href).searchParams; + const filteredParams = new URLSearchParams(); + params.forEach((value, key) => { + // Check if the value is not empty after trimming white space + // and if the key is not a Wagtail internal param + if (value.trim() !== '' && !key.startsWith('_w_')) { + filteredParams.append(key, value); + } + }); + const queryString = `?${filteredParams.toString()}`; + window.history.replaceState(null, '', queryString); + } + /** * Abort any existing requests & set up new abort controller, then fetch and replace * the HTML target with the new results. @@ -259,11 +275,24 @@ export class SwapController extends Controller< }) .then((results) => { target.innerHTML = results; + + if (this.reflectValue) { + const event = this.dispatch('reflect', { + cancelable: true, + detail: { requestUrl }, + target, + }); + if (!event.defaultPrevented) { + this.reflectParams(requestUrl); + } + } + this.dispatch('success', { cancelable: false, detail: { requestUrl, results }, target, }); + return results; }) .catch((error) => {