From 3746e07276b91129407bdbc8fff56a7b86b798b7 Mon Sep 17 00:00:00 2001 From: nathan Date: Wed, 20 Dec 2023 15:22:14 -0700 Subject: [PATCH] Et2Email: Some automatic tests --- api/js/etemplate/Et2Email/Et2Email.ts | 77 +++++++++++-- .../etemplate/Et2Email/test/Et2Email.test.ts | 103 ++++++++++++++---- api/js/etemplate/Et2Select/Tag/Et2EmailTag.ts | 2 +- .../Et2Select/test/Et2EmailTag.test.ts | 3 +- 4 files changed, 152 insertions(+), 33 deletions(-) diff --git a/api/js/etemplate/Et2Email/Et2Email.ts b/api/js/etemplate/Et2Email/Et2Email.ts index 774d67933e..eb8a193722 100644 --- a/api/js/etemplate/Et2Email/Et2Email.ts +++ b/api/js/etemplate/Et2Email/Et2Email.ts @@ -89,16 +89,7 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI // Parse string into array if(typeof value === 'string' && value.indexOf(',') !== -1) { - let val = value.split(','); - for(let n = 0; n < val.length - 1; n++) - { - while(val[n].indexOf('@') === -1 && n < val.length - 1) - { - val[n] += ',' + val[n + 1]; - val.splice(n + 1, 1); - } - } - return val; + return parseEmailsString(value, false); } return value; }, @@ -676,6 +667,47 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI } } +<<<<<<< HEAD +======= + + /** + * Sometimes users paste multiple comma separated values at once. Split them then handle normally. + * Overridden here to handle email addresses that may have commas using the regex from the validator. + * + * @param {ClipboardEvent} event + * @protected + */ + protected handlePaste(event : ClipboardEvent) + { + event.preventDefault(); + + let paste = event.clipboardData.getData('text'); + if(!paste) + { + return; + } + const selection = window.getSelection(); + if(selection.rangeCount) + { + selection.deleteFromDocument(); + } + let values = parseEmailsString(paste, this.allowPlaceholder); + + if(values) + { + values.forEach(v => + { + this.addAddress(v.trim()); + }); + this.hide(); + + // Update key to force Lit to redraw tags + this._valueUID = this.egw()?.uid() ?? new Date().toISOString(); + this.dispatchEvent(new Event("change", {bubbles: true})); + } + } + +>>>>>>> f68faa7941 (Et2Email: Some automatic tests) private handleSearchFocus() { this.hasFocus = true; @@ -693,6 +725,26 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI this.hasFocus = false; // Should not be needed, but not firing the update this.requestUpdate("hasFocus"); +<<<<<<< HEAD +======= + + // If they had something OK typed, use it, but only if focus went outside Et2Email + // because maybe they clicked an option which took focus + if(event.composedPath().includes(this)) + { + if(this.addAddress(this._search.value.trim())) + { + this._search.value = ""; + this.dispatchEvent(new Event("change", {bubbles: true})); + } + else if(this._search.value) + { + // Invalid input, show message. Not part of the value, so normal validation doesn't apply + // Can't just call this.validate(), it will get cleared immediately + this.set_validation_error(this.egw().lang("Invalid email") + ' "' + this._search.value + '"') + } + } +>>>>>>> f68faa7941 (Et2Email: Some automatic tests) } handleSearchKeyDown(event) @@ -742,6 +794,7 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI { this.open = false; this._search.value = ""; + this.dispatchEvent(new Event("change", {bubbles: true})); } if(event.key == "Tab") { @@ -927,6 +980,7 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI this._search.value = ""; this._search.focus(); this.requestUpdate("value"); + this.dispatchEvent(new Event("change", {bubbles: true})); if(this._close_on_select) { this.open = false; @@ -941,6 +995,7 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI let index = this.value.indexOf(event.originalValue); this.value[index] = event.target.value; this.requestUpdate(); + this.dispatchEvent(new Event("change", {bubbles: true})); } if(event.target.current) { @@ -954,6 +1009,7 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI const index = this.value.indexOf(value); this.value.splice(index, 1); this.requestUpdate("value"); + this.dispatchEvent(new Event("change", {bubbles: true})); } tagsTemplate() @@ -1143,6 +1199,7 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI } } +// @ts-ignore TypeScript is not recognizing that this widget is a LitElement customElements.define("et2-email", Et2Email); /** diff --git a/api/js/etemplate/Et2Email/test/Et2Email.test.ts b/api/js/etemplate/Et2Email/test/Et2Email.test.ts index 49c0c4980a..e4559ed93a 100644 --- a/api/js/etemplate/Et2Email/test/Et2Email.test.ts +++ b/api/js/etemplate/Et2Email/test/Et2Email.test.ts @@ -2,6 +2,8 @@ import {assert, elementUpdated, fixture, html, oneEvent} from '@open-wc/testing' import * as sinon from 'sinon'; import {inputBasicTests} from "../../Et2InputWidget/test/InputBasicTests"; import {Et2Email} from "../Et2Email"; +import {Et2EmailTag} from "../../Et2Select/Tag/Et2EmailTag"; +import {waitForEvent} from "../../Et2Widget/event"; /** * Test file for Etemplate webComponent Select @@ -10,11 +12,24 @@ import {Et2Email} from "../Et2Email"; */ // Stub global egw for cssImage to find // @ts-ignore +let uid = 0; +const testSuggestions = [ + {value: "suggestion.1@example.com", label: "Suggestion 1"}, + {value: "suggestion.2@example.com", label: "Suggestion 2"} +]; window.egw = { + ajaxUrl: () => "", + app: () => "addressbook", + decodePath: (_path : string) => _path, image: () => "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gR2VuZXJhdG9yOiBBZG9iZSBJbGx1c3RyYXRvciAxNS4wLjAsIFNWRyBFeHBvcnQgUGx1Zy1JbiAuIFNWRyBWZXJzaW9uOiA2LjAwIEJ1aWxkIDApICAtLT4NCjwhRE9DVFlQRSBzdmcgUFVCTElDICItLy9XM0MvL0RURCBTVkcgMS4xLy9FTiIgImh0dHA6Ly93d3cudzMub3JnL0dyYXBoaWNzL1NWRy8xLjEvRFREL3N2ZzExLmR0ZCI+DQo8c3ZnIHZlcnNpb249IjEuMSIgaWQ9IkViZW5lXzEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KCSB3aWR0aD0iMzJweCIgaGVpZ2h0PSIzMnB4IiB2aWV3Qm94PSIwIDAgMzIgMzIiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDMyIDMyIiB4bWw6c3BhY2U9InByZXNlcnZlIj4NCjxwYXRoIGZpbGwtcnVsZT0iZXZlbm9kZCIgY2xpcC1ydWxlPSJldmVub2RkIiBmaWxsPSIjNjk2OTY5IiBkPSJNNi45NDMsMjguNDUzDQoJYzAuOTA2LDAuNzY1LDIuMDk3LDEuMTI3LDMuMjg2LDEuMTA5YzAuNDMsMC4wMTQsMC44NTItMC4wNjgsMS4yNjUtMC4yMDdjMC42NzktMC4xOCwxLjMyOC0wLjQ1LDEuODY2LTAuOTAyTDI5LjQwMywxNC45DQoJYzEuNzcyLTEuNDk4LDEuNzcyLTMuOTI1LDAtNS40MjJjLTEuNzcyLTEuNDk3LTQuNjQ2LTEuNDk3LTYuNDE4LDBMMTAuMTE5LDIwLjM0OWwtMi4zODktMi40MjRjLTEuNDQtMS40NTctMy43NzItMS40NTctNS4yMTIsMA0KCWMtMS40MzgsMS40Ni0xLjQzOCwzLjgyNSwwLDUuMjgxQzIuNTE4LDIzLjIwNiw1LjQ3NCwyNi45NDcsNi45NDMsMjguNDUzeiIvPg0KPC9zdmc+DQo=", + jsonq: () => Promise.resolve({}), lang: i => i + "*", + link: i => i, + preference: i => "", + request: () => Promise.resolve(testSuggestions), tooltipUnbind: () => {}, - webserverUrl: "" + webserverUrl: "", + uid: () => {return "" + (uid++);} }; let element : Et2Email; @@ -52,7 +67,13 @@ describe("Email widget basics", () => await elementUpdated(element); assert.equal(element.querySelector("[slot='label']").textContent, "Label set"); - }) + }); + + it("textbox gets focus when widget is focused", async() => + { + element.focus(); + assert.equal(element.shadowRoot.activeElement, element._search, "Search textbox did not get focus when widget got focus"); + }); it("closes when losing focus", async() => { @@ -67,8 +88,9 @@ describe("Email widget basics", () => { element.addEventListener("sl-hide", resolve); }); + await elementUpdated(element); - element.focus(); + element.show(); await showPromise; await elementUpdated(element); @@ -82,7 +104,57 @@ describe("Email widget basics", () => // Check that it actually closed dropdown assert.isFalse(element.hasAttribute("open")); - }) + }); + + it("blurring widget accepts current text", async() => + { + const value = "valid@example.com"; + element.focus(); + element._search.value = value; + element.blur(); + await elementUpdated(element); + + assert.sameMembers(element.value, [value], "Valid email was not accepted on blur"); + }); +}); +describe("Suggestions", () => +{ // Setup run before each test + beforeEach(before); + + it("clicking accepts suggestion", async() => + { + await elementUpdated(element); + // Start the search + element.focus(); + element.startSearch(); + debugger; + await waitForEvent(element, "sl-after-show"); + + // Click the first one + element._listbox.querySelector('sl-option').dispatchEvent(new MouseEvent("mouseup", {bubbles: true})) + await elementUpdated(element); + // Check the value + assert.sameMembers(element.value, [testSuggestions[0].value]); + }); + + it("tab accepts top suggestion", async() => + { + element.focus(); + element.startSearch(); + await waitForEvent(element, "sl-after-show"); + + // No match between what they typed and the suggestion - no + element._search.dispatchEvent(new KeyboardEvent("keydown", {key: "Tab"})); + await elementUpdated(element); + assert.sameMembers(element.value, []); + + // Partial match with current suggestion, take it + element.focus(); + element._search.value = "sugg"; + element._search.dispatchEvent(new KeyboardEvent("keydown", {key: "Tab"})); + await elementUpdated(element); + assert.sameMembers(element.value, [testSuggestions[0].value]); + }); }); describe("Tags", () => @@ -107,21 +179,11 @@ describe("Tags", () => { assert.equal(element._tags.length, 2, "Did not find tags"); - // Await tags to render - /* TODO - let tag_updates = [] - element.select.combobox.querySelectorAll("et2-tag").forEach((t : Et2Tag) => tag_updates.push(t.updateComplete)); - await Promise.all(tag_updates); - - assert.equal(tags.length, 2); - assert.equal(tags[0].value, "one"); - assert.equal(tags[1].value, "two"); -*/ // Set up listener const listener = oneEvent(element, "change"); // Click to remove first tag - let removeButton = tags[0].shadowRoot.querySelector("[part='remove-button']"); + let removeButton = element._tags[0].shadowRoot.querySelector("[part='remove-button']"); assert.exists(removeButton, "Could not find tag remove button"); removeButton.dispatchEvent(new Event("click")); @@ -129,14 +191,13 @@ describe("Tags", () => // Wait for widget to update await element.updateComplete; - tag_updates = [] - element.select.combobox.querySelectorAll('et2-tag').forEach((t : Et2Tag) => tag_updates.push(t.updateComplete)); + let tag_updates = [] + element._tags.forEach((t : Et2EmailTag) => tag_updates.push(t.updateComplete)); await Promise.all(tag_updates); // Check - assert.sameMembers(element.value, ["two"], "Removing tag did not remove value"); - tags = element.select.combobox.querySelectorAll('.select__tags et2-tag'); - assert.equal(tags.length, 1, "Removed tag is still there"); + assert.sameMembers(element.value, ["two@example.com"], "Removing tag did not remove value"); + assert.equal(element._tags.length, 1, "Removed tag is still there"); }); }); @@ -146,4 +207,4 @@ inputBasicTests(async() => const element = await before(); element.noLang = true; return element -}, "", "sl-select"); \ No newline at end of file +}, "", "input"); \ No newline at end of file diff --git a/api/js/etemplate/Et2Select/Tag/Et2EmailTag.ts b/api/js/etemplate/Et2Select/Tag/Et2EmailTag.ts index c6376e6405..c9cf2615a3 100644 --- a/api/js/etemplate/Et2Select/Tag/Et2EmailTag.ts +++ b/api/js/etemplate/Et2Select/Tag/Et2EmailTag.ts @@ -181,7 +181,7 @@ export class Et2EmailTag extends Et2Tag e.stopPropagation(); let extra = { - 'presets[email]': this.value + 'presets[email]': this.value ?? "" }; this.egw().open('', 'addressbook', 'add', extra); diff --git a/api/js/etemplate/Et2Select/test/Et2EmailTag.test.ts b/api/js/etemplate/Et2Select/test/Et2EmailTag.test.ts index 77a1af7c3e..e81966b651 100644 --- a/api/js/etemplate/Et2Select/test/Et2EmailTag.test.ts +++ b/api/js/etemplate/Et2Select/test/Et2EmailTag.test.ts @@ -76,7 +76,8 @@ describe('Et2EmailTag', () => assert.equal(extra['presets[email]'], 'test@example.com'); } }; - component.handleMouseDown(new MouseEvent('click')); + debugger; + component.shadowRoot.querySelector("et2-button-icon").dispatchEvent(new MouseEvent('click')); }); it('should open addressbook CRM on avatar click', async() =>