From 2f108db6ab5825a6076aca59473e4e412b2bf026 Mon Sep 17 00:00:00 2001 From: Filip Halama Date: Fri, 17 May 2024 13:30:05 +0200 Subject: [PATCH 1/9] SubmitEvent.submitter & FormData(form, submitter) Solves #401 --- .github/workflows/test.yml | 2 +- src/core/UIHandler.ts | 80 +++++++++++--------------------------- tests/Naja.UIHandler.js | 26 ++++++------- 3 files changed, 36 insertions(+), 72 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dcfcf79..8df4708 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,7 +1,7 @@ name: Test on: push: - branches: [main] + branches: [main, naja-formdata-submitter] pull_request: branches: [main] diff --git a/src/core/UIHandler.ts b/src/core/UIHandler.ts index 037685e..10b9ab3 100644 --- a/src/core/UIHandler.ts +++ b/src/core/UIHandler.ts @@ -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'; @@ -20,29 +20,18 @@ 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)); + const elements = element.querySelectorAll(selector); + elements.forEach((element) => bindElement(element as HTMLAnchorElement)); - if (element.matches(selectors)) { - bindElement(element); + if (element.matches(selector)) { + bindElement(element as HTMLAnchorElement); } const bindForm = (form: HTMLFormElement) => { @@ -50,11 +39,11 @@ export class UIHandler extends EventTarget { form.addEventListener('submit', this.handler); }; - if (element.matches(`form${this.selector}`)) { + if (element.tagName === 'FORM') { bindForm(element as HTMLFormElement); } - const forms = element.querySelectorAll(`form${this.selector}`); + const forms = element.querySelectorAll('form'); forms.forEach((form) => bindForm(form as HTMLFormElement)); } @@ -73,50 +62,25 @@ 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.clickElement(element as HTMLAnchorElement, options, mouseEvent).catch(ignoreErrors); } } - public async clickElement(element: HTMLElement, options: Options = {}, event?: MouseEvent): Promise { - let method: string = 'GET', url: string = '', data: any; - - if (element.tagName === 'A') { - assert(element instanceof HTMLAnchorElement); - - method = 'GET'; - url = element.href; - data = null; - - } 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); - - if (element.type === 'submit' && element.name !== '') { - data.append(element.name, element.value || ''); - - } 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))); - } - } - - return this.processInteraction(element, method, url, data, options, event); + public async clickElement(element: HTMLAnchorElement, options: Options = {}, event?: MouseEvent): Promise { + return this.processInteraction(element, 'GET', element.href, null, options, event); } - public async submitForm(form: HTMLFormElement, options: Options = {}, event?: Event): Promise { - const method = form.getAttribute('method')?.toUpperCase() ?? 'GET'; - const url = form.getAttribute('action') ?? window.location.pathname + window.location.search; - const data = new FormData(form); + public async submitForm(form: HTMLFormElement, options: Options = {}, event?: SubmitEvent): Promise { + const submitter = event?.submitter; + 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); return this.processInteraction(form, method, url, data, options, event); } diff --git a/tests/Naja.UIHandler.js b/tests/Naja.UIHandler.js index d01b461..15ca9a1 100644 --- a/tests/Naja.UIHandler.js +++ b/tests/Naja.UIHandler.js @@ -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 () { @@ -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); @@ -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); @@ -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); @@ -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); From 1b7aca48dfe0b84361485c90df7357853574c22a Mon Sep 17 00:00:00 2001 From: Filip Halama Date: Fri, 17 May 2024 15:28:02 +0200 Subject: [PATCH 2/9] Update test.yml --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8df4708..dcfcf79 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,7 +1,7 @@ name: Test on: push: - branches: [main, naja-formdata-submitter] + branches: [main] pull_request: branches: [main] From eeb868df10ad6c5ad7446ce1b81bbb620314a6e5 Mon Sep 17 00:00:00 2001 From: Filip Halama Date: Fri, 17 May 2024 17:02:33 +0200 Subject: [PATCH 3/9] Update UIHandler.ts --- src/core/UIHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/UIHandler.ts b/src/core/UIHandler.ts index 10b9ab3..2c8f40a 100644 --- a/src/core/UIHandler.ts +++ b/src/core/UIHandler.ts @@ -82,7 +82,7 @@ export class UIHandler extends EventTarget { const url = submitter?.getAttribute('formaction') ?? form.getAttribute('action') ?? window.location.pathname + window.location.search; const data = new FormData(form, submitter); - return this.processInteraction(form, method, url, data, options, event); + return this.processInteraction(submitter || form, method, url, data, options, event); } public async processInteraction( From 35671f692b80d19425d402a463bf67d4ddf36d10 Mon Sep 17 00:00:00 2001 From: Filip Halama Date: Wed, 29 May 2024 18:25:28 +0200 Subject: [PATCH 4/9] UIHandler: Coding style --- src/core/UIHandler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/UIHandler.ts b/src/core/UIHandler.ts index 2c8f40a..571141e 100644 --- a/src/core/UIHandler.ts +++ b/src/core/UIHandler.ts @@ -78,11 +78,11 @@ export class UIHandler extends EventTarget { public async submitForm(form: HTMLFormElement, options: Options = {}, event?: SubmitEvent): Promise { const submitter = event?.submitter; - const method = (submitter?.getAttribute('formmethod') || form.getAttribute('method') || 'GET').toUpperCase(); + 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); - return this.processInteraction(submitter || form, method, url, data, options, event); + return this.processInteraction(submitter ?? form, method, url, data, options, event); } public async processInteraction( From 1b0b2d2518a81aadf49913515c8d218441411d1b Mon Sep 17 00:00:00 2001 From: Filip Halama Date: Wed, 29 May 2024 18:26:40 +0200 Subject: [PATCH 5/9] UIHandler.submitForm: Accepts *any* event --- src/core/UIHandler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/UIHandler.ts b/src/core/UIHandler.ts index 571141e..0ca4583 100644 --- a/src/core/UIHandler.ts +++ b/src/core/UIHandler.ts @@ -76,8 +76,8 @@ export class UIHandler extends EventTarget { return this.processInteraction(element, 'GET', element.href, null, options, event); } - public async submitForm(form: HTMLFormElement, options: Options = {}, event?: SubmitEvent): Promise { - const submitter = event?.submitter; + public async submitForm(form: HTMLFormElement, options: Options = {}, event?: Event): Promise { + const submitter = event?.type === 'submit' ? (event as SubmitEvent)?.submitter : null; 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); From dfe8ddebc26935aeb5d65616fc296858d75c0f52 Mon Sep 17 00:00:00 2001 From: Filip Halama Date: Wed, 29 May 2024 18:30:37 +0200 Subject: [PATCH 6/9] UIHandler.clickElement: Handles click events on button|input elements --- src/core/UIHandler.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/core/UIHandler.ts b/src/core/UIHandler.ts index 0ca4583..26ed98f 100644 --- a/src/core/UIHandler.ts +++ b/src/core/UIHandler.ts @@ -68,12 +68,24 @@ export class UIHandler extends EventTarget { } } else if (event.type === 'click') { - this.clickElement(element as HTMLAnchorElement, options, mouseEvent).catch(ignoreErrors); + this.processInteraction(element as HTMLAnchorElement, 'GET', (element as HTMLAnchorElement).href, null, options, mouseEvent).catch(ignoreErrors); + } } - public async clickElement(element: HTMLAnchorElement, options: Options = {}, event?: MouseEvent): Promise { - return this.processInteraction(element, 'GET', element.href, null, options, event); + public async clickElement(element: HTMLElement, options: Options = {}, event?: MouseEvent): Promise { + if (element.tagName === 'A') { + return this.processInteraction(element, 'GET', (element as HTMLAnchorElement).href, null, options, event); + + } else if (element.tagName === 'INPUT' || element.tagName === 'BUTTON') { + const {form} = element as HTMLButtonElement | HTMLInputElement; + if (form) { + return this.submitForm(form, options, event); + } + + } + + return {}; } public async submitForm(form: HTMLFormElement, options: Options = {}, event?: Event): Promise { From c1e7d7fb602447e842646eae4981a3778a23e925 Mon Sep 17 00:00:00 2001 From: Filip Halama Date: Wed, 29 May 2024 18:41:24 +0200 Subject: [PATCH 7/9] UIHandler.bindUI: Logical order --- src/core/UIHandler.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/core/UIHandler.ts b/src/core/UIHandler.ts index 26ed98f..3dc1d49 100644 --- a/src/core/UIHandler.ts +++ b/src/core/UIHandler.ts @@ -27,20 +27,20 @@ export class UIHandler extends EventTarget { element.addEventListener('click', this.handler); }; - const elements = element.querySelectorAll(selector); - elements.forEach((element) => bindElement(element as HTMLAnchorElement)); - if (element.matches(selector)) { - bindElement(element as HTMLAnchorElement); + 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.tagName === 'FORM') { - bindForm(element as HTMLFormElement); + return bindForm(element as HTMLFormElement); } const forms = element.querySelectorAll('form'); From bbd6cdab79e796658389084bacbafb94fdb5a1d1 Mon Sep 17 00:00:00 2001 From: Filip Halama Date: Wed, 29 May 2024 18:48:00 +0200 Subject: [PATCH 8/9] UIHandler: tests --- tests/Naja.UIHandler.js | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/Naja.UIHandler.js b/tests/Naja.UIHandler.js index 15ca9a1..02875c1 100644 --- a/tests/Naja.UIHandler.js +++ b/tests/Naja.UIHandler.js @@ -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); @@ -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 () { From 2255ac4a0906752cf6ed0acc35409726086dbfdf Mon Sep 17 00:00:00 2001 From: Filip Halama Date: Tue, 4 Jun 2024 16:10:58 +0200 Subject: [PATCH 9/9] UiHandler: submitForm --- src/core/UIHandler.ts | 32 +++++++++++++++++++++----------- tests/Naja.UIHandler.js | 14 ++++++++++++++ 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/src/core/UIHandler.ts b/src/core/UIHandler.ts index 3dc1d49..9d94020 100644 --- a/src/core/UIHandler.ts +++ b/src/core/UIHandler.ts @@ -77,24 +77,34 @@ export class UIHandler extends EventTarget { if (element.tagName === 'A') { return this.processInteraction(element, 'GET', (element as HTMLAnchorElement).href, null, options, event); - } else if (element.tagName === 'INPUT' || element.tagName === 'BUTTON') { - const {form} = element as HTMLButtonElement | HTMLInputElement; - if (form) { - return this.submitForm(form, options, event); - } + } else if (element.tagName === 'INPUT' || element.tagName === 'BUTTON' && (element as HTMLButtonElement | HTMLInputElement).form) { + return this.submitForm(element, options, event); } return {}; } - public async submitForm(form: HTMLFormElement, options: Options = {}, event?: Event): Promise { - const submitter = event?.type === 'submit' ? (event as SubmitEvent)?.submitter : null; - 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(sender: HTMLFormElement|HTMLElement, options: Options = {}, event?: Event): Promise { + let form: HTMLFormElement|null = sender.tagName === 'FORM' ? sender as HTMLFormElement : null; + let submitter: HTMLElement|null|undefined = null; + + 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; + } + + 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); - return this.processInteraction(submitter ?? form, method, url, data, options, event); + return this.processInteraction(submitter ?? form, method, url, data, options, event); + } + + return {}; } public async processInteraction( diff --git a/tests/Naja.UIHandler.js b/tests/Naja.UIHandler.js index 02875c1..1c1a8e1 100644 --- a/tests/Naja.UIHandler.js +++ b/tests/Naja.UIHandler.js @@ -709,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 () {