Skip to content

Commit

Permalink
UIHandler: rework form submission processing (#402)
Browse files Browse the repository at this point in the history
* SubmitEvent.submitter & FormData(form, submitter)

Solves #401

* Update test.yml

* Update UIHandler.ts

* UIHandler: Coding style

* UIHandler.submitForm: Accepts *any* event

* UIHandler.clickElement: Handles click events on button|input elements

* UIHandler.bindUI: Logical order

* UIHandler: tests

* UiHandler: submitForm
  • Loading branch information
filiphlm committed Jul 7, 2024
1 parent 509dbd6 commit 80e15ee
Show file tree
Hide file tree
Showing 2 changed files with 103 additions and 66 deletions.
92 changes: 39 additions & 53 deletions src/core/UIHandler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {Naja, Options, Payload} from '../Naja';
import {assert, onDomReady, TypedEventListener} from '../utils';
import {onDomReady, TypedEventListener} from '../utils';

export class UIHandler extends EventTarget {
public selector: string = '.ajax';
Expand All @@ -20,41 +20,30 @@ export class UIHandler extends EventTarget {
}

public bindUI(element: Element): void {
const selectors = [
`a${this.selector}`,
`input[type="submit"]${this.selector}`,
`input[type="image"]${this.selector}`,
`button[type="submit"]${this.selector}`,
`button[form]:not([type])${this.selector}`,
`form button:not([type])${this.selector}`,
`form${this.selector} input[type="submit"]`,
`form${this.selector} input[type="image"]`,
`form${this.selector} button[type="submit"]`,
`form${this.selector} button:not([type])`,
].join(', ');

const bindElement = (element: Element) => {
const selector = `a${this.selector}`;

const bindElement = (element: HTMLAnchorElement) => {
element.removeEventListener('click', this.handler);
element.addEventListener('click', this.handler);
};

const elements = element.querySelectorAll(selectors);
elements.forEach((element) => bindElement(element));

if (element.matches(selectors)) {
bindElement(element);
if (element.matches(selector)) {
return bindElement(element as HTMLAnchorElement);
}

const elements = element.querySelectorAll(selector);
elements.forEach((element) => bindElement(element as HTMLAnchorElement));

const bindForm = (form: HTMLFormElement) => {
form.removeEventListener('submit', this.handler);
form.addEventListener('submit', this.handler);
};

if (element.matches(`form${this.selector}`)) {
bindForm(element as HTMLFormElement);
if (element.tagName === 'FORM') {
return bindForm(element as HTMLFormElement);
}

const forms = element.querySelectorAll(`form${this.selector}`);
const forms = element.querySelectorAll('form');
forms.forEach((form) => bindForm(form as HTMLFormElement));
}

Expand All @@ -73,52 +62,49 @@ export class UIHandler extends EventTarget {
};

if (event.type === 'submit') {
this.submitForm(element as HTMLFormElement, options, event).catch(ignoreErrors);
const {submitter} = (event as SubmitEvent);
if ((element as HTMLFormElement).matches(this.selector) || submitter?.matches(this.selector)) {
this.submitForm(element as HTMLFormElement, options, event as SubmitEvent).catch(ignoreErrors);
}

} else if (event.type === 'click') {
this.clickElement(element as HTMLElement, options, mouseEvent).catch(ignoreErrors);
this.processInteraction(element as HTMLAnchorElement, 'GET', (element as HTMLAnchorElement).href, null, options, mouseEvent).catch(ignoreErrors);

}
}

public async clickElement(element: HTMLElement, options: Options = {}, event?: MouseEvent): Promise<Payload> {
let method: string = 'GET', url: string = '', data: any;

if (element.tagName === 'A') {
assert(element instanceof HTMLAnchorElement);
return this.processInteraction(element, 'GET', (element as HTMLAnchorElement).href, null, options, event);

method = 'GET';
url = element.href;
data = null;
} else if (element.tagName === 'INPUT' || element.tagName === 'BUTTON' && (element as HTMLButtonElement | HTMLInputElement).form) {
return this.submitForm(element, options, event);

} else if (element.tagName === 'INPUT' || element.tagName === 'BUTTON') {
assert(element instanceof HTMLInputElement || element instanceof HTMLButtonElement);
}

const {form} = element;
// eslint-disable-next-line no-nested-ternary,no-extra-parens
method = element.getAttribute('formmethod')?.toUpperCase() ?? form?.getAttribute('method')?.toUpperCase() ?? 'GET';
url = element.getAttribute('formaction') ?? form?.getAttribute('action') ?? window.location.pathname + window.location.search;
data = new FormData(form ?? undefined);
return {};
}

if (element.type === 'submit' && element.name !== '') {
data.append(element.name, element.value || '');
public async submitForm(sender: HTMLFormElement|HTMLElement, options: Options = {}, event?: Event): Promise<Payload> {
let form: HTMLFormElement|null = sender.tagName === 'FORM' ? sender as HTMLFormElement : null;
let submitter: HTMLElement|null|undefined = null;

} else if (element.type === 'image') {
const coords = element.getBoundingClientRect();
const prefix = element.name !== '' ? `${element.name}.` : '';
data.append(`${prefix}x`, Math.max(0, Math.floor(event !== undefined ? event.pageX - coords.left : 0)));
data.append(`${prefix}y`, Math.max(0, Math.floor(event !== undefined ? event.pageY - coords.top : 0)));
}
if (event?.type === 'submit') {
submitter = (event as SubmitEvent)?.submitter;
} else if (sender.tagName === 'INPUT' || sender.tagName === 'BUTTON') {
form = (sender as HTMLButtonElement | HTMLInputElement).form ?? null;
submitter = sender;
}

return this.processInteraction(element, method, url, data, options, event);
}
if (form) {
const method = (submitter?.getAttribute('formmethod') ?? form.getAttribute('method') ?? 'GET').toUpperCase();
const url = submitter?.getAttribute('formaction') ?? form.getAttribute('action') ?? window.location.pathname + window.location.search;
const data = new FormData(form, submitter);

public async submitForm(form: HTMLFormElement, options: Options = {}, event?: Event): Promise<Payload> {
const method = form.getAttribute('method')?.toUpperCase() ?? 'GET';
const url = form.getAttribute('action') ?? window.location.pathname + window.location.search;
const data = new FormData(form);
return this.processInteraction(submitter ?? form, method, url, data, options, event);
}

return this.processInteraction(form, method, url, data, options, event);
return {};
}

public async processInteraction(
Expand Down
77 changes: 64 additions & 13 deletions tests/Naja.UIHandler.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,12 +110,8 @@ describe('UIHandler', function () {

this.a.dispatchEvent(createEvent('click'));
this.form.dispatchEvent(createEvent('submit'));
this.input.dispatchEvent(createEvent('click'));
this.image.dispatchEvent(createEvent('click'));
this.submitButton.dispatchEvent(createEvent('click'));
this.externalButton.dispatchEvent(createEvent('click'));

assert.equal(naja.uiHandler.handler.callCount, 6);
assert.equal(naja.uiHandler.handler.callCount, 2);
});

it('binds to elements specified by custom selector', function () {
Expand Down Expand Up @@ -488,8 +484,9 @@ describe('UIHandler', function () {

const preventDefault = sinon.spy();
const evt = {
type: 'click',
currentTarget: this.input,
type: 'submit',
currentTarget: this.form2,
submitter: this.input,
preventDefault,
};
handler.handleUI(evt);
Expand All @@ -511,8 +508,9 @@ describe('UIHandler', function () {

const preventDefault = sinon.spy();
const evt = {
type: 'click',
currentTarget: this.image,
type: 'submit',
currentTarget: this.form3,
submitter: this.image,
preventDefault,
};
handler.handleUI(evt);
Expand All @@ -534,8 +532,9 @@ describe('UIHandler', function () {

const preventDefault = sinon.spy();
const evt = {
type: 'click',
currentTarget: this.submitButton,
type: 'submit',
currentTarget: this.form4,
submitter: this.submitButton,
preventDefault,
};
handler.handleUI(evt);
Expand All @@ -557,8 +556,9 @@ describe('UIHandler', function () {

const preventDefault = sinon.spy();
const evt = {
type: 'click',
currentTarget: this.externalButton,
type: 'submit',
currentTarget: this.form5,
submitter: this.externalButton,
preventDefault,
};
handler.handleUI(evt);
Expand Down Expand Up @@ -591,6 +591,29 @@ describe('UIHandler', function () {
});
});

it('dispatches request on button[form]|input[form]', function () {
const naja = mockNaja();
const mock = sinon.mock(naja);
const expectedResult = {answer: 42};

mock.expects('makeRequest')
.withExactArgs('POST', '/UIHandler/submitForm', sinon.match.instanceOf(FormData), {})
.once()
.returns(Promise.resolve(expectedResult));

const form = document.createElement('form');
form.method = 'POST';
form.action = '/UIHandler/submitForm';
const input = document.createElement('input');
input.type = 'submit';
form.appendChild(input);

const handler = new UIHandler(naja);
handler.clickElement(input);

mock.verify();
});

it('triggers interaction event', function () {
const naja = mockNaja();
const mock = sinon.mock(naja);
Expand Down Expand Up @@ -618,6 +641,20 @@ describe('UIHandler', function () {

mock.verify();
});

it('does not trigger interaction event on non-hyperlink|:not([form]) elements', function () {
const naja = mockNaja();

const btn = document.createElement('button');

const listener = sinon.spy();
const handler = new UIHandler(naja);
handler.addEventListener('interaction', listener);

handler.clickElement(btn);

assert.isFalse(listener.called);
});
});

describe('submitForm()', function () {
Expand Down Expand Up @@ -672,6 +709,20 @@ describe('UIHandler', function () {

mock.verify();
});

it('does not trigger interaction event without form element', function () {
const naja = mockNaja();

const btn = document.createElement('button');

const listener = sinon.spy();
const handler = new UIHandler(naja);
handler.addEventListener('interaction', listener);

handler.submitForm(btn);

assert.isFalse(listener.called);
});
});

describe('processInteraction()', function () {
Expand Down

0 comments on commit 80e15ee

Please sign in to comment.