Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
liamwhite committed Apr 15, 2024
1 parent 39efe67 commit 59cc4e4
Show file tree
Hide file tree
Showing 2 changed files with 260 additions and 17 deletions.
242 changes: 242 additions & 0 deletions assets/js/__tests__/ujs.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
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 = () => {
const a = document.createElement('a');
a.href = mockEndpoint;
a.dataset.remote = 'remote';
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();
expect(fetch).toHaveBeenNthCalledWith(1, mockEndpoint, {
method: mockVerb,
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();
}));
});

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]', () => {
const submitForm = () => {
const form = document.createElement('form');
form.action = mockEndpoint;
form.method = mockVerb;
form.dataset.remote = 'remote';

document.documentElement.insertAdjacentElement('beforeend', form);
form.submit();

return form;
};

it('should call native fetch with the correct parameters (with body)', () => {
submitForm();
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 emit fetchcomplete event', () => new Promise<void>(resolve => {
let form: HTMLFormElement | null = null;

addOneShotEventListener('fetchcomplete', event => {
expect(event.target).toBe(form);
resolve();
});

form = submitForm();
}));
});
});

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 ];
};

it('should disable submit button containing a text child on click', () => {
const [ , button ] = createFormAndButton('Submit', 'Loading...');
button.click();

expect(button.textContent).toEqual(' Loading...');
expect(button.dataset.enableWith).toEqual('Submit');
});

it('should disable submit button containing element children on click', () => {
const [ , button ] = createFormAndButton('<em>Submit</em>', '<em>Loading...</em>');
button.click();

expect(button.innerHTML).toEqual('<em>Loading...</em>');
expect(button.dataset.enableWith).toEqual('<em>Submit</em>');
});

it('should reset submit button containing a text child on completion', () => {
const [ form, button ] = createFormAndButton('Submit', 'Loading...');
button.click();
fireEvent(form, new CustomEvent('reset', { bubbles: true }));

expect(button.textContent).toEqual(' Submit');
expect(button.dataset.enableWith).not.toBeDefined();
});

it('should reset submit button containing element children on completion', () => {
const [ form, button ] = createFormAndButton('<em>Submit</em>', '<em>Loading...</em>');
button.click();
fireEvent(form, new CustomEvent('reset', { bubbles: true }));

expect(button.innerHTML).toEqual('<em>Submit</em>');
expect(button.dataset.enableWith).not.toBeDefined();
});

it('should reset disabled form elements on pageshow', () => {
const [ , button ] = createFormAndButton('Submit', 'Loading...');
button.click();
fireEvent(window, new CustomEvent('pageshow'));

expect(button.textContent).toEqual(' Submit');
expect(button.dataset.enableWith).not.toBeDefined();
});
});
});
35 changes: 18 additions & 17 deletions assets/js/ujs.js → assets/js/ujs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,36 +6,36 @@ const headers = () => ({
'x-requested-with': 'XMLHttpRequest'
});

function confirm(event, target) {
function confirm(event: Event, target: HTMLElement) {
if (!window.confirm(target.dataset.confirm)) {
event.preventDefault();
event.stopImmediatePropagation();
return false;
}
}

function disable(event, target) {
function disable(event: Event, target: HTMLAnchorElement | HTMLButtonElement | HTMLInputElement) {
// failed validations prevent the form from being submitted;
// stop here or the form will be permanently locked
if (target.type === 'submit' && target.closest(':invalid') !== null) return;

// Store what's already there so we don't lose it
const label = findFirstTextNode(target);
const label = findFirstTextNode<Text>(target);
if (label) {
target.dataset.enableWith = label.nodeValue;
target.dataset.enableWith = label.nodeValue || '';
label.nodeValue = ` ${target.dataset.disableWith}`;
}
else {
target.dataset.enableWith = target.innerHTML;
target.innerHTML = target.dataset.disableWith;
target.innerHTML = target.dataset.disableWith || '';
}

// 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]!
function linkMethod(event, target) {
function linkMethod(event: Event, target: HTMLAnchorElement) {
event.preventDefault();

const form = makeEl('form', { action: target.href, method: 'POST' });
Expand All @@ -49,7 +49,7 @@ function linkMethod(event, target) {
form.submit();
}

function formRemote(event, target) {
function formRemote(event: Event, target: HTMLFormElement) {
event.preventDefault();

fetch(target.action, {
Expand All @@ -58,32 +58,31 @@ function formRemote(event, target) {
headers: headers(),
body: new FormData(target)
}).then(response => {
fire(target, 'fetchcomplete', response);
if (response && response.status === 300) {
window.location.reload(true);
return;
window.location.reload();
}
fire(target, 'fetchcomplete', response);
});
}

function formReset(event, target) {
$$('[disabled][data-disable-with][data-enable-with]', target).forEach(input => {
function formReset(_event: Event | null, target: HTMLElement) {
$$<HTMLElement>('[disabled][data-disable-with][data-enable-with]', target).forEach(input => {
const label = findFirstTextNode(input);
if (label) {
label.nodeValue = ` ${input.dataset.enableWith}`;
}
else { input.innerHTML = target.dataset.enableWith; }
else { input.innerHTML = input.dataset.enableWith || ''; }
delete input.dataset.enableWith;
input.removeAttribute('disabled');
});
}

function linkRemote(event, target) {
function linkRemote(event: Event, target: HTMLAnchorElement) {
event.preventDefault();

fetch(target.href, {
credentials: 'same-origin',
method: target.dataset.method.toUpperCase(),
method: (target.dataset.method || 'get').toUpperCase(),
headers: headers()
}).then(response =>
fire(target, 'fetchcomplete', response)
Expand All @@ -106,5 +105,7 @@ delegate(document, 'reset', {
});

window.addEventListener('pageshow', () => {
[].forEach.call(document.forms, form => formReset(null, form));
for (const form of document.forms) {
formReset(null, form);
}
});

0 comments on commit 59cc4e4

Please sign in to comment.