diff --git a/web/scripts/eslint.nightmare.mjs b/web/scripts/eslint.nightmare.mjs index 0c44096ee8..66869375f5 100644 --- a/web/scripts/eslint.nightmare.mjs +++ b/web/scripts/eslint.nightmare.mjs @@ -6,6 +6,142 @@ import wcconf from "eslint-plugin-wc"; import globals from "globals"; import tseslint from "typescript-eslint"; +const MAX_DEPTH = 4; +const MAX_NESTED_CALLBACKS = 4; +const MAX_PARAMS = 5; +const MAX_COGNITIVE_COMPLEXITY = 9; + +const rules = { + "accessor-pairs": "error", + "array-callback-return": "error", + "block-scoped-var": "error", + "consistent-return": "error", + "consistent-this": ["error", "that"], + "curly": ["error", "all"], + "dot-notation": [ + "error", + { + allowKeywords: true, + }, + ], + "eqeqeq": "error", + "func-names": "error", + "guard-for-in": "error", + "max-depth": ["error", MAX_DEPTH], + "max-nested-callbacks": ["error", MAX_NESTED_CALLBACKS], + "max-params": ["error", MAX_PARAMS], + "new-cap": "error", + "no-alert": "error", + "no-array-constructor": "error", + "no-bitwise": "error", + "no-caller": "error", + "no-case-declarations": "error", + "no-class-assign": "error", + "no-cond-assign": "error", + "no-const-assign": "error", + "no-constant-condition": "error", + "no-control-regex": "error", + "no-debugger": "error", + "no-delete-var": "error", + "no-div-regex": "error", + "no-dupe-args": "error", + "no-dupe-keys": "error", + "no-duplicate-case": "error", + "no-else-return": "error", + "no-empty": "error", + "no-empty-character-class": "error", + "no-empty-function": "error", + "no-labels": "error", + "no-eq-null": "error", + "no-eval": "error", + "no-ex-assign": "error", + "no-extend-native": "error", + "no-extra-bind": "error", + "no-extra-boolean-cast": "error", + "no-extra-label": "error", + "no-fallthrough": "error", + "no-func-assign": "error", + "no-implied-eval": "error", + "no-implicit-coercion": "error", + "no-implicit-globals": "error", + "no-inner-declarations": ["error", "functions"], + "no-invalid-regexp": "error", + "no-irregular-whitespace": "error", + "no-iterator": "error", + "no-invalid-this": "error", + "no-label-var": "error", + "no-lone-blocks": "error", + "no-lonely-if": "error", + "no-loop-func": "error", + "no-magic-numbers": ["error", { ignore: [0, 1, -1] }], + "no-multi-str": "error", + "no-negated-condition": "error", + "no-nested-ternary": "error", + "no-new": "error", + "no-new-func": "error", + "no-new-wrappers": "error", + "no-obj-calls": "error", + "no-octal": "error", + "no-octal-escape": "error", + "no-param-reassign": "error", + "no-proto": "error", + "no-redeclare": "error", + "no-regex-spaces": "error", + "no-restricted-syntax": ["error", "WithStatement"], + "no-script-url": "error", + "no-self-assign": "error", + "no-self-compare": "error", + "no-sequences": "error", + "no-shadow": "error", + "no-shadow-restricted-names": "error", + "no-sparse-arrays": "error", + "no-this-before-super": "error", + "no-throw-literal": "error", + "no-trailing-spaces": "error", + "no-undef": "error", + "no-undef-init": "error", + "no-unexpected-multiline": "error", + "no-useless-constructor": "error", + "no-unmodified-loop-condition": "error", + "no-unneeded-ternary": "error", + "no-unreachable": "error", + "no-unused-expressions": "error", + "no-unused-labels": "error", + "no-use-before-define": "error", + "no-useless-call": "error", + "no-dupe-class-members": "error", + "no-var": "error", + "no-void": "error", + "no-with": "error", + "prefer-arrow-callback": "error", + "prefer-const": "error", + "prefer-rest-params": "error", + "prefer-spread": "error", + "prefer-template": "error", + "radix": "error", + "require-yield": "error", + "strict": ["error", "global"], + "use-isnan": "error", + "valid-typeof": "error", + "vars-on-top": "error", + "yoda": ["error", "never"], + + "no-unused-vars": "off", + "no-console": ["error", { allow: ["debug", "warn", "error"] }], + "sonarjs/cognitive-complexity": ["off", MAX_COGNITIVE_COMPLEXITY], + "sonarjs/no-duplicate-string": "off", + "sonarjs/no-nested-template-literals": "off", + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + caughtErrorsIgnorePattern: "^_", + }, + ], +}; + export default [ // You would not believe how much this change has frustrated users: ["if an ignores key is used // without any other keys in the configuration object, then the patterns act as global @@ -24,6 +160,7 @@ export default [ "src/locale-codes.ts", "storybook-static/", "src/locales/", + "src/**/*.test.ts", ], }, eslint.configs.recommended, @@ -43,136 +180,7 @@ export default [ }, }, files: ["src/**"], - rules: { - "accessor-pairs": "error", - "array-callback-return": "error", - "block-scoped-var": "error", - "consistent-return": "error", - "consistent-this": ["error", "that"], - "curly": ["error", "all"], - "dot-notation": [ - "error", - { - allowKeywords: true, - }, - ], - "eqeqeq": "error", - "func-names": "error", - "guard-for-in": "error", - "max-depth": ["error", 4], - "max-nested-callbacks": ["error", 4], - "max-params": ["error", 5], - "new-cap": "error", - "no-alert": "error", - "no-array-constructor": "error", - "no-bitwise": "error", - "no-caller": "error", - "no-case-declarations": "error", - "no-class-assign": "error", - "no-cond-assign": "error", - "no-const-assign": "error", - "no-constant-condition": "error", - "no-control-regex": "error", - "no-debugger": "error", - "no-delete-var": "error", - "no-div-regex": "error", - "no-dupe-args": "error", - "no-dupe-keys": "error", - "no-duplicate-case": "error", - "no-else-return": "error", - "no-empty": "error", - "no-empty-character-class": "error", - "no-empty-function": "error", - "no-labels": "error", - "no-eq-null": "error", - "no-eval": "error", - "no-ex-assign": "error", - "no-extend-native": "error", - "no-extra-bind": "error", - "no-extra-boolean-cast": "error", - "no-extra-label": "error", - "no-fallthrough": "error", - "no-func-assign": "error", - "no-implied-eval": "error", - "no-implicit-coercion": "error", - "no-implicit-globals": "error", - "no-inner-declarations": ["error", "functions"], - "no-invalid-regexp": "error", - "no-irregular-whitespace": "error", - "no-iterator": "error", - "no-invalid-this": "error", - "no-label-var": "error", - "no-lone-blocks": "error", - "no-lonely-if": "error", - "no-loop-func": "error", - "no-magic-numbers": ["error", { ignore: [0, 1, -1] }], - "no-multi-str": "error", - "no-negated-condition": "error", - "no-nested-ternary": "error", - "no-new": "error", - "no-new-func": "error", - "no-new-wrappers": "error", - "no-obj-calls": "error", - "no-octal": "error", - "no-octal-escape": "error", - "no-param-reassign": "error", - "no-proto": "error", - "no-redeclare": "error", - "no-regex-spaces": "error", - "no-restricted-syntax": ["error", "WithStatement"], - "no-script-url": "error", - "no-self-assign": "error", - "no-self-compare": "error", - "no-sequences": "error", - "no-shadow": "error", - "no-shadow-restricted-names": "error", - "no-sparse-arrays": "error", - "no-this-before-super": "error", - "no-throw-literal": "error", - "no-trailing-spaces": "error", - "no-undef": "error", - "no-undef-init": "error", - "no-unexpected-multiline": "error", - "no-useless-constructor": "error", - "no-unmodified-loop-condition": "error", - "no-unneeded-ternary": "error", - "no-unreachable": "error", - "no-unused-expressions": "error", - "no-unused-labels": "error", - "no-use-before-define": "error", - "no-useless-call": "error", - "no-dupe-class-members": "error", - "no-var": "error", - "no-void": "error", - "no-with": "error", - "prefer-arrow-callback": "error", - "prefer-const": "error", - "prefer-rest-params": "error", - "prefer-spread": "error", - "prefer-template": "error", - "radix": "error", - "require-yield": "error", - "strict": ["error", "global"], - "use-isnan": "error", - "valid-typeof": "error", - "vars-on-top": "error", - "yoda": ["error", "never"], - - "no-unused-vars": "off", - "no-console": ["error", { allow: ["debug", "warn", "error"] }], - "sonarjs/cognitive-complexity": ["off", 9], - "sonarjs/no-duplicate-string": "off", - "sonarjs/no-nested-template-literals": "off", - "@typescript-eslint/ban-ts-comment": "off", - "@typescript-eslint/no-unused-vars": [ - "error", - { - argsIgnorePattern: "^_", - varsIgnorePattern: "^_", - caughtErrorsIgnorePattern: "^_", - }, - ], - }, + rules, }, { languageOptions: { @@ -186,18 +194,21 @@ export default [ }, }, files: ["scripts/*.mjs", "*.ts", "*.mjs"], - rules: { - "no-unused-vars": "off", - "no-console": "off", - "@typescript-eslint/ban-ts-comment": "off", - "@typescript-eslint/no-unused-vars": [ - "error", - { - argsIgnorePattern: "^_", - varsIgnorePattern: "^_", - caughtErrorsIgnorePattern: "^_", - }, - ], + rules, + }, + { + languageOptions: { + parser: tsparser, + parserOptions: { + ecmaVersion: 12, + sourceType: "module", + }, + globals: { + ...globals.nodeBuiltin, + ...globals.jest, + }, }, + files: ["src/**/*.test.ts"], + rules, }, ]; diff --git a/web/src/elements/forms/SearchSelect/SearchSelect.ts b/web/src/elements/forms/SearchSelect/SearchSelect.ts index 35b6939409..662e70b0e1 100644 --- a/web/src/elements/forms/SearchSelect/SearchSelect.ts +++ b/web/src/elements/forms/SearchSelect/SearchSelect.ts @@ -8,7 +8,7 @@ import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter"; import { randomId } from "@goauthentik/elements/utils/randomId.js"; import { msg } from "@lit/localize"; -import { TemplateResult, html } from "lit"; +import { PropertyValues, TemplateResult, html } from "lit"; import { property, state } from "lit/decorators.js"; import { ifDefined } from "lit/directives/if-defined.js"; @@ -16,6 +16,7 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css"; import { ResponseError } from "@goauthentik/api"; +import "./ak-search-select-loading-indicator.js"; import "./ak-search-select-view.js"; import { SearchSelectView } from "./ak-search-select-view.js"; @@ -120,6 +121,7 @@ export class SearchSelectBase return Promise.resolve(); } this.isFetchingData = true; + this.dispatchEvent(new Event("loading")); return this.fetchObjects(this.query) .then((objects) => { objects.forEach((obj) => { @@ -228,8 +230,15 @@ export class SearchSelectBase return html`${msg("Failed to fetch objects: ")} ${this.error.detail}`; } + // `this.objects` is both a container and a sigil; if it is in the `undefined` state, it's a + // marker that this component has not yet completed a *first* load. After that, it should + // never be empty. The only state that allows it to be empty after a successful retrieval is + // a subsequent retrieval failure, in which case `this.error` above will be populated and + // displayed before this. if (!this.objects) { - return html`${msg("Loading...")}`; + return html``; } const options = this.getGroupedItems(); @@ -248,7 +257,10 @@ export class SearchSelectBase > `; } - public override updated() { + public override updated(changed: PropertyValues) { + if (!this.isFetchingData && changed.has("objects")) { + this.dispatchEvent(new Event("ready")); + } // It is not safe for automated tests to interact with this component while it is fetching // data. if (!this.isFetchingData) { diff --git a/web/src/elements/forms/SearchSelect/ak-search-select-ez.ts b/web/src/elements/forms/SearchSelect/ak-search-select-ez.ts index a05d1d7f62..4160390443 100644 --- a/web/src/elements/forms/SearchSelect/ak-search-select-ez.ts +++ b/web/src/elements/forms/SearchSelect/ak-search-select-ez.ts @@ -1,8 +1,6 @@ import { TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; -import PFBase from "@patternfly/patternfly/patternfly-base.css"; - import { type ISearchSelectBase, SearchSelectBase } from "./SearchSelect.js"; export interface ISearchSelectApi { @@ -48,7 +46,7 @@ export interface ISearchSelectEz extends ISearchSelectBase { @customElement("ak-search-select-ez") export class SearchSelectEz extends SearchSelectBase implements ISearchSelectEz { static get styles() { - return [PFBase]; + return [...SearchSelectBase.styles]; } @property({ type: Object, attribute: false }) diff --git a/web/src/elements/forms/SearchSelect/ak-search-select-loading-indicator.ts b/web/src/elements/forms/SearchSelect/ak-search-select-loading-indicator.ts new file mode 100644 index 0000000000..94b1221262 --- /dev/null +++ b/web/src/elements/forms/SearchSelect/ak-search-select-loading-indicator.ts @@ -0,0 +1,64 @@ +import { AKElement } from "@goauthentik/elements/Base.js"; +import { randomId } from "@goauthentik/elements/utils/randomId.js"; + +import { msg } from "@lit/localize"; +import { html } from "lit"; +import { customElement } from "lit/decorators.js"; + +import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css"; +import PFSelect from "@patternfly/patternfly/components/Select/select.css"; +import PFBase from "@patternfly/patternfly/patternfly-base.css"; + +/** + * @class SearchSelectLoadingIndicator + * @element ak-search-select-loading-indicator + * + * Just a loading indicator to fill in while we wait for the view to settle + * + * ## Available CSS `part::` + * + * - @part ak-search-select: The main Patternfly div + * - @part ak-search-select-toggle: The Patternfly inner div + * - @part ak-search-select-wrapper: Yet another Patternfly inner div + * - @part ak-search-select-loading-indicator: The input object that hosts the "Loading..." message + */ + +@customElement("ak-search-select-loading-indicator") +export class SearchSelectLoadingIndicator extends AKElement { + static get styles() { + return [PFBase, PFFormControl, PFSelect]; + } + + connectedCallback() { + super.connectedCallback(); + this.setAttribute("data-ouia-component-type", "ak-search-select-loading-indicator"); + this.setAttribute("data-ouia-component-id", this.getAttribute("id") || randomId()); + this.setAttribute("data-ouia-component-safe", "true"); + } + + render() { + return html` +
+
+
+ +
+
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ak-search-select-loading-indicator": SearchSelectLoadingIndicator; + } +} diff --git a/web/src/elements/forms/SearchSelect/ak-search-select-view.ts b/web/src/elements/forms/SearchSelect/ak-search-select-view.ts index 7324b9edc4..c2133f6f16 100644 --- a/web/src/elements/forms/SearchSelect/ak-search-select-view.ts +++ b/web/src/elements/forms/SearchSelect/ak-search-select-view.ts @@ -69,6 +69,10 @@ export interface ISearchSelectView { */ @customElement("ak-search-select-view") export class SearchSelectView extends AKElement implements ISearchSelectView { + static get styles() { + return [PFBase, PFForm, PFFormControl, PFSelect]; + } + /** * The options collection. The simplest variant is just [key, label, optional]. See * the `./types.ts` file for variants and how to use them. @@ -186,10 +190,6 @@ export class SearchSelectView extends AKElement implements ISearchSelectView { */ flatOptions: [string, SelectOption][] = []; - static get styles() { - return [PFBase, PFForm, PFFormControl, PFSelect]; - } - connectedCallback() { super.connectedCallback(); this.setAttribute("data-ouia-component-type", "ak-search-select-view"); diff --git a/web/src/elements/forms/SearchSelect/ak-search-select.ts b/web/src/elements/forms/SearchSelect/ak-search-select.ts index 0c7bcf9b67..36b8b43325 100644 --- a/web/src/elements/forms/SearchSelect/ak-search-select.ts +++ b/web/src/elements/forms/SearchSelect/ak-search-select.ts @@ -3,8 +3,6 @@ import { groupBy } from "@goauthentik/common/utils"; import { TemplateResult } from "lit"; import { customElement, property } from "lit/decorators.js"; -import PFBase from "@patternfly/patternfly/patternfly-base.css"; - import { type ISearchSelectBase, SearchSelectBase } from "./SearchSelect.js"; export interface ISearchSelect extends ISearchSelectBase { @@ -57,7 +55,7 @@ export interface ISearchSelect extends ISearchSelectBase { @customElement("ak-search-select") export class SearchSelect extends SearchSelectBase implements ISearchSelect { static get styles() { - return [PFBase]; + return [...SearchSelectBase.styles]; } // A function which takes the query state object (accepting that it may be empty) and returns a diff --git a/web/src/elements/forms/SearchSelect/tests/ak-search-select-view.test.ts b/web/src/elements/forms/SearchSelect/tests/ak-search-select-view.test.ts new file mode 100644 index 0000000000..6c22e88696 --- /dev/null +++ b/web/src/elements/forms/SearchSelect/tests/ak-search-select-view.test.ts @@ -0,0 +1,104 @@ +import { $, browser } from "@wdio/globals"; +import { slug } from "github-slugger"; +import { Key } from "webdriverio"; + +import { html, render } from "lit"; + +import "../ak-search-select-view.js"; +import { sampleData } from "../stories/sampleData.js"; +import { AkSearchSelectViewDriver } from "./ak-search-select-view.comp.js"; + +const longGoodForYouPairs = { + grouped: false, + options: sampleData.map(({ produce }) => [slug(produce), produce]), +}; + +describe("Search select: Test Input Field", () => { + let select: AkSearchSelectViewDriver; + + beforeEach(async () => { + await render( + html` `, + document.body, + ); + // @ts-ignore + select = await AkSearchSelectViewDriver.build(await $("ak-search-select-view")); + }); + + it("should open the menu when the input is clicked", async () => { + expect(await select.open).toBe(false); + expect(await select.menuIsVisible()).toBe(false); + await select.clickInput(); + expect(await select.open).toBe(true); + // expect(await select.menuIsVisible()).toBe(true); + }); + + it("should not open the menu when the input is focused", async () => { + expect(await select.open).toBe(false); + await select.focusOnInput(); + expect(await select.open).toBe(false); + expect(await select.menuIsVisible()).toBe(false); + }); + + it("should close the menu when the input is clicked a second time", async () => { + expect(await select.open).toBe(false); + expect(await select.menuIsVisible()).toBe(false); + await select.clickInput(); + expect(await select.menuIsVisible()).toBe(true); + expect(await select.open).toBe(true); + await select.clickInput(); + expect(await select.open).toBe(false); + expect(await select.open).toBe(false); + }); + + it("should open the menu from a focused but closed input when a search is begun", async () => { + expect(await select.open).toBe(false); + await select.focusOnInput(); + expect(await select.open).toBe(false); + expect(await select.menuIsVisible()).toBe(false); + await browser.keys("A"); + expect(await select.open).toBe(true); + expect(await select.menuIsVisible()).toBe(true); + }); + + it("should update the list as the user types", async () => { + await select.focusOnInput(); + await browser.keys("Ap"); + expect(await select.menuIsVisible()).toBe(true); + const elements = Array.from(await select.listElements()); + expect(elements.length).toBe(2); + }); + + it("set the value when a match is close", async () => { + await select.focusOnInput(); + await browser.keys("Ap"); + expect(await select.menuIsVisible()).toBe(true); + const elements = Array.from(await select.listElements()); + expect(elements.length).toBe(2); + await browser.keys(Key.Tab); + expect(await (await select.input()).getValue()).toBe("Apples"); + }); + + it("should close the menu when the user clicks away", async () => { + document.body.insertAdjacentHTML( + "afterbegin", + '', + ); + const input = await browser.$("#a-separate-component"); + + await select.clickInput(); + expect(await select.open).toBe(true); + await input.click(); + expect(await select.open).toBe(false); + }); + + afterEach(async () => { + await document.body.querySelector("#a-separate-component")?.remove(); + await document.body.querySelector("ak-search-select-view")?.remove(); + // @ts-expect-error expression of type '"_$litPart$"' is added by Lit + if (document.body["_$litPart$"]) { + // @ts-expect-error expression of type '"_$litPart$"' is added by Lit + delete document.body["_$litPart$"]; + } + }); +}); diff --git a/web/src/elements/forms/SearchSelect/tests/ak-search-select.test.ts b/web/src/elements/forms/SearchSelect/tests/ak-search-select.test.ts index 6c22e88696..c0026e8ee8 100644 --- a/web/src/elements/forms/SearchSelect/tests/ak-search-select.test.ts +++ b/web/src/elements/forms/SearchSelect/tests/ak-search-select.test.ts @@ -1,100 +1,108 @@ +/* eslint-env jest */ +import { AKElement } from "@goauthentik/elements/Base"; +import { bound } from "@goauthentik/elements/decorators/bound.js"; +import { CustomListenerElement } from "@goauthentik/elements/utils/eventEmitter"; import { $, browser } from "@wdio/globals"; import { slug } from "github-slugger"; -import { Key } from "webdriverio"; import { html, render } from "lit"; +import { customElement } from "lit/decorators.js"; +import { property, query } from "lit/decorators.js"; -import "../ak-search-select-view.js"; -import { sampleData } from "../stories/sampleData.js"; +import "../ak-search-select.js"; +import { SearchSelect } from "../ak-search-select.js"; +import { type ViewSample, sampleData } from "../stories/sampleData.js"; import { AkSearchSelectViewDriver } from "./ak-search-select-view.comp.js"; -const longGoodForYouPairs = { - grouped: false, - options: sampleData.map(({ produce }) => [slug(produce), produce]), -}; +const renderElement = (fruit: ViewSample) => fruit.produce; -describe("Search select: Test Input Field", () => { +const renderDescription = (fruit: ViewSample) => html`${fruit.desc}`; + +const renderValue = (fruit: ViewSample | undefined) => slug(fruit?.produce ?? ""); + +@customElement("ak-mock-search-group") +export class MockSearch extends CustomListenerElement(AKElement) { + /** + * The current fruit + * + * @attr + */ + @property({ type: String, reflect: true }) + fruit?: string; + + @query("ak-search-select") + search!: SearchSelect; + + selectedFruit?: ViewSample; + + get value() { + return this.selectedFruit ? renderValue(this.selectedFruit) : undefined; + } + + @bound + handleSearchUpdate(ev: CustomEvent) { + ev.stopPropagation(); + this.selectedFruit = ev.detail.value; + this.dispatchEvent(new InputEvent("input", { bubbles: true, composed: true })); + } + + @bound + selected(fruit: ViewSample) { + return this.fruit === slug(fruit.produce); + } + + @bound + fetchObjects() { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const resolver = (resolve: any) => { + this.addEventListener("resolve", () => { + resolve(sampleData); + }); + }; + return new Promise(resolver); + } + + render() { + return html` + + + `; + } +} + +describe("Search select: event driven startup", () => { let select: AkSearchSelectViewDriver; + let wrapper: SearchSelect; beforeEach(async () => { - await render( - html` `, - document.body, - ); + await render(html``, document.body); // @ts-ignore - select = await AkSearchSelectViewDriver.build(await $("ak-search-select-view")); + wrapper = await $(">>>ak-search-select"); }); - it("should open the menu when the input is clicked", async () => { - expect(await select.open).toBe(false); - expect(await select.menuIsVisible()).toBe(false); - await select.clickInput(); - expect(await select.open).toBe(true); - // expect(await select.menuIsVisible()).toBe(true); - }); - - it("should not open the menu when the input is focused", async () => { - expect(await select.open).toBe(false); - await select.focusOnInput(); - expect(await select.open).toBe(false); - expect(await select.menuIsVisible()).toBe(false); - }); - - it("should close the menu when the input is clicked a second time", async () => { - expect(await select.open).toBe(false); - expect(await select.menuIsVisible()).toBe(false); - await select.clickInput(); - expect(await select.menuIsVisible()).toBe(true); - expect(await select.open).toBe(true); - await select.clickInput(); - expect(await select.open).toBe(false); - expect(await select.open).toBe(false); - }); - - it("should open the menu from a focused but closed input when a search is begun", async () => { - expect(await select.open).toBe(false); - await select.focusOnInput(); - expect(await select.open).toBe(false); - expect(await select.menuIsVisible()).toBe(false); - await browser.keys("A"); - expect(await select.open).toBe(true); - expect(await select.menuIsVisible()).toBe(true); - }); - - it("should update the list as the user types", async () => { - await select.focusOnInput(); - await browser.keys("Ap"); - expect(await select.menuIsVisible()).toBe(true); - const elements = Array.from(await select.listElements()); - expect(elements.length).toBe(2); - }); - - it("set the value when a match is close", async () => { - await select.focusOnInput(); - await browser.keys("Ap"); - expect(await select.menuIsVisible()).toBe(true); - const elements = Array.from(await select.listElements()); - expect(elements.length).toBe(2); - await browser.keys(Key.Tab); - expect(await (await select.input()).getValue()).toBe("Apples"); - }); - - it("should close the menu when the user clicks away", async () => { - document.body.insertAdjacentHTML( - "afterbegin", - '', - ); - const input = await browser.$("#a-separate-component"); - - await select.clickInput(); - expect(await select.open).toBe(true); - await input.click(); - expect(await select.open).toBe(false); + it("should shift from the loading indicator to search select view on fetch event completed", async () => { + expect(await wrapper).toBeExisting(); + expect(await $(">>>ak-search-select-loading-indicator")).toBeDisplayed(); + await browser.execute(() => { + const mock = document.querySelector("ak-mock-search-group"); + mock?.dispatchEvent(new Event("resolve")); + }); + expect(await $(">>>ak-search-select-loading-indicator")).not.toBeDisplayed(); + select = await AkSearchSelectViewDriver.build(await $(">>>ak-search-select-view")); + expect(await select).toBeExisting(); }); afterEach(async () => { - await document.body.querySelector("#a-separate-component")?.remove(); - await document.body.querySelector("ak-search-select-view")?.remove(); + await document.body.querySelector("ak-mock-search-group")?.remove(); // @ts-expect-error expression of type '"_$litPart$"' is added by Lit if (document.body["_$litPart$"]) { // @ts-expect-error expression of type '"_$litPart$"' is added by Lit