From f935454875a1ca98c91c3dd9b4a24b0e84aa0a00 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Thu, 7 Jul 2022 08:59:33 -0700 Subject: [PATCH] Fix check on NamespaceSelectFilter not updating (#5691) --- src/common/utils/index.ts | 9 +- src/common/utils/noop.ts | 10 + src/common/utils/observable-crate/impl.ts | 53 + .../observable-crate/observable-crate.test.ts | 116 ++ .../namespace-select-filter.test.tsx.snap | 1698 +++++++++++++++++ .../is-selection-key.injectable.ts | 22 + ...amespace-select-filter-model.injectable.ts | 6 +- .../namespace-select-filter-model.tsx | 300 +-- .../+namespaces/namespace-select-filter.scss | 13 +- .../namespace-select-filter.test.tsx | 252 +++ .../+namespaces/namespace-select-filter.tsx | 11 +- src/renderer/components/select/select.tsx | 1 + .../__snapshots__/cluster-frame.test.tsx.snap | 5 +- 13 files changed, 2341 insertions(+), 155 deletions(-) create mode 100644 src/common/utils/noop.ts create mode 100644 src/common/utils/observable-crate/impl.ts create mode 100644 src/common/utils/observable-crate/observable-crate.test.ts create mode 100644 src/renderer/components/+namespaces/__snapshots__/namespace-select-filter.test.tsx.snap create mode 100644 src/renderer/components/+namespaces/namespace-select-filter-model/is-selection-key.injectable.ts create mode 100644 src/renderer/components/+namespaces/namespace-select-filter.test.tsx diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index e36ccd317f..f0aa66294b 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -3,13 +3,6 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -/** - * A function that does nothing - */ -export function noop(...args: T): void { - return void args; -} - export * from "./abort-controller"; export * from "./app-version"; export * from "./autobind"; @@ -27,6 +20,8 @@ export * from "./formatDuration"; export * from "./getRandId"; export * from "./hash-set"; export * from "./n-fircate"; +export * from "./noop"; +export * from "./observable-crate/impl"; export * from "./openBrowser"; export * from "./paths"; export * from "./promise-exec"; diff --git a/src/common/utils/noop.ts b/src/common/utils/noop.ts new file mode 100644 index 0000000000..a9171f2618 --- /dev/null +++ b/src/common/utils/noop.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +/** + * A function that does nothing + */ +export function noop(...args: T): void { + return void args; +} diff --git a/src/common/utils/observable-crate/impl.ts b/src/common/utils/observable-crate/impl.ts new file mode 100644 index 0000000000..e9874c9b7d --- /dev/null +++ b/src/common/utils/observable-crate/impl.ts @@ -0,0 +1,53 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import { observable, runInAction } from "mobx"; +import { getOrInsertMap } from "../collection-functions"; +import { noop } from "../noop"; + +export interface ObservableCrate { + get(): T; + set(value: T): void; +} + +export interface ObservableCrateFactory { + (initialValue: T, transitionHandlers?: ObservableCrateTransitionHandlers): ObservableCrate; +} + +export interface ObservableCrateTransitionHandler { + from: T; + to: T; + onTransition: () => void; +} +export type ObservableCrateTransitionHandlers = ObservableCrateTransitionHandler[]; + +function convertToHandlersMap(handlers: ObservableCrateTransitionHandlers): Map void>> { + const res: ReturnType> = new Map(); + + for (const { from, to, onTransition } of handlers) { + getOrInsertMap(res, from).set(to, onTransition); + } + + return res; +} + +export const observableCrate = ((initialValue, transitionHandlers = []) => { + const crate = observable.box(initialValue); + const handlers = convertToHandlersMap(transitionHandlers); + + return { + get() { + return crate.get(); + }, + set(value) { + const onTransition = handlers.get(crate.get())?.get(value) ?? noop; + + runInAction(() => { + crate.set(value); + onTransition(); + }); + }, + }; +}) as ObservableCrateFactory; diff --git a/src/common/utils/observable-crate/observable-crate.test.ts b/src/common/utils/observable-crate/observable-crate.test.ts new file mode 100644 index 0000000000..03ee2e43f8 --- /dev/null +++ b/src/common/utils/observable-crate/observable-crate.test.ts @@ -0,0 +1,116 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { ObservableCrate } from "./impl"; +import { observableCrate } from "./impl"; + +describe("observable-crate", () => { + it("can be constructed with initial value", () => { + expect(() => observableCrate(0)).not.toThrow(); + }); + + it("has a definite type if the initial value is provided", () => { + expect (() => { + const res: ObservableCrate = observableCrate(0); + + void res; + }).not.toThrow(); + }); + + it("accepts an array of transitionHandlers", () => { + expect(() => observableCrate(0, [])).not.toThrow(); + }); + + describe("with a crate over an enum, and some transition handlers", () => { + enum Test { + Start, + T1, + End, + } + + let crate: ObservableCrate; + let correctHandler: jest.MockedFunction<() => void>; + let incorrectHandler: jest.MockedFunction<() => void>; + + beforeEach(() => { + correctHandler = jest.fn(); + incorrectHandler = jest.fn(); + crate = observableCrate(Test.Start, [ + { + from: Test.Start, + to: Test.Start, + onTransition: incorrectHandler, + }, + { + from: Test.Start, + to: Test.T1, + onTransition: correctHandler, + }, + { + from: Test.Start, + to: Test.End, + onTransition: incorrectHandler, + }, + { + from: Test.T1, + to: Test.Start, + onTransition: incorrectHandler, + }, + { + from: Test.T1, + to: Test.T1, + onTransition: incorrectHandler, + }, + { + from: Test.T1, + to: Test.End, + onTransition: incorrectHandler, + }, + { + from: Test.End, + to: Test.Start, + onTransition: incorrectHandler, + }, + { + from: Test.End, + to: Test.T1, + onTransition: incorrectHandler, + }, + { + from: Test.End, + to: Test.End, + onTransition: incorrectHandler, + }, + ]); + }); + + it("initial value is available", () => { + expect(crate.get()).toBe(Test.Start); + }); + + it("does not call any transition handler", () => { + expect(correctHandler).not.toBeCalled(); + expect(incorrectHandler).not.toBeCalled(); + }); + + describe("when setting a new value", () => { + beforeEach(() => { + crate.set(Test.T1); + }); + + it("calls the associated transition handler", () => { + expect(correctHandler).toBeCalled(); + }); + + it("does not call any other transition handler", () => { + expect(incorrectHandler).not.toBeCalled(); + }); + + it("new value is available", () => { + expect(crate.get()).toBe(Test.T1); + }); + }); + }); +}); diff --git a/src/renderer/components/+namespaces/__snapshots__/namespace-select-filter.test.tsx.snap b/src/renderer/components/+namespaces/__snapshots__/namespace-select-filter.test.tsx.snap new file mode 100644 index 0000000000..5d147fea18 --- /dev/null +++ b/src/renderer/components/+namespaces/__snapshots__/namespace-select-filter.test.tsx.snap @@ -0,0 +1,1698 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders 1`] = ` + +
+
+
+ + +
+
+
+ All namespaces +
+
+ +
+
+
+ + +
+
+
+
+
+ +`; + +exports[` when clicked renders 1`] = ` + +
+
+
+ + +
+
+
+ All namespaces +
+
+ +
+
+
+ + +
+
+
+
+
+
+
+
+
+ All Namespaces +
+
+
+ + + layers + + + + test-1 + + + + check + + +
+
+
+
+ + + layers + + + + test-2 + + + + check + + +
+
+
+
+ + + layers + + + + test-3 + + + + check + + +
+
+
+
+ + + layers + + + + test-4 + + + + check + + +
+
+
+
+ + + layers + + + + test-5 + + + + check + + +
+
+
+
+ + + layers + + + + test-6 + + + + check + + +
+
+
+
+ + + layers + + + + test-7 + + + + check + + +
+
+
+
+ + + layers + + + + test-8 + + + + check + + +
+
+
+
+ + + layers + + + + test-9 + + + + check + + +
+
+
+
+ + + layers + + + + test-10 + + + + check + + +
+
+
+
+ + + layers + + + + test-11 + + + + check + + +
+
+
+
+ + + layers + + + + test-12 + + + + check + + +
+
+
+
+ + + layers + + + + test-13 + + + + check + + +
+
+
+
+
+ +`; + +exports[` when clicked when 'test-2' is clicked renders 1`] = ` + +
+
+
+ + +
+
+
+ Namespace: test-2 +
+
+ +
+
+
+ + +
+
+
+
+
+ +`; + +exports[` when clicked when 'test-2' is clicked when clicked again renders 1`] = ` + +
+
+
+ + +
+
+
+ Namespace: test-2 +
+
+ +
+
+
+ + +
+
+
+
+
+
+
+
+
+ All Namespaces +
+
+
+ + + layers + + + + test-2 + + + + check + + +
+
+
+
+ + + layers + + + + test-1 + +
+
+
+
+ + + layers + + + + test-3 + +
+
+
+
+ + + layers + + + + test-4 + +
+
+
+
+ + + layers + + + + test-5 + +
+
+
+
+ + + layers + + + + test-6 + +
+
+
+
+ + + layers + + + + test-7 + +
+
+
+
+ + + layers + + + + test-8 + +
+
+
+
+ + + layers + + + + test-9 + +
+
+
+
+ + + layers + + + + test-10 + +
+
+
+
+ + + layers + + + + test-11 + +
+
+
+
+ + + layers + + + + test-12 + +
+
+
+
+ + + layers + + + + test-13 + +
+
+
+
+
+ +`; + +exports[` when clicked when 'test-2' is clicked when clicked again when 'test-1' is clicked renders 1`] = ` + +
+
+
+ + +
+
+
+ Namespace: test-1 +
+
+ +
+
+
+ + +
+
+
+
+
+ +`; + +exports[` when clicked when 'test-2' is clicked when clicked again when 'test-1' is clicked when clicked again, then holding down multi select key when 'test-3' is clicked renders 1`] = ` + +
+
+
+ + +
+
+
+ Namespaces: test-1, test-3 +
+
+ +
+
+
+ + +
+
+
+
+
+
+
+
+
+ All Namespaces +
+
+
+ + + layers + + + + test-1 + + + + check + + +
+
+
+
+ + + layers + + + + test-2 + +
+
+
+
+ + + layers + + + + test-3 + + + + check + + +
+
+
+
+ + + layers + + + + test-4 + +
+
+
+
+ + + layers + + + + test-5 + +
+
+
+
+ + + layers + + + + test-6 + +
+
+
+
+ + + layers + + + + test-7 + +
+
+
+
+ + + layers + + + + test-8 + +
+
+
+
+ + + layers + + + + test-9 + +
+
+
+
+ + + layers + + + + test-10 + +
+
+
+
+ + + layers + + + + test-11 + +
+
+
+
+ + + layers + + + + test-12 + +
+
+
+
+ + + layers + + + + test-13 + +
+
+
+
+
+ +`; diff --git a/src/renderer/components/+namespaces/namespace-select-filter-model/is-selection-key.injectable.ts b/src/renderer/components/+namespaces/namespace-select-filter-model/is-selection-key.injectable.ts new file mode 100644 index 0000000000..3562b8a166 --- /dev/null +++ b/src/renderer/components/+namespaces/namespace-select-filter-model/is-selection-key.injectable.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import type React from "react"; +import isMacInjectable from "../../../../common/vars/is-mac.injectable"; + +export type IsMultiSelectionKey = (event: React.KeyboardEvent) => boolean; + +const isMultiSelectionKeyInjectable = getInjectable({ + id: "is-multi-selection-key", + instantiate: (di): IsMultiSelectionKey => { + const isMac = di.inject(isMacInjectable); + + return isMac + ? ({ key }) => key === "Meta" + : ({ key }) => key === "Control"; + }, +}); + +export default isMultiSelectionKeyInjectable; diff --git a/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.injectable.ts b/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.injectable.ts index 087ff4a29b..5578767cf1 100644 --- a/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.injectable.ts +++ b/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.injectable.ts @@ -2,15 +2,17 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import { NamespaceSelectFilterModel } from "./namespace-select-filter-model"; +import { namespaceSelectFilterModelFor } from "./namespace-select-filter-model"; import { getInjectable } from "@ogre-tools/injectable"; import namespaceStoreInjectable from "../store.injectable"; +import isMultiSelectionKeyInjectable from "./is-selection-key.injectable"; const namespaceSelectFilterModelInjectable = getInjectable({ id: "namespace-select-filter-model", - instantiate: (di) => new NamespaceSelectFilterModel({ + instantiate: (di) => namespaceSelectFilterModelFor({ namespaceStore: di.inject(namespaceStoreInjectable), + isMultiSelectionKey: di.inject(isMultiSelectionKeyInjectable), }), }); diff --git a/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.tsx b/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.tsx index 9731abf8b8..a3d850cdcd 100644 --- a/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.tsx +++ b/src/renderer/components/+namespaces/namespace-select-filter-model/namespace-select-filter-model.tsx @@ -3,16 +3,18 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ import React from "react"; -import { observable, action, untracked, computed, makeObservable } from "mobx"; +import type { IComputedValue } from "mobx"; +import { observable, action, computed, comparer } from "mobx"; import type { NamespaceStore } from "../store"; -import { isMac } from "../../../../common/vars"; -import type { ActionMeta } from "react-select"; +import type { ActionMeta, MultiValue } from "react-select"; import { Icon } from "../../icon"; import type { SelectOption } from "../../select"; -import { autoBind } from "../../../utils"; +import { observableCrate } from "../../../utils"; +import type { IsMultiSelectionKey } from "./is-selection-key.injectable"; interface Dependencies { - readonly namespaceStore: NamespaceStore; + namespaceStore: NamespaceStore; + isMultiSelectionKey: IsMultiSelectionKey; } export const selectAllNamespaces = Symbol("all-namespaces-selected"); @@ -20,140 +22,160 @@ export const selectAllNamespaces = Symbol("all-namespaces-selected"); export type SelectAllNamespaces = typeof selectAllNamespaces; export type NamespaceSelectFilterOption = SelectOption; -export class NamespaceSelectFilterModel { - constructor(private readonly dependencies: Dependencies) { - makeObservable(this); - autoBind(this); - } - - readonly options = computed((): readonly NamespaceSelectFilterOption[] => { - const baseOptions = this.dependencies.namespaceStore.items.map(ns => ns.getName()); - - baseOptions.sort(( - (left, right) => - +this.selectedNames.has(right) - - +this.selectedNames.has(left) - )); - - return [ - { - value: selectAllNamespaces, - label: "All Namespaces", - isSelected: false, - }, - ...baseOptions.map(namespace => ({ - value: namespace, - label: namespace, - isSelected: this.selectedNames.has(namespace), - })), - ]; - }); - - formatOptionLabel({ value, isSelected }: NamespaceSelectFilterOption) { - if (value === selectAllNamespaces) { - return "All Namespaces"; - } - - return ( -
- - {value} - {isSelected && ( - - )} -
- ); - } - - readonly menuIsOpen = observable.box(false); - - @action - closeMenu() { - this.menuIsOpen.set(false); - } - - @action - openMenu(){ - this.menuIsOpen.set(true); - } - - get selectedNames() { - return untracked(() => this.dependencies.namespaceStore.selectedNames); - } - - isSelected(namespace: string | string[]) { - return this.dependencies.namespaceStore.hasContext(namespace); - } - - selectSingle(namespace: string) { - this.dependencies.namespaceStore.selectSingle(namespace); - } - - selectAll() { - this.dependencies.namespaceStore.selectAll(); - } - - onChange(namespace: unknown, action: ActionMeta) { - switch (action.action) { - case "clear": - this.dependencies.namespaceStore.selectAll(); - break; - case "deselect-option": - if (typeof action.option === "string") { - this.dependencies.namespaceStore.toggleSingle(action.option); - } - break; - case "select-option": - if (action.option?.value === selectAllNamespaces) { - this.dependencies.namespaceStore.selectAll(); - } else if (action.option) { - if (this.isMultiSelection) { - this.dependencies.namespaceStore.toggleSingle(action.option.value); - } else { - this.dependencies.namespaceStore.selectSingle(action.option.value); - } - } - break; - } - } - - onClick() { - if (!this.menuIsOpen.get()) { - this.openMenu(); - } else if (!this.isMultiSelection) { - this.closeMenu(); - } - } - - private isMultiSelection = false; - - onKeyDown(event: React.KeyboardEvent) { - if (isSelectionKey(event)) { - this.isMultiSelection = true; - } - } - - onKeyUp(event: React.KeyboardEvent) { - if (isSelectionKey(event)) { - this.isMultiSelection = false; - } - } - - @action - reset() { - this.isMultiSelection = false; - this.closeMenu(); - } +export interface NamespaceSelectFilterModel { + readonly options: IComputedValue; + readonly menu: { + open: () => void; + close: () => void; + readonly isOpen: IComputedValue; + }; + onChange: (newValue: MultiValue, actionMeta: ActionMeta) => void; + onClick: () => void; + onKeyDown: React.KeyboardEventHandler; + onKeyUp: React.KeyboardEventHandler; + reset: () => void; + isOptionSelected: (option: NamespaceSelectFilterOption) => boolean; + formatOptionLabel: (option: NamespaceSelectFilterOption) => JSX.Element; } -const isSelectionKey = (event: React.KeyboardEvent): boolean => { - if (isMac) { - return event.key === "Meta"; - } +enum SelectMenuState { + Close = "close", + Open = "open", +} - return event.key === "Control"; // windows or linux -}; +export function namespaceSelectFilterModelFor(dependencies: Dependencies): NamespaceSelectFilterModel { + const { isMultiSelectionKey, namespaceStore } = dependencies; + + let didToggle = false; + let isMultiSelection = false; + const menuState = observableCrate(SelectMenuState.Close, [{ + from: SelectMenuState.Close, + to: SelectMenuState.Open, + onTransition: () => { + optionsSortingSelected.replace(selectedNames.get()); + didToggle = false; + }, + }]); + const selectedNames = computed(() => new Set(namespaceStore.contextNamespaces), { + equals: comparer.structural, + }); + const optionsSortingSelected = observable.set(selectedNames.get()); + const sortNamespacesByIfTheyHaveBeenSelected = (left: string, right: string) => { + const isLeftSelected = optionsSortingSelected.has(left); + const isRightSelected = optionsSortingSelected.has(right); + + if (isLeftSelected === isRightSelected) { + return 0; + } + + return isRightSelected + ? 1 + : -1; + }; + const options = computed((): readonly NamespaceSelectFilterOption[] => [ + { + value: selectAllNamespaces, + label: "All Namespaces", + id: "all-namespaces", + }, + ...namespaceStore + .items + .map(ns => ns.getName()) + .sort(sortNamespacesByIfTheyHaveBeenSelected) + .map(namespace => ({ + value: namespace, + label: namespace, + id: namespace, + })), + ]); + const menuIsOpen = computed(() => menuState.get() === SelectMenuState.Open); + const isOptionSelected: NamespaceSelectFilterModel["isOptionSelected"] = (option) => { + if (option.value === selectAllNamespaces) { + return false; + } + + return selectedNames.get().has(option.value); + }; + + const model: NamespaceSelectFilterModel = { + options, + menu: { + close: action(() => { + menuState.set(SelectMenuState.Close); + }), + open: action(() => { + menuState.set(SelectMenuState.Open); + }), + isOpen: menuIsOpen, + }, + onChange: (_, action) => { + switch (action.action) { + case "clear": + namespaceStore.selectAll(); + break; + case "deselect-option": + case "select-option": + if (action.option) { + didToggle = true; + + if (action.option.value === selectAllNamespaces) { + namespaceStore.selectAll(); + } else if (isMultiSelection) { + namespaceStore.toggleSingle(action.option.value); + } else { + namespaceStore.selectSingle(action.option.value); + } + } + break; + } + }, + onClick: () => { + if (!menuIsOpen.get()) { + model.menu.open(); + } else if (!isMultiSelection) { + model.menu.close(); + } + }, + onKeyDown: (event) => { + if (isMultiSelectionKey(event)) { + isMultiSelection = true; + } + }, + onKeyUp: (event) => { + if (isMultiSelectionKey(event)) { + isMultiSelection = false; + + if (didToggle) { + model.menu.close(); + } + } + }, + reset: action(() => { + isMultiSelection = false; + model.menu.close(); + }), + isOptionSelected, + formatOptionLabel: (option) => { + if (option.value === selectAllNamespaces) { + return <>All Namespaces; + } + + return ( +
+ + {option.value} + {isOptionSelected(option) && ( + + )} +
+ ); + }, + }; + + return model; +} diff --git a/src/renderer/components/+namespaces/namespace-select-filter.scss b/src/renderer/components/+namespaces/namespace-select-filter.scss index 5230fd7a84..869d647557 100644 --- a/src/renderer/components/+namespaces/namespace-select-filter.scss +++ b/src/renderer/components/+namespaces/namespace-select-filter.scss @@ -4,14 +4,17 @@ } } +.NamespaceSelectFilterParent { + max-width: 300px; +} + .NamespaceSelectFilter { --gradientColor: var(--select-menu-bgc); .Select { &__placeholder { - width: 100%; white-space: nowrap; - overflow: scroll!important; + overflow: scroll hidden!important; text-overflow: unset!important; margin-left: -8px; padding-left: 8px; @@ -61,10 +64,14 @@ word-break: break-all; padding: 4px 8px; border-radius: 3px; + + &--is-selected:not(&--is-focused) { + background: transparent; + } } } .Icon { margin-right: $margin * 0.5; } -} \ No newline at end of file +} diff --git a/src/renderer/components/+namespaces/namespace-select-filter.test.tsx b/src/renderer/components/+namespaces/namespace-select-filter.test.tsx new file mode 100644 index 0000000000..a81e32e263 --- /dev/null +++ b/src/renderer/components/+namespaces/namespace-select-filter.test.tsx @@ -0,0 +1,252 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import type { DiContainer } from "@ogre-tools/injectable"; +import type { RenderResult } from "@testing-library/react"; +import { fireEvent } from "@testing-library/react"; +import React from "react"; +import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable"; +import { Namespace } from "../../../common/k8s-api/endpoints"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable"; +import { renderFor } from "../test-utils/renderFor"; +import { NamespaceSelectFilter } from "./namespace-select-filter"; +import type { NamespaceStore } from "./store"; +import namespaceStoreInjectable from "./store.injectable"; + +function createNamespace(name: string): Namespace { + return new Namespace({ + apiVersion: "v1", + kind: "Namespace", + metadata: { + name, + resourceVersion: "1", + selfLink: `/api/v1/namespaces/${name}`, + uid: `${name}-1`, + }, + }); +} + +describe("", () => { + let di: DiContainer; + let namespaceStore: NamespaceStore; + let result: RenderResult; + + beforeEach(() => { + di = getDiForUnitTesting({ doGeneralOverrides: true }); + di.override(directoryForUserDataInjectable, () => "/some-directory"); + di.override(storesAndApisCanBeCreatedInjectable, () => true); + namespaceStore = di.inject(namespaceStoreInjectable); + + const render = renderFor(di); + + namespaceStore.items.replace([ + createNamespace("test-1"), + createNamespace("test-2"), + createNamespace("test-3"), + createNamespace("test-4"), + createNamespace("test-5"), + createNamespace("test-6"), + createNamespace("test-7"), + createNamespace("test-8"), + createNamespace("test-9"), + createNamespace("test-10"), + createNamespace("test-11"), + createNamespace("test-12"), + createNamespace("test-13"), + ]); + + result = render(( + + )); + }); + + it("renders", () => { + expect(result.baseElement).toMatchSnapshot(); + }); + + describe("when clicked", () => { + beforeEach(() => { + result.getByTestId("namespace-select-filter").click(); + }); + + it("renders", () => { + expect(result.baseElement).toMatchSnapshot(); + }); + + it("opens menu", () => { + expect(result.baseElement.querySelector("#react-select-namespace-select-filter-listbox")).not.toBeNull(); + }); + + describe("when 'test-2' is clicked", () => { + beforeEach(() => { + result.getByText("test-2").click(); + }); + + it("renders", () => { + expect(result.baseElement).toMatchSnapshot(); + }); + + it("has only 'test-2' is selected in the store", () => { + expect(namespaceStore.contextNamespaces).toEqual(["test-2"]); + }); + + it("closes menu", () => { + expect(result.baseElement.querySelector("#react-select-namespace-select-filter-listbox")).toBeNull(); + }); + + describe("when clicked again", () => { + beforeEach(() => { + result.getByTestId("namespace-select-filter").click(); + }); + + it("renders", () => { + expect(result.baseElement).toMatchSnapshot(); + }); + + it("shows 'test-2' as selected", () => { + expect(result.queryByTestId("namespace-select-filter-option-test-2-selected")).not.toBeNull(); + }); + + it("does not show 'test-1' as selected", () => { + expect(result.queryByTestId("namespace-select-filter-option-test-1-selected")).toBeNull(); + }); + + describe("when 'test-1' is clicked", () => { + beforeEach(() => { + result.getByText("test-1").click(); + }); + + it("renders", () => { + expect(result.baseElement).toMatchSnapshot(); + }); + + it("has only 'test-1' is selected in the store", () => { + expect(namespaceStore.contextNamespaces).toEqual(["test-1"]); + }); + + it("closes menu", () => { + expect(result.baseElement.querySelector("#react-select-namespace-select-filter-listbox")).toBeNull(); + }); + + describe("when clicked again, then holding down multi select key", () => { + beforeEach(() => { + const filter = result.getByTestId("namespace-select-filter"); + + filter.click(); + fireEvent.keyDown(filter, { key: "Meta" }); + }); + + describe("when 'test-3' is clicked", () => { + beforeEach(() => { + result.getByText("test-3").click(); + }); + + it("renders", () => { + expect(result.baseElement).toMatchSnapshot(); + }); + + it("has both 'test-1' and 'test-3' as selected in the store", () => { + expect(new Set(namespaceStore.contextNamespaces)).toEqual(new Set(["test-1", "test-3"])); + }); + + it("keeps menu open", () => { + expect(result.baseElement.querySelector("#react-select-namespace-select-filter-listbox")).not.toBeNull(); + }); + + it("does not show 'kube-system' as selected", () => { + expect(result.queryByTestId("namespace-select-filter-option-kube-system-selected")).toBeNull(); + }); + + describe("when 'test-13' is clicked", () => { + beforeEach(() => { + result.getByText("test-13").click(); + }); + + it("has all of 'test-1', 'test-3', and 'test-13' selected in the store", () => { + expect(new Set(namespaceStore.contextNamespaces)).toEqual(new Set(["test-1", "test-3", "test-13"])); + }); + + it("'test-13' is not sorted to the top of the list", () => { + const topLevelElement = result.getByText("test-13").parentElement?.parentElement as HTMLElement; + + expect(topLevelElement.nextSibling).toBe(null); + }); + }); + + describe("when releasing multi select key", () => { + beforeEach(() => { + const filter = result.getByTestId("namespace-select-filter"); + + fireEvent.keyUp(filter, { key: "Meta" }); + }); + + it("closes menu", () => { + expect(result.baseElement.querySelector("#react-select-namespace-select-filter-listbox")).toBeNull(); + }); + }); + }); + + describe("when releasing multi select key", () => { + beforeEach(() => { + const filter = result.getByTestId("namespace-select-filter"); + + fireEvent.keyUp(filter, { key: "Meta" }); + }); + + it("keeps menu open", () => { + expect(result.baseElement.querySelector("#react-select-namespace-select-filter-listbox")).not.toBeNull(); + }); + }); + }); + }); + }); + }); + + describe("when multi-selection key is pressed", () => { + beforeEach(() => { + const filter = result.getByTestId("namespace-select-filter"); + + fireEvent.keyDown(filter, { key: "Meta" }); + }); + + it("should show placeholder text as 'All namespaces'", () => { + expect(result.baseElement.querySelector("#react-select-namespace-select-filter-placeholder")).toHaveTextContent("All namespaces"); + }); + + describe("when 'test-2' is clicked", () => { + beforeEach(() => { + result.getByText("test-2").click(); + }); + + it("should not show placeholder text as 'All namespaces'", () => { + expect(result.baseElement.querySelector("#react-select-namespace-select-filter-placeholder")).not.toHaveTextContent("All namespaces"); + }); + + describe("when 'test-2' is clicked", () => { + beforeEach(() => { + result.getByText("test-2").click(); + }); + + it("should not show placeholder as 'All namespaces'", () => { + expect(result.baseElement.querySelector("#react-select-namespace-select-filter-placeholder")).not.toHaveTextContent("All namespaces"); + }); + + describe("when multi-selection key is raised", () => { + beforeEach(() => { + const filter = result.getByTestId("namespace-select-filter"); + + fireEvent.keyUp(filter, { key: "Meta" }); + }); + + it("should show placeholder text as 'All namespaces'", () => { + expect(result.baseElement.querySelector("#react-select-namespace-select-filter-placeholder")).not.toHaveTextContent("All namespaces"); + }); + }); + }); + }); + }); + }); +}); diff --git a/src/renderer/components/+namespaces/namespace-select-filter.tsx b/src/renderer/components/+namespaces/namespace-select-filter.tsx index 67203f1100..de1c2a8cd6 100644 --- a/src/renderer/components/+namespaces/namespace-select-filter.tsx +++ b/src/renderer/components/+namespaces/namespace-select-filter.tsx @@ -29,12 +29,14 @@ const NonInjectedNamespaceSelectFilter = observer(({ model, id }: Dependencies & onKeyUp={model.onKeyUp} onKeyDown={model.onKeyDown} onClick={model.onClick} + className="NamespaceSelectFilterParent" + data-testid="namespace-select-filter" > id={id} isMulti={true} isClearable={false} - menuIsOpen={model.menuIsOpen.get()} + menuIsOpen={model.menu.isOpen.get()} components={{ Placeholder }} closeMenuOnSelect={false} controlShouldRenderValue={false} @@ -43,7 +45,10 @@ const NonInjectedNamespaceSelectFilter = observer(({ model, id }: Dependencies & formatOptionLabel={model.formatOptionLabel} options={model.options.get()} className="NamespaceSelect NamespaceSelectFilter" - menuClass="NamespaceSelectFilterMenu" /> + menuClass="NamespaceSelectFilterMenu" + isOptionSelected={model.isOptionSelected} + hideSelectedOptions={false} + /> )); @@ -54,7 +59,7 @@ export const NamespaceSelectFilter = withInjectables {} +export interface CustomPlaceholderProps extends PlaceholderProps {} interface PlaceholderDependencies { namespaceStore: NamespaceStore; diff --git a/src/renderer/components/select/select.tsx b/src/renderer/components/select/select.tsx index d38ff57f3a..61b6de1284 100644 --- a/src/renderer/components/select/select.tsx +++ b/src/renderer/components/select/select.tsx @@ -25,6 +25,7 @@ export interface SelectOption { label: React.ReactNode; isDisabled?: boolean; isSelected?: boolean; + id?: string; } /** diff --git a/src/renderer/frames/cluster-frame/__snapshots__/cluster-frame.test.tsx.snap b/src/renderer/frames/cluster-frame/__snapshots__/cluster-frame.test.tsx.snap index 6b59eeb03e..3dc152804a 100644 --- a/src/renderer/frames/cluster-frame/__snapshots__/cluster-frame.test.tsx.snap +++ b/src/renderer/frames/cluster-frame/__snapshots__/cluster-frame.test.tsx.snap @@ -1278,7 +1278,10 @@ exports[` given cluster without list nodes, but with namespaces > Overview -
+