diff --git a/e2e/integration/LinkingChildToSchool.cy.ts b/e2e/integration/LinkingChildToSchool.cy.ts index e34cb8b00f..933341e1eb 100644 --- a/e2e/integration/LinkingChildToSchool.cy.ts +++ b/e2e/integration/LinkingChildToSchool.cy.ts @@ -25,7 +25,7 @@ describe("Scenario: Linking a child to a school - E2E test", function () { // choose the school to add cy.contains("mat-form-field", "School") - .find("[matInput]") + .find("[matInput]:visible") .type("E2E School{enter}"); // save school in child profile diff --git a/src/app/core/configurable-enum/basic-autocomplete/basic-autocomplete.component.html b/src/app/core/configurable-enum/basic-autocomplete/basic-autocomplete.component.html index 95d1138694..248a4fbf65 100644 --- a/src/app/core/configurable-enum/basic-autocomplete/basic-autocomplete.component.html +++ b/src/app/core/configurable-enum/basic-autocomplete/basic-autocomplete.component.html @@ -1,23 +1,40 @@ + + + + + - {{ item.asString }} + {{ + item.asString + }} - Add - option {{ inputElement.value }} + Add option + {{ inputElement.value }} diff --git a/src/app/core/configurable-enum/basic-autocomplete/basic-autocomplete.component.spec.ts b/src/app/core/configurable-enum/basic-autocomplete/basic-autocomplete.component.spec.ts index 33e7ad9c96..ba24cb64b9 100644 --- a/src/app/core/configurable-enum/basic-autocomplete/basic-autocomplete.component.spec.ts +++ b/src/app/core/configurable-enum/basic-autocomplete/basic-autocomplete.component.spec.ts @@ -15,7 +15,6 @@ import { NoopAnimationsModule } from "@angular/platform-browser/animations"; import { MatDialogModule } from "@angular/material/dialog"; import { TestbedHarnessEnvironment } from "@angular/cdk/testing/testbed"; import { HarnessLoader } from "@angular/cdk/testing"; -import { MatInputHarness } from "@angular/material/input/testing"; import { MatAutocompleteHarness } from "@angular/material/autocomplete/testing"; import { FormControl, @@ -24,6 +23,8 @@ import { NgForm, Validators, } from "@angular/forms"; +import { genders } from "../../../child-dev-project/children/model/genders"; +import { ConfirmationDialogService } from "../../confirmation-dialog/confirmation-dialog.service"; describe("BasicAutocompleteComponent", () => { let component: BasicAutocompleteComponent; @@ -92,9 +93,7 @@ describe("BasicAutocompleteComponent", () => { component.ngOnChanges({ value: true, options: true, valueMapper: true }); fixture.detectChanges(); - expect(component.autocompleteForm).toHaveValue("First Child"); - const inputElement = await loader.getHarness(MatInputHarness); - await expectAsync(inputElement.getValue()).toBeResolvedTo("First Child"); + expect(component.displayText).toBe("First Child"); }); it("should have the correct entity selected when it's name is entered", () => { @@ -108,7 +107,7 @@ describe("BasicAutocompleteComponent", () => { expect(component.value).toBe(child1.getId()); }); - it("should reset if nothing has been selected", fakeAsync(() => { + it("should reset if leaving empty autocomplete", fakeAsync(() => { const first = Child.create("First"); const second = Child.create("Second"); component.options = [first, second]; @@ -117,7 +116,7 @@ describe("BasicAutocompleteComponent", () => { component.select({ asValue: first.getId() } as any); expect(component.value).toBe(first.getId()); - component.autocompleteForm.setValue("Non existent"); + component.autocompleteForm.setValue(""); component.onFocusOut({} as any); tick(200); @@ -145,25 +144,30 @@ describe("BasicAutocompleteComponent", () => { expect(options).toHaveSize(3); await options[2].click(); + // When browser is not in foreground, this doesn't happen automatically + component.autocomplete.openPanel(); + fixture.detectChanges(); await options[1].click(); expect(component.value).toEqual([0, 2]); }); - it("should clear the input when focusing in multi select mode", fakeAsync(() => { + it("should switch the input when focusing in multi select mode", fakeAsync(() => { component.multi = true; component.options = ["some", "values", "and", "other", "options"]; component.value = ["some", "values"]; component.ngOnChanges({ value: true, options: true }); - expect(component.autocompleteForm).toHaveValue("some, values"); + expect(component.displayText).toBe("some, values"); - component.onFocusIn(); + component.showAutocomplete(); expect(component.autocompleteForm).toHaveValue(""); + expect(component.focused).toBeTrue(); component.onFocusOut({} as any); tick(200); - expect(component.autocompleteForm).toHaveValue("some, values"); + expect(component.displayText).toBe("some, values"); + expect(component.focused).toBeFalse(); })); it("should update the error state if the form is invalid", () => { @@ -178,4 +182,45 @@ describe("BasicAutocompleteComponent", () => { expect(component.errorState).toBeTrue(); }); + + it("should create new option", fakeAsync(() => { + const newOption = "new option"; + const confirmationSpy = spyOn( + TestBed.inject(ConfirmationDialogService), + "getConfirmation" + ); + component.createOption = (id) => ({ id: id, label: id }); + const createOptionEventSpy = spyOn( + component, + "createOption" + ).and.callThrough(); + component.options = genders; + const initialValue = genders[0].id; + component.value = initialValue; + component.valueMapper = (o) => o.id; + + component.ngOnChanges({ value: true, options: true, valueMapper: true }); + + component.showAutocomplete(); + component.autocompleteForm.setValue(newOption); + + // decline confirmation for new option + confirmationSpy.and.resolveTo(false); + component.select(newOption); + + tick(); + expect(confirmationSpy).toHaveBeenCalled(); + expect(createOptionEventSpy).not.toHaveBeenCalled(); + expect(component.value).toEqual(initialValue); + + // confirm new option + confirmationSpy.calls.reset(); + confirmationSpy.and.resolveTo(true); + component.select(newOption); + + tick(); + expect(confirmationSpy).toHaveBeenCalled(); + expect(createOptionEventSpy).toHaveBeenCalledWith(newOption); + expect(component.value).toEqual(newOption); + })); }); diff --git a/src/app/core/configurable-enum/basic-autocomplete/basic-autocomplete.component.ts b/src/app/core/configurable-enum/basic-autocomplete/basic-autocomplete.component.ts index a19a6fc7c2..0a7d7406a4 100644 --- a/src/app/core/configurable-enum/basic-autocomplete/basic-autocomplete.component.ts +++ b/src/app/core/configurable-enum/basic-autocomplete/basic-autocomplete.component.ts @@ -23,13 +23,19 @@ import { MatAutocompleteModule, MatAutocompleteTrigger, } from "@angular/material/autocomplete"; -import { concat, of, skip } from "rxjs"; import { MatCheckboxModule } from "@angular/material/checkbox"; -import { distinctUntilChanged, filter, map, startWith } from "rxjs/operators"; +import { + distinctUntilChanged, + filter, + map, + skip, + startWith, +} from "rxjs/operators"; import { ConfirmationDialogService } from "../../confirmation-dialog/confirmation-dialog.service"; import { ErrorStateMatcher } from "@angular/material/core"; import { CustomFormControlDirective } from "./custom-form-control.directive"; import { coerceBooleanProperty } from "@angular/cdk/coercion"; +import { concat, of } from "rxjs"; interface SelectableOption { initial: O; @@ -82,7 +88,14 @@ export class BasicAutocompleteComponent startWith([] as SelectableOption[]) ); showAddOption = false; - private delayedBlur: any; + + get displayText() { + const values: V[] = Array.isArray(this.value) ? this.value : [this.value]; + + return values + .map((v) => this._options.find((o) => o.asValue === v)?.asString) + .join(", "); + } get disabled(): boolean { return this._disabled; @@ -136,10 +149,25 @@ export class BasicAutocompleteComponent } showAutocomplete() { - this.autocompleteSuggestedOptions = concat( - of(this._options), - this.autocompleteSuggestedOptions.pipe(skip(1)) - ); + if (this.multi) { + this.autocompleteForm.setValue(""); + } else { + // cannot setValue to "" here because the current selection would be lost + this.autocompleteForm.setValue(this.displayText); + this.autocompleteSuggestedOptions = concat( + of(this._options), + this.autocompleteSuggestedOptions.pipe(skip(1)) + ); + } + setTimeout(() => { + this.inputElement.focus(); + + // select all text for easy overwriting when typing to search for options + ( + this.inputElement._elementRef.nativeElement as HTMLInputElement + ).select(); + }); + this.focus(); } private updateAutocomplete(inputText: string): SelectableOption[] { @@ -160,24 +188,9 @@ export class BasicAutocompleteComponent this._options.forEach( (o) => (o.selected = (this.value as V[])?.includes(o.asValue)) ); - this.displaySelectedOptions(); - } else { - const selected = this._options.find( - ({ asValue }) => asValue === this.value - ); - this.autocompleteForm.setValue(selected?.asString ?? ""); } } - private displaySelectedOptions() { - this.autocompleteForm.setValue( - this._options - .filter((o) => o.selected) - .map((o) => o.asString) - .join(", ") - ); - } - select(selected: string | SelectableOption) { if (typeof selected === "string") { this.createNewOption(selected); @@ -202,6 +215,10 @@ export class BasicAutocompleteComponent const newOption = this.toSelectableOption(this.createOption(option)); this._options.push(newOption); this.select(newOption); + } else { + // continue editing + this.showAutocomplete(); + this.autocompleteForm.setValue(option); } } @@ -212,10 +229,8 @@ export class BasicAutocompleteComponent .filter((o) => o.selected) .map((o) => o.asValue); // re-open autocomplete to select next option - this.autocompleteForm.setValue(""); - setTimeout(() => this.autocomplete.openPanel(), 100); + this.showAutocomplete(); } else { - this.autocompleteForm.setValue(option.asString); this.value = option.asValue; } } @@ -229,49 +244,23 @@ export class BasicAutocompleteComponent }; } - onFocusIn() { - clearTimeout(this.delayedBlur); - if (!this.focused) { - if (this.multi) { - this.autocompleteForm.setValue(""); - } else { - this.showAutocomplete(); - } - this.focus(); - } - } - onFocusOut(event: FocusEvent) { if ( !this.elementRef.nativeElement.contains(event.relatedTarget as Element) ) { - // use short timeout in order for creating an option to work - this.delayedBlur = setTimeout(() => this.notifyFocusOut(), 200); - } - } - - private notifyFocusOut() { - if (this.multi) { - this.displaySelectedOptions(); - } else { - const inputValue = this.autocompleteForm.value; - const selectedOption = this._options.find( - ({ asValue }) => asValue === this._value - ); - if (selectedOption?.asString !== inputValue) { - // try to select the option that matches the input string - const matchingOption = this._options.find( - ({ asString }) => asString.toLowerCase() === inputValue.toLowerCase() - ); - this.select(matchingOption); + if (!this.multi && this.autocompleteForm.value === "") { + this.select(undefined); } + this.blur(); } - this.blur(); } onContainerClick(event: MouseEvent) { - if ((event.target as Element).tagName.toLowerCase() != "input") { - this.inputElement.focus(); + if ( + !this._disabled && + (event.target as Element).tagName.toLowerCase() != "input" + ) { + this.showAutocomplete(); } }