mirror of
https://github.com/philomena-dev/philomena.git
synced 2025-01-19 22:27:59 +01:00
ujs: migrate to TypeScript (#225)
* ujs: migrate to TypeScript * Address review comments
This commit is contained in:
parent
394c23893c
commit
3cba72ec4c
2 changed files with 358 additions and 19 deletions
335
assets/js/__tests__/ujs.spec.ts
Normal file
335
assets/js/__tests__/ujs.spec.ts
Normal file
|
@ -0,0 +1,335 @@
|
||||||
|
import fetchMock from 'jest-fetch-mock';
|
||||||
|
import { fireEvent } from '@testing-library/dom';
|
||||||
|
import { assertType } from '../utils/assert';
|
||||||
|
import '../ujs';
|
||||||
|
|
||||||
|
const mockEndpoint = 'http://localhost/endpoint';
|
||||||
|
const mockVerb = 'POST';
|
||||||
|
|
||||||
|
describe('Remote utilities', () => {
|
||||||
|
beforeAll(() => {
|
||||||
|
fetchMock.enableMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
fetchMock.disableMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
window.booru.csrfToken = Math.random().toString();
|
||||||
|
fetchMock.resetMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function addOneShotEventListener(name: string, cb: (e: Event) => void) {
|
||||||
|
const handler = (event: Event) => {
|
||||||
|
cb(event);
|
||||||
|
document.removeEventListener(name, handler);
|
||||||
|
};
|
||||||
|
document.addEventListener(name, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('a[data-remote]', () => {
|
||||||
|
const submitA = ({ setMethod }: { setMethod: boolean; }) => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = mockEndpoint;
|
||||||
|
a.dataset.remote = 'remote';
|
||||||
|
if (setMethod) {
|
||||||
|
a.dataset.method = mockVerb;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.documentElement.insertAdjacentElement('beforeend', a);
|
||||||
|
a.click();
|
||||||
|
|
||||||
|
return a;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should call native fetch with the correct parameters (without body)', () => {
|
||||||
|
submitA({ setMethod: true });
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetch).toHaveBeenNthCalledWith(1, mockEndpoint, {
|
||||||
|
method: mockVerb,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'x-csrf-token': window.booru.csrfToken,
|
||||||
|
'x-requested-with': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call native fetch for a get request without explicit method', () => {
|
||||||
|
submitA({ setMethod: false });
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetch).toHaveBeenNthCalledWith(1, mockEndpoint, {
|
||||||
|
method: 'GET',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'x-csrf-token': window.booru.csrfToken,
|
||||||
|
'x-requested-with': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit fetchcomplete event', () => new Promise<void>(resolve => {
|
||||||
|
let a: HTMLAnchorElement | null = null;
|
||||||
|
|
||||||
|
addOneShotEventListener('fetchcomplete', event => {
|
||||||
|
expect(event.target).toBe(a);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
a = submitA({ setMethod: true });
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a[data-method]', () => {
|
||||||
|
const submitA = () => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = mockEndpoint;
|
||||||
|
a.dataset.method = mockVerb;
|
||||||
|
|
||||||
|
document.documentElement.insertAdjacentElement('beforeend', a);
|
||||||
|
a.click();
|
||||||
|
|
||||||
|
return a;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should submit a form with the given action', () => new Promise<void>(resolve => {
|
||||||
|
addOneShotEventListener('submit', event => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const target = assertType(event.target, HTMLFormElement);
|
||||||
|
const [ csrf, method ] = target.querySelectorAll('input');
|
||||||
|
|
||||||
|
expect(csrf.name).toBe('_csrf_token');
|
||||||
|
expect(csrf.value).toBe(window.booru.csrfToken);
|
||||||
|
|
||||||
|
expect(method.name).toBe('_method');
|
||||||
|
expect(method.value).toBe(mockVerb);
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
submitA();
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('form[data-remote]', () => {
|
||||||
|
// https://www.benmvp.com/blog/mocking-window-location-methods-jest-jsdom/
|
||||||
|
let oldWindowLocation: Location;
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
oldWindowLocation = window.location;
|
||||||
|
delete (window as any).location;
|
||||||
|
|
||||||
|
(window as any).location = Object.defineProperties(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
...Object.getOwnPropertyDescriptors(oldWindowLocation),
|
||||||
|
reload: {
|
||||||
|
configurable: true,
|
||||||
|
value: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
(window.location.reload as any).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
// restore window.location to the jsdom Location object
|
||||||
|
window.location = oldWindowLocation;
|
||||||
|
});
|
||||||
|
|
||||||
|
const configureForm = () => {
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.action = mockEndpoint;
|
||||||
|
form.dataset.remote = 'remote';
|
||||||
|
document.documentElement.insertAdjacentElement('beforeend', form);
|
||||||
|
return form;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitForm = () => {
|
||||||
|
const form = configureForm();
|
||||||
|
form.method = mockVerb;
|
||||||
|
form.submit();
|
||||||
|
return form;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should call native fetch with the correct parameters (with body)', () => {
|
||||||
|
submitForm();
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetch).toHaveBeenNthCalledWith(1, mockEndpoint, {
|
||||||
|
method: mockVerb,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'x-csrf-token': window.booru.csrfToken,
|
||||||
|
'x-requested-with': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
body: new FormData(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should submit a PUT request with put data-method specified', () => {
|
||||||
|
const form = configureForm();
|
||||||
|
form.dataset.method = 'put';
|
||||||
|
form.submit();
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetch).toHaveBeenNthCalledWith(1, mockEndpoint, {
|
||||||
|
method: 'PUT',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'x-csrf-token': window.booru.csrfToken,
|
||||||
|
'x-requested-with': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
body: new FormData(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should emit fetchcomplete event', () => new Promise<void>(resolve => {
|
||||||
|
let form: HTMLFormElement | null = null;
|
||||||
|
|
||||||
|
addOneShotEventListener('fetchcomplete', event => {
|
||||||
|
expect(event.target).toBe(form);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
form = submitForm();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it('should reload the page on 300 multiple choices response', () => {
|
||||||
|
const promiseLike = {
|
||||||
|
then(cb: (r: Response) => void) {
|
||||||
|
if (cb) {
|
||||||
|
cb(new Response('', { status: 300 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
jest.spyOn(global, 'fetch').mockReturnValue(promiseLike as any);
|
||||||
|
|
||||||
|
submitForm();
|
||||||
|
expect(window.location.reload).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Form utilities', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(window, 'requestAnimationFrame').mockImplementation(cb => {
|
||||||
|
cb(1);
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('[data-confirm]', () => {
|
||||||
|
const createA = () => {
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.dataset.confirm = 'confirm';
|
||||||
|
a.href = mockEndpoint;
|
||||||
|
document.documentElement.insertAdjacentElement('beforeend', a);
|
||||||
|
return a;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should cancel the event on failed confirm', () => {
|
||||||
|
const a = createA();
|
||||||
|
const confirm = jest.spyOn(window, 'confirm').mockImplementationOnce(() => false);
|
||||||
|
const event = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
expect(fireEvent(a, event)).toBe(false);
|
||||||
|
expect(confirm).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow the event on confirm', () => {
|
||||||
|
const a = createA();
|
||||||
|
const confirm = jest.spyOn(window, 'confirm').mockImplementationOnce(() => true);
|
||||||
|
const event = new MouseEvent('click', { bubbles: true, cancelable: true });
|
||||||
|
|
||||||
|
expect(fireEvent(a, event)).toBe(true);
|
||||||
|
expect(confirm).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('[data-disable-with][data-enable-with]', () => {
|
||||||
|
const createFormAndButton = (innerHTML: string, disableWith: string) => {
|
||||||
|
const form = document.createElement('form');
|
||||||
|
form.action = mockEndpoint;
|
||||||
|
|
||||||
|
// jsdom has no implementation for HTMLFormElement.prototype.submit
|
||||||
|
// and will return an error if the event's default isn't prevented
|
||||||
|
form.addEventListener('submit', event => event.preventDefault());
|
||||||
|
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'submit';
|
||||||
|
button.innerHTML = innerHTML;
|
||||||
|
button.dataset.disableWith = disableWith;
|
||||||
|
|
||||||
|
form.insertAdjacentElement('beforeend', button);
|
||||||
|
document.documentElement.insertAdjacentElement('beforeend', form);
|
||||||
|
|
||||||
|
return [ form, button ];
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitText = 'Submit';
|
||||||
|
const loadingText = 'Loading...';
|
||||||
|
const submitMarkup = '<em>Submit</em>';
|
||||||
|
const loadingMarkup = '<em>Loading...</em>';
|
||||||
|
|
||||||
|
it('should disable submit button containing a text child on click', () => {
|
||||||
|
const [ , button ] = createFormAndButton(submitText, loadingText);
|
||||||
|
button.click();
|
||||||
|
|
||||||
|
expect(button.textContent).toEqual(' Loading...');
|
||||||
|
expect(button.dataset.enableWith).toEqual(submitText);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable submit button containing element children on click', () => {
|
||||||
|
const [ , button ] = createFormAndButton(submitMarkup, loadingMarkup);
|
||||||
|
button.click();
|
||||||
|
|
||||||
|
expect(button.innerHTML).toEqual(loadingMarkup);
|
||||||
|
expect(button.dataset.enableWith).toEqual(submitMarkup);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not disable anything when the form is invalid', () => {
|
||||||
|
const [ form, button ] = createFormAndButton(submitText, loadingText);
|
||||||
|
form.insertAdjacentHTML('afterbegin', '<input type="text" name="valid" required="true" />');
|
||||||
|
button.click();
|
||||||
|
|
||||||
|
expect(button.textContent).toEqual(submitText);
|
||||||
|
expect(button.dataset.enableWith).not.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset submit button containing a text child on completion', () => {
|
||||||
|
const [ form, button ] = createFormAndButton(submitText, loadingText);
|
||||||
|
button.click();
|
||||||
|
fireEvent(form, new CustomEvent('reset', { bubbles: true }));
|
||||||
|
|
||||||
|
expect(button.textContent?.trim()).toEqual(submitText);
|
||||||
|
expect(button.dataset.enableWith).not.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset submit button containing element children on completion', () => {
|
||||||
|
const [ form, button ] = createFormAndButton(submitMarkup, loadingMarkup);
|
||||||
|
button.click();
|
||||||
|
fireEvent(form, new CustomEvent('reset', { bubbles: true }));
|
||||||
|
|
||||||
|
expect(button.innerHTML).toEqual(submitMarkup);
|
||||||
|
expect(button.dataset.enableWith).not.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset disabled form elements on pageshow', () => {
|
||||||
|
const [ , button ] = createFormAndButton(submitText, loadingText);
|
||||||
|
button.click();
|
||||||
|
fireEvent(window, new CustomEvent('pageshow'));
|
||||||
|
|
||||||
|
expect(button.textContent?.trim()).toEqual(submitText);
|
||||||
|
expect(button.dataset.enableWith).not.toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { assertNotNull, assertNotUndefined } from './utils/assert';
|
||||||
import { $$, makeEl, findFirstTextNode } from './utils/dom';
|
import { $$, makeEl, findFirstTextNode } from './utils/dom';
|
||||||
import { fire, delegate, leftClick } from './utils/events';
|
import { fire, delegate, leftClick } from './utils/events';
|
||||||
|
|
||||||
|
@ -6,7 +7,7 @@ const headers = () => ({
|
||||||
'x-requested-with': 'XMLHttpRequest'
|
'x-requested-with': 'XMLHttpRequest'
|
||||||
});
|
});
|
||||||
|
|
||||||
function confirm(event, target) {
|
function confirm(event: Event, target: HTMLElement) {
|
||||||
if (!window.confirm(target.dataset.confirm)) {
|
if (!window.confirm(target.dataset.confirm)) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopImmediatePropagation();
|
event.stopImmediatePropagation();
|
||||||
|
@ -14,28 +15,28 @@ function confirm(event, target) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function disable(event, target) {
|
function disable(event: Event, target: HTMLAnchorElement | HTMLButtonElement | HTMLInputElement) {
|
||||||
// failed validations prevent the form from being submitted;
|
// failed validations prevent the form from being submitted;
|
||||||
// stop here or the form will be permanently locked
|
// stop here or the form will be permanently locked
|
||||||
if (target.type === 'submit' && target.closest(':invalid') !== null) return;
|
if (target.type === 'submit' && target.closest(':invalid') !== null) return;
|
||||||
|
|
||||||
// Store what's already there so we don't lose it
|
// Store what's already there so we don't lose it
|
||||||
const label = findFirstTextNode(target);
|
const label = findFirstTextNode<Text>(target);
|
||||||
if (label) {
|
if (label) {
|
||||||
target.dataset.enableWith = label.nodeValue;
|
target.dataset.enableWith = assertNotNull(label.nodeValue);
|
||||||
label.nodeValue = ` ${target.dataset.disableWith}`;
|
label.nodeValue = ` ${target.dataset.disableWith}`;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
target.dataset.enableWith = target.innerHTML;
|
target.dataset.enableWith = target.innerHTML;
|
||||||
target.innerHTML = target.dataset.disableWith;
|
target.innerHTML = assertNotUndefined(target.dataset.disableWith);
|
||||||
}
|
}
|
||||||
|
|
||||||
// delay is needed because Safari stops the submit if the button is immediately disabled
|
// delay is needed because Safari stops the submit if the button is immediately disabled
|
||||||
requestAnimationFrame(() => target.disabled = 'disabled');
|
requestAnimationFrame(() => target.setAttribute('disabled', 'disabled'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// you should use button_to instead of link_to[method]!
|
// you should use button_to instead of link_to[method]!
|
||||||
function linkMethod(event, target) {
|
function linkMethod(event: Event, target: HTMLAnchorElement) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const form = makeEl('form', { action: target.href, method: 'POST' });
|
const form = makeEl('form', { action: target.href, method: 'POST' });
|
||||||
|
@ -49,41 +50,42 @@ function linkMethod(event, target) {
|
||||||
form.submit();
|
form.submit();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formRemote(event, target) {
|
function formRemote(event: Event, target: HTMLFormElement) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
fetch(target.action, {
|
fetch(target.action, {
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
method: (target.dataset.method || target.method || 'POST').toUpperCase(),
|
method: (target.dataset.method || target.method).toUpperCase(),
|
||||||
headers: headers(),
|
headers: headers(),
|
||||||
body: new FormData(target)
|
body: new FormData(target)
|
||||||
}).then(response => {
|
}).then(response => {
|
||||||
if (response && response.status === 300) {
|
|
||||||
window.location.reload(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fire(target, 'fetchcomplete', response);
|
fire(target, 'fetchcomplete', response);
|
||||||
|
if (response && response.status === 300) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function formReset(event, target) {
|
function formReset(_event: Event | null, target: HTMLElement) {
|
||||||
$$('[disabled][data-disable-with][data-enable-with]', target).forEach(input => {
|
$$<HTMLElement>('[disabled][data-disable-with][data-enable-with]', target).forEach(input => {
|
||||||
const label = findFirstTextNode(input);
|
const label = findFirstTextNode(input);
|
||||||
if (label) {
|
if (label) {
|
||||||
label.nodeValue = ` ${input.dataset.enableWith}`;
|
label.nodeValue = ` ${input.dataset.enableWith}`;
|
||||||
}
|
}
|
||||||
else { input.innerHTML = target.dataset.enableWith; }
|
else {
|
||||||
|
input.innerHTML = assertNotUndefined(input.dataset.enableWith);
|
||||||
|
}
|
||||||
delete input.dataset.enableWith;
|
delete input.dataset.enableWith;
|
||||||
input.removeAttribute('disabled');
|
input.removeAttribute('disabled');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function linkRemote(event, target) {
|
function linkRemote(event: Event, target: HTMLAnchorElement) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
fetch(target.href, {
|
fetch(target.href, {
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
method: target.dataset.method.toUpperCase(),
|
method: (target.dataset.method || 'get').toUpperCase(),
|
||||||
headers: headers()
|
headers: headers()
|
||||||
}).then(response =>
|
}).then(response =>
|
||||||
fire(target, 'fetchcomplete', response)
|
fire(target, 'fetchcomplete', response)
|
||||||
|
@ -106,5 +108,7 @@ delegate(document, 'reset', {
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('pageshow', () => {
|
window.addEventListener('pageshow', () => {
|
||||||
[].forEach.call(document.forms, form => formReset(null, form));
|
for (const form of document.forms) {
|
||||||
|
formReset(null, form);
|
||||||
|
}
|
||||||
});
|
});
|
Loading…
Reference in a new issue