1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

refactor & add few tests

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
Jari Kolehmainen 2021-12-21 14:57:17 +02:00
parent 87b4049f62
commit d098b4fee4
5 changed files with 293 additions and 36 deletions

View File

@ -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<string, any>,
});
const storageHelper = new StorageHelper<string[]>("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(<><NamespaceSelectFilter /></>);
}).not.toThrow();
});
it ("renders all namespaces by default", async () => {
const { getByTestId } = render(<><NamespaceSelectFilter /></>);
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(<><NamespaceSelectFilter /></>);
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(<><NamespaceSelectFilter /></>);
// });
});

View File

@ -22,20 +22,28 @@
import "./namespace-select-filter.scss"; import "./namespace-select-filter.scss";
import React from "react"; import React from "react";
import { withInjectables } from "@ogre-tools/injectable-react";
import { disposeOnUnmount, observer } from "mobx-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 { action, computed, makeObservable, observable, reaction } from "mobx";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { NamespaceSelect } from "./namespace-select"; import { NamespaceSelect } from "./namespace-select";
import { namespaceStore } from "./namespace.store"; import type { NamespaceStore } from "./namespace.store";
import type { SelectOption, SelectProps } from "../select"; import type { SelectOption, SelectProps } from "../select";
import { isMac } from "../../../common/vars"; import { isMac } from "../../../common/vars";
import namespaceStoreInjectable from "./namespace.store.injectable";
const Placeholder = observer((props: PlaceholderProps<any, boolean>) => { interface NamespaceSelectFilterPlaceholderProps {
namespaceStore: NamespaceStore
}
const Placeholder = observer((props: PlaceholderProps<any, boolean> & NamespaceSelectFilterPlaceholderProps) => {
const getPlaceholder = (): React.ReactNode => { const getPlaceholder = (): React.ReactNode => {
if (props.isFocused) { const { namespaceStore, isFocused } = props;
if (isFocused || !namespaceStore) {
return <>Search Namespaces ...</>; return <>Search Namespaces ...</>;
} }
@ -53,14 +61,22 @@ const Placeholder = observer((props: PlaceholderProps<any, boolean>) => {
}; };
return ( return (
<components.Placeholder {...props}> <components.Placeholder {...props} data-testid="namespace-select-filter-placeholder">
{getPlaceholder()} {getPlaceholder()}
</components.Placeholder> </components.Placeholder>
); );
}); });
export interface NamespaceSelectFilterProps extends SelectProps {
maxItems?: number;
}
interface NamespaceSelectFilterDependencies {
namespaceStore: NamespaceStore,
}
@observer @observer
export class NamespaceSelectFilter extends React.Component<SelectProps> { export class NonInjectedNamespaceSelectFilter extends React.Component<NamespaceSelectFilterProps & NamespaceSelectFilterDependencies> {
static isMultiSelection = observable.box(false); static isMultiSelection = observable.box(false);
static isMenuOpen = observable.box(false); static isMenuOpen = observable.box(false);
@ -69,36 +85,41 @@ export class NamespaceSelectFilter extends React.Component<SelectProps> {
*/ */
private selected = observable.set<string>(); private selected = observable.set<string>();
private didToggle = false; private didToggle = false;
private inputValue: string;
constructor(props: SelectProps) { constructor(props: NamespaceSelectFilterProps & NamespaceSelectFilterDependencies) {
super(props); super(props);
makeObservable(this); makeObservable(this);
} }
@computed get isMultiSelection() { get namespaceStore() {
return NamespaceSelectFilter.isMultiSelection.get(); return this.props.namespaceStore;
} }
set isMultiSelection(val: boolean) { set isMultiSelection(val: boolean) {
NamespaceSelectFilter.isMultiSelection.set(val); NonInjectedNamespaceSelectFilter.isMultiSelection.set(val);
} }
@computed get isMenuOpen() { @computed get isMenuOpen() {
return NamespaceSelectFilter.isMenuOpen.get(); return NonInjectedNamespaceSelectFilter.isMenuOpen.get();
} }
set isMenuOpen(val: boolean) { set isMenuOpen(val: boolean) {
NamespaceSelectFilter.isMenuOpen.set(val); NonInjectedNamespaceSelectFilter.isMenuOpen.set(val);
}
get maxItems() {
return this.props.maxItems;
} }
componentDidMount() { componentDidMount() {
disposeOnUnmount(this, [ disposeOnUnmount(this, [
reaction(() => this.isMenuOpen, newVal => { reaction(() => this.isMenuOpen, newVal => {
if (newVal) { // rising edge of selection if (newVal) { // rising edge of selection
if (namespaceStore.areAllSelectedImplicitly) { if (this.namespaceStore.areAllSelectedImplicitly) {
this.selected.replace([""]); this.selected.replace([""]);
} else { } else {
this.selected.replace(namespaceStore.selectedNames); this.selected.replace(this.namespaceStore.selectedNames);
} }
this.didToggle = false; this.didToggle = false;
} }
@ -108,8 +129,8 @@ export class NamespaceSelectFilter extends React.Component<SelectProps> {
formatOptionLabel({ value: namespace, label }: SelectOption) { formatOptionLabel({ value: namespace, label }: SelectOption) {
const isSelected = namespace const isSelected = namespace
? !namespaceStore.areAllSelectedImplicitly && namespaceStore.hasContext(namespace) ? !this.namespaceStore.areAllSelectedImplicitly && this.namespaceStore.hasContext(namespace)
: namespaceStore.areAllSelectedImplicitly; : this.namespaceStore.areAllSelectedImplicitly;
return ( return (
<div className="flex gaps align-center"> <div className="flex gaps align-center">
@ -124,16 +145,30 @@ export class NamespaceSelectFilter extends React.Component<SelectProps> {
onChange = ([{ value: namespace }]: SelectOption[]) => { onChange = ([{ value: namespace }]: SelectOption[]) => {
if (namespace) { if (namespace) {
if (this.isMultiSelection) { if (this.isMultiSelection) {
if (this.inputValue === "" && (this.namespaceStore.items.length < this.maxItems)) {
this.didToggle = true; this.didToggle = true;
namespaceStore.toggleSingle(namespace); this.namespaceStore.toggleSingle(namespace);
} else { } else {
namespaceStore.selectSingle(namespace); if (this.namespaceStore.areAllSelectedImplicitly && namespace === "") {
this.namespaceStore.selectSingle(namespace);
} else {
this.namespaceStore.toggleSingle(namespace);
}
}
} else {
this.namespaceStore.selectSingle(namespace);
} }
} else { } 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 { private isSelectionKey(e: React.KeyboardEvent): boolean {
if (isMac) { if (isMac) {
return e.key === "Meta"; return e.key === "Meta";
@ -169,6 +204,7 @@ export class NamespaceSelectFilter extends React.Component<SelectProps> {
} }
}; };
@action
reset = () => { reset = () => {
this.isMultiSelection = false; this.isMultiSelection = false;
this.isMenuOpen = false; this.isMenuOpen = false;
@ -176,18 +212,22 @@ export class NamespaceSelectFilter extends React.Component<SelectProps> {
render() { render() {
return ( return (
<div onKeyUp={this.onKeyUp} onKeyDown={this.onKeyDown} onClick={this.onClick}> <div onKeyUp={this.onKeyUp} onKeyDown={this.onKeyDown} onClick={this.onClick} data-testid="namespace-select-filter">
<NamespaceSelect <NamespaceSelect
isMulti={true} isMulti={true}
inputValue={this.inputValue}
menuIsOpen={this.isMenuOpen} menuIsOpen={this.isMenuOpen}
components={{ Placeholder }} components={{
Placeholder: (props) => <Placeholder {...props} namespaceStore={this.namespaceStore} />,
}}
showAllNamespacesOption={true} showAllNamespacesOption={true}
closeMenuOnSelect={false} closeMenuOnSelect={false}
controlShouldRenderValue={false} controlShouldRenderValue={false}
placeholder={""} placeholder={""}
onChange={this.onChange} onChange={this.onChange}
onBlur={this.reset} onBlur={this.reset}
formatOptionLabel={this.formatOptionLabel} onInputChange={this.onInputChange}
formatOptionLabel={this.formatOptionLabel.bind(this)}
className="NamespaceSelectFilter" className="NamespaceSelectFilter"
menuClass="NamespaceSelectFilterMenu" menuClass="NamespaceSelectFilterMenu"
sort={(left, right) => +this.selected.has(right.value) - +this.selected.has(left.value)} sort={(left, right) => +this.selected.has(right.value) - +this.selected.has(left.value)}
@ -196,3 +236,11 @@ export class NamespaceSelectFilter extends React.Component<SelectProps> {
); );
} }
} }
export const NamespaceSelectFilter = withInjectables<NamespaceSelectFilterDependencies, NamespaceSelectFilterProps>(NonInjectedNamespaceSelectFilter, {
getProps: (di, props) => ({
namespaceStore: di.inject(namespaceStoreInjectable),
maxItems: 500,
...props,
}),
});

View File

@ -27,33 +27,53 @@ import { observer } from "mobx-react";
import { Select, SelectOption, SelectProps } from "../select"; import { Select, SelectOption, SelectProps } from "../select";
import { cssNames } from "../../utils"; import { cssNames } from "../../utils";
import { Icon } from "../icon"; 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; showIcons?: boolean;
sort?: (a: SelectOption<string>, b: SelectOption<string>) => number; sort?: (a: SelectOption<string>, b: SelectOption<string>) => number;
showAllNamespacesOption?: boolean; // show "All namespaces" option on the top (default: false) showAllNamespacesOption?: boolean; // show "All namespaces" option on the top (default: false)
customizeOptions?(options: SelectOption[]): SelectOption[]; customizeOptions?(options: SelectOption[]): SelectOption[];
maxItems?: number;
} }
const defaultProps: Partial<Props> = { interface NamespaceSelectDependencies {
namespaceStore?: NamespaceStore
}
const defaultProps: Partial<NamespaceSelectProps> = {
showIcons: true, showIcons: true,
maxItems: 500,
}; };
@observer @observer
export class NamespaceSelect extends React.Component<Props> { class NonInjectedNamespaceSelect extends React.Component<NamespaceSelectProps & NamespaceSelectDependencies> {
static defaultProps = defaultProps as object; static defaultProps = defaultProps as object;
constructor(props: Props) { constructor(props: NamespaceSelectProps & NamespaceSelectDependencies) {
super(props); super(props);
makeObservable(this); makeObservable(this);
} }
private get namespaceStore() {
return this.props.namespaceStore;
}
private get maxItems() {
return this.props.maxItems;
}
// No subscribe here because the subscribe is in <App /> (the cluster frame root component) // No subscribe here because the subscribe is in <App /> (the cluster frame root component)
@computed.struct get options(): SelectOption[] { @computed.struct get options(): SelectOption[] {
const { customizeOptions, showAllNamespacesOption, sort } = this.props; 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) { if (sort) {
options.sort(sort); options.sort(sort);
@ -83,11 +103,11 @@ export class NamespaceSelect extends React.Component<Props> {
}; };
filterOption = (option: SelectOption, rawInput: string) => { 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; return true;
} }
if (namespaceStore.items.length > 500 && rawInput.length < 3) { if (this.namespaceStore.items.length > this.maxItems && rawInput.length < 3) {
return false; return false;
} }
@ -109,8 +129,16 @@ export class NamespaceSelect extends React.Component<Props> {
options={this.options} options={this.options}
components={components} components={components}
filterOption={this.filterOption} filterOption={this.filterOption}
blurInputOnSelect={false}
{...selectProps} {...selectProps}
/> />
); );
} }
} }
export const NamespaceSelect = withInjectables(NonInjectedNamespaceSelect, {
getProps: (di, props) => ({
namespaceStore: di.inject(namespaceStoreInjectable),
...props,
}),
});

View File

@ -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;

View File

@ -20,22 +20,34 @@
*/ */
import { action, comparer, computed, IReactionDisposer, makeObservable, reaction } from "mobx"; 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 { KubeObjectStore, KubeObjectStoreLoadingParams } from "../../../common/k8s-api/kube-object.store";
import { Namespace, namespacesApi } from "../../../common/k8s-api/endpoints/namespaces.api"; import { Namespace, namespacesApi } from "../../../common/k8s-api/endpoints/namespaces.api";
import { apiManager } from "../../../common/k8s-api/api-manager"; import { apiManager } from "../../../common/k8s-api/api-manager";
export type NamespaceStoreConfig = {
storage?: StorageHelper<string[] | undefined>;
autoInit?: boolean;
};
export class NamespaceStore extends KubeObjectStore<Namespace> { export class NamespaceStore extends KubeObjectStore<Namespace> {
api = namespacesApi; api = namespacesApi;
private storage = createStorage<string[] | undefined>("selected_namespaces", undefined);
constructor() { constructor(private config: NamespaceStoreConfig) {
super(); super();
makeObservable(this); makeObservable(this);
autoBind(this); autoBind(this);
if (config.autoInit !== false) {
this.init(); this.init();
} }
}
private get storage() {
return this.config.storage;
}
private async init() { private async init() {
await this.contextReady; await this.contextReady;
@ -247,7 +259,9 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
} }
} }
export const namespaceStore = new NamespaceStore(); export const namespaceStore = new NamespaceStore({
storage: createStorage<string[] | undefined>("selected_namespaces", undefined),
});
apiManager.registerStore(namespaceStore); apiManager.registerStore(namespaceStore);
export function getDummyNamespace(name: string) { export function getDummyNamespace(name: string) {