From d098b4fee44404656be9a82054566f22fb7ef405 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Tue, 21 Dec 2021 14:57:17 +0200 Subject: [PATCH] refactor & add few tests Signed-off-by: Jari Kolehmainen --- .../namespace-select-filter.test.tsx | 133 ++++++++++++++++++ .../+namespaces/namespace-select-filter.tsx | 94 ++++++++++--- .../+namespaces/namespace-select.tsx | 44 ++++-- .../+namespaces/namespace.store.injectable.ts | 34 +++++ .../components/+namespaces/namespace.store.ts | 24 +++- 5 files changed, 293 insertions(+), 36 deletions(-) create mode 100644 src/renderer/components/+namespaces/__tests__/namespace-select-filter.test.tsx create mode 100644 src/renderer/components/+namespaces/namespace.store.injectable.ts diff --git a/src/renderer/components/+namespaces/__tests__/namespace-select-filter.test.tsx b/src/renderer/components/+namespaces/__tests__/namespace-select-filter.test.tsx new file mode 100644 index 0000000000..0e2600e417 --- /dev/null +++ b/src/renderer/components/+namespaces/__tests__/namespace-select-filter.test.tsx @@ -0,0 +1,133 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import React from "react"; +import "@testing-library/jest-dom/extend-expect"; +import { NamespaceSelectFilter } from "../namespace-select-filter"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import { DiRender, renderFor } from "../../test-utils/renderFor"; +import { ThemeStore } from "../../../theme.store"; +import { UserStore } from "../../../../common/user-store"; +import namespaceStoreInjectable from "../namespace.store.injectable"; +import { NamespaceStore } from "../namespace.store"; +import { AppPaths } from "../../../../common/app-paths"; +import { Namespace } from "../../../../common/k8s-api/endpoints"; +import { StorageHelper } from "../../../utils"; +import { observable } from "mobx"; + +jest.mock("electron", () => ({ // TODO: remove mocks + app: { + getVersion: () => "99.99.99", + getName: () => "lens", + setName: jest.fn(), + setPath: jest.fn(), + getPath: () => "tmp", + }, + ipcMain: { + on: jest.fn(), + handle: jest.fn(), + }, +})); + +describe("NamespaceSelectFilter", () => { + let render: DiRender; + let namespaceStore: NamespaceStore; + + beforeAll(async () => { // TODO: remove beforeAll + await AppPaths.init(); + + UserStore.resetInstance(); + UserStore.createInstance(); + + ThemeStore.resetInstance(); + ThemeStore.createInstance(); + }); + + beforeEach(() => { + const di = getDiForUnitTesting(); + const storage = observable({ + initialized: true, + loaded: true, + data: {} as Record, + }); + const storageHelper = new StorageHelper("namespace_select", { + autoInit: true, + defaultValue: undefined, + storage: { + async getItem(key: string) { + return storage.data[key]; + }, + setItem(key: string, value: any) { + storage.data[key] = value; + }, + removeItem(key: string) { + delete storage.data[key]; + }, + }, + }); + + namespaceStore = new NamespaceStore({ + storage: storageHelper, + autoInit: false, + }); + namespaceStore.resetSelection(); + di.override(namespaceStoreInjectable, namespaceStore); + + render = renderFor(di); + }); + + it ("renders without errors using defaults", async () => { + expect(() => { + render(<>); + }).not.toThrow(); + }); + + it ("renders all namespaces by default", async () => { + const { getByTestId } = render(<>); + const select = getByTestId("namespace-select-filter"); + + expect(select.getElementsByClassName("Select__placeholder")[0].innerHTML).toEqual("All namespaces"); + }); + + it ("renders selected namespaces", async () => { + namespaceStore.items.replace([ + new Namespace({ kind: "Namespace", apiVersion: "v1", metadata: { name: "one ", uid: "one", resourceVersion: "1" }}), + new Namespace({ kind: "Namespace", apiVersion: "v1", metadata: { name: "two ", uid: "two", resourceVersion: "1" }}), + new Namespace({ kind: "Namespace", apiVersion: "v1", metadata: { name: "three ", uid: "three", resourceVersion: "1" }}), + ]); + + namespaceStore.selectNamespaces(["two", "three"]); + + const { getByTestId } = render(<>); + const select = getByTestId("namespace-select-filter"); + + expect(select.getElementsByClassName("Select__placeholder")[0].innerHTML).toEqual("Namespaces: two, three"); + }); + + // it ("renders items", async () => { + // namespaceStore.items.replace([ + // new Namespace({ kind: "Namespace", apiVersion: "v1", metadata: { name: "one ", uid: "one", resourceVersion: "1" }}), + // new Namespace({ kind: "Namespace", apiVersion: "v1", metadata: { name: "two ", uid: "two", resourceVersion: "1" }}), + // ]); + + // const { } = render(<>); + // }); +}); diff --git a/src/renderer/components/+namespaces/namespace-select-filter.tsx b/src/renderer/components/+namespaces/namespace-select-filter.tsx index 732cfc4dba..e23f3e6cd3 100644 --- a/src/renderer/components/+namespaces/namespace-select-filter.tsx +++ b/src/renderer/components/+namespaces/namespace-select-filter.tsx @@ -22,20 +22,28 @@ import "./namespace-select-filter.scss"; import React from "react"; +import { withInjectables } from "@ogre-tools/injectable-react"; import { disposeOnUnmount, observer } from "mobx-react"; -import { components, PlaceholderProps } from "react-select"; +import { components, InputActionMeta, PlaceholderProps } from "react-select"; import { action, computed, makeObservable, observable, reaction } from "mobx"; import { Icon } from "../icon"; import { NamespaceSelect } from "./namespace-select"; -import { namespaceStore } from "./namespace.store"; +import type { NamespaceStore } from "./namespace.store"; import type { SelectOption, SelectProps } from "../select"; import { isMac } from "../../../common/vars"; +import namespaceStoreInjectable from "./namespace.store.injectable"; -const Placeholder = observer((props: PlaceholderProps) => { +interface NamespaceSelectFilterPlaceholderProps { + namespaceStore: NamespaceStore +} + +const Placeholder = observer((props: PlaceholderProps & NamespaceSelectFilterPlaceholderProps) => { const getPlaceholder = (): React.ReactNode => { - if (props.isFocused) { + const { namespaceStore, isFocused } = props; + + if (isFocused || !namespaceStore) { return <>Search Namespaces ...; } @@ -53,14 +61,22 @@ const Placeholder = observer((props: PlaceholderProps) => { }; return ( - + {getPlaceholder()} ); }); +export interface NamespaceSelectFilterProps extends SelectProps { + maxItems?: number; +} + +interface NamespaceSelectFilterDependencies { + namespaceStore: NamespaceStore, +} + @observer -export class NamespaceSelectFilter extends React.Component { +export class NonInjectedNamespaceSelectFilter extends React.Component { static isMultiSelection = observable.box(false); static isMenuOpen = observable.box(false); @@ -69,36 +85,41 @@ export class NamespaceSelectFilter extends React.Component { */ private selected = observable.set(); private didToggle = false; + private inputValue: string; - constructor(props: SelectProps) { + constructor(props: NamespaceSelectFilterProps & NamespaceSelectFilterDependencies) { super(props); makeObservable(this); } - @computed get isMultiSelection() { - return NamespaceSelectFilter.isMultiSelection.get(); + get namespaceStore() { + return this.props.namespaceStore; } set isMultiSelection(val: boolean) { - NamespaceSelectFilter.isMultiSelection.set(val); + NonInjectedNamespaceSelectFilter.isMultiSelection.set(val); } @computed get isMenuOpen() { - return NamespaceSelectFilter.isMenuOpen.get(); + return NonInjectedNamespaceSelectFilter.isMenuOpen.get(); } set isMenuOpen(val: boolean) { - NamespaceSelectFilter.isMenuOpen.set(val); + NonInjectedNamespaceSelectFilter.isMenuOpen.set(val); + } + + get maxItems() { + return this.props.maxItems; } componentDidMount() { disposeOnUnmount(this, [ reaction(() => this.isMenuOpen, newVal => { if (newVal) { // rising edge of selection - if (namespaceStore.areAllSelectedImplicitly) { + if (this.namespaceStore.areAllSelectedImplicitly) { this.selected.replace([""]); } else { - this.selected.replace(namespaceStore.selectedNames); + this.selected.replace(this.namespaceStore.selectedNames); } this.didToggle = false; } @@ -108,8 +129,8 @@ export class NamespaceSelectFilter extends React.Component { formatOptionLabel({ value: namespace, label }: SelectOption) { const isSelected = namespace - ? !namespaceStore.areAllSelectedImplicitly && namespaceStore.hasContext(namespace) - : namespaceStore.areAllSelectedImplicitly; + ? !this.namespaceStore.areAllSelectedImplicitly && this.namespaceStore.hasContext(namespace) + : this.namespaceStore.areAllSelectedImplicitly; return (
@@ -124,16 +145,30 @@ export class NamespaceSelectFilter extends React.Component { onChange = ([{ value: namespace }]: SelectOption[]) => { if (namespace) { if (this.isMultiSelection) { - this.didToggle = true; - namespaceStore.toggleSingle(namespace); + if (this.inputValue === "" && (this.namespaceStore.items.length < this.maxItems)) { + this.didToggle = true; + this.namespaceStore.toggleSingle(namespace); + } else { + if (this.namespaceStore.areAllSelectedImplicitly && namespace === "") { + this.namespaceStore.selectSingle(namespace); + } else { + this.namespaceStore.toggleSingle(namespace); + } + } + } else { - namespaceStore.selectSingle(namespace); + this.namespaceStore.selectSingle(namespace); } } else { - namespaceStore.selectAll(); + this.namespaceStore.selectAll(); } }; + onInputChange = (value: string, meta: InputActionMeta) => { + if (meta.action === "input-change") this.inputValue = value; + if (meta.action === "menu-close") this.inputValue = ""; + }; + private isSelectionKey(e: React.KeyboardEvent): boolean { if (isMac) { return e.key === "Meta"; @@ -169,6 +204,7 @@ export class NamespaceSelectFilter extends React.Component { } }; + @action reset = () => { this.isMultiSelection = false; this.isMenuOpen = false; @@ -176,18 +212,22 @@ export class NamespaceSelectFilter extends React.Component { render() { return ( -
+
, + }} showAllNamespacesOption={true} closeMenuOnSelect={false} controlShouldRenderValue={false} placeholder={""} onChange={this.onChange} onBlur={this.reset} - formatOptionLabel={this.formatOptionLabel} + onInputChange={this.onInputChange} + formatOptionLabel={this.formatOptionLabel.bind(this)} className="NamespaceSelectFilter" menuClass="NamespaceSelectFilterMenu" sort={(left, right) => +this.selected.has(right.value) - +this.selected.has(left.value)} @@ -196,3 +236,11 @@ export class NamespaceSelectFilter extends React.Component { ); } } + +export const NamespaceSelectFilter = withInjectables(NonInjectedNamespaceSelectFilter, { + getProps: (di, props) => ({ + namespaceStore: di.inject(namespaceStoreInjectable), + maxItems: 500, + ...props, + }), +}); diff --git a/src/renderer/components/+namespaces/namespace-select.tsx b/src/renderer/components/+namespaces/namespace-select.tsx index 3a36ca6f81..5b86f5d1ee 100644 --- a/src/renderer/components/+namespaces/namespace-select.tsx +++ b/src/renderer/components/+namespaces/namespace-select.tsx @@ -27,33 +27,53 @@ import { observer } from "mobx-react"; import { Select, SelectOption, SelectProps } from "../select"; import { cssNames } from "../../utils"; import { Icon } from "../icon"; -import { namespaceStore } from "./namespace.store"; +import type { NamespaceStore } from "./namespace.store"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import namespaceStoreInjectable from "./namespace.store.injectable"; -interface Props extends SelectProps { +export interface NamespaceSelectProps extends SelectProps { showIcons?: boolean; sort?: (a: SelectOption, b: SelectOption) => number; showAllNamespacesOption?: boolean; // show "All namespaces" option on the top (default: false) customizeOptions?(options: SelectOption[]): SelectOption[]; + maxItems?: number; } -const defaultProps: Partial = { +interface NamespaceSelectDependencies { + namespaceStore?: NamespaceStore +} + +const defaultProps: Partial = { showIcons: true, + maxItems: 500, }; @observer -export class NamespaceSelect extends React.Component { +class NonInjectedNamespaceSelect extends React.Component { static defaultProps = defaultProps as object; - constructor(props: Props) { + constructor(props: NamespaceSelectProps & NamespaceSelectDependencies) { super(props); makeObservable(this); } + private get namespaceStore() { + return this.props.namespaceStore; + } + + private get maxItems() { + return this.props.maxItems; + } + // No subscribe here because the subscribe is in (the cluster frame root component) @computed.struct get options(): SelectOption[] { const { customizeOptions, showAllNamespacesOption, sort } = this.props; - let options: SelectOption[] = namespaceStore.items.map(ns => ({ value: ns.getName(), label: ns.getName() })); + let options: SelectOption[] = Array.from(this.namespaceStore.items.map(ns => ({ value: ns.getName(), label: ns.getName() }))); + + if (this.namespaceStore.selectedNames.size > this.maxItems) { + options = options.slice(0, this.maxItems); // need to protect UI from freezing + } if (sort) { options.sort(sort); @@ -83,11 +103,11 @@ export class NamespaceSelect extends React.Component { }; filterOption = (option: SelectOption, rawInput: string) => { - if (option.value === "" || (!namespaceStore.areAllSelectedImplicitly && namespaceStore.selectedNames.has(option.value))) { + if (option.value === "" || (this.namespaceStore !== undefined && !this.namespaceStore.areAllSelectedImplicitly && this.namespaceStore.selectedNames.has(option.value))) { return true; } - if (namespaceStore.items.length > 500 && rawInput.length < 3) { + if (this.namespaceStore.items.length > this.maxItems && rawInput.length < 3) { return false; } @@ -109,8 +129,16 @@ export class NamespaceSelect extends React.Component { options={this.options} components={components} filterOption={this.filterOption} + blurInputOnSelect={false} {...selectProps} /> ); } } + +export const NamespaceSelect = withInjectables(NonInjectedNamespaceSelect, { + getProps: (di, props) => ({ + namespaceStore: di.inject(namespaceStoreInjectable), + ...props, + }), +}); diff --git a/src/renderer/components/+namespaces/namespace.store.injectable.ts b/src/renderer/components/+namespaces/namespace.store.injectable.ts new file mode 100644 index 0000000000..ee28ff53d8 --- /dev/null +++ b/src/renderer/components/+namespaces/namespace.store.injectable.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { Injectable, lifecycleEnum } from "@ogre-tools/injectable"; +import { namespaceStore, NamespaceStore } from "./namespace.store"; + +const namespaceStoreInjectable: Injectable< + NamespaceStore +> = { + getDependencies: di => ({}), + + instantiate: () => namespaceStore, + lifecycle: lifecycleEnum.singleton, +}; + +export default namespaceStoreInjectable; diff --git a/src/renderer/components/+namespaces/namespace.store.ts b/src/renderer/components/+namespaces/namespace.store.ts index 34e4348488..b46c380f5b 100644 --- a/src/renderer/components/+namespaces/namespace.store.ts +++ b/src/renderer/components/+namespaces/namespace.store.ts @@ -20,21 +20,33 @@ */ import { action, comparer, computed, IReactionDisposer, makeObservable, reaction } from "mobx"; -import { autoBind, createStorage, noop, ToggleSet } from "../../utils"; +import { autoBind, createStorage, noop, StorageHelper, ToggleSet } from "../../utils"; import { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../../common/k8s-api/kube-object.store"; import { Namespace, namespacesApi } from "../../../common/k8s-api/endpoints/namespaces.api"; import { apiManager } from "../../../common/k8s-api/api-manager"; + + +export type NamespaceStoreConfig = { + storage?: StorageHelper; + autoInit?: boolean; +}; + export class NamespaceStore extends KubeObjectStore { api = namespacesApi; - private storage = createStorage("selected_namespaces", undefined); - constructor() { + constructor(private config: NamespaceStoreConfig) { super(); makeObservable(this); autoBind(this); - this.init(); + if (config.autoInit !== false) { + this.init(); + } + } + + private get storage() { + return this.config.storage; } private async init() { @@ -247,7 +259,9 @@ export class NamespaceStore extends KubeObjectStore { } } -export const namespaceStore = new NamespaceStore(); +export const namespaceStore = new NamespaceStore({ + storage: createStorage("selected_namespaces", undefined), +}); apiManager.registerStore(namespaceStore); export function getDummyNamespace(name: string) {