diff --git a/packages/core/src/renderer/components/namespace-select-filter/model.injectable.ts b/packages/core/src/renderer/components/namespace-select-filter/model.injectable.ts new file mode 100644 index 0000000000..7594de276f --- /dev/null +++ b/packages/core/src/renderer/components/namespace-select-filter/model.injectable.ts @@ -0,0 +1,191 @@ +/** + * 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 namespaceStoreInjectable from "../namespaces/store.injectable"; +import isMultiSelectionKeyInjectable from "./is-selection-key.injectable"; +import clusterFrameContextForNamespacedResourcesInjectable from "../../cluster-frame-context/for-namespaced-resources.injectable"; +import type { IComputedValue, IObservableValue } from "mobx"; +import { action, comparer, computed, observable } from "mobx"; +import GlobToRegExp from "glob-to-regexp"; +import { observableCrate } from "@k8slens/utilities"; + + +export const selectAllNamespaces = Symbol("all-namespaces-selected"); + +export type SelectAllNamespaces = typeof selectAllNamespaces; +export interface NamespaceSelectFilterOption { + value: string | SelectAllNamespaces; + label: string; + id: string | SelectAllNamespaces; +} + +export interface NamespaceSelectFilterModel { + readonly options: IComputedValue; + readonly filteredOptions: IComputedValue; + readonly selectedOptions: IComputedValue; + readonly menu: { + open: () => void; + close: () => void; + toggle: () => void; + readonly isOpen: IComputedValue; + readonly hasSelectedAll: IComputedValue; + onKeyDown: React.KeyboardEventHandler; + onKeyUp: React.KeyboardEventHandler; + }; + onClick: (options: NamespaceSelectFilterOption) => void; + deselect: (namespace: string) => void; + select: (namespace: string) => void; + readonly filterText: IObservableValue; + reset: () => void; + isOptionSelected: (option: NamespaceSelectFilterOption) => boolean; +} + +enum SelectMenuState { + Close = "close", + Open = "open", +} + +const filterBasedOnText = (filterText: string) => { + const regexp = new RegExp(GlobToRegExp(filterText, { extended: true, flags: "gi" })); + + return (options: NamespaceSelectFilterOption) => { + if (options.value === selectAllNamespaces) { + return true; + } + + return Boolean(options.value.match(regexp)); + }; +}; + +const namespaceSelectFilterModelInjectable = getInjectable({ + id: "namespace-select-filter-model", + + instantiate: (di) => { + const namespaceStore = di.inject(namespaceStoreInjectable); + const isMultiSelectionKey = di.inject(isMultiSelectionKeyInjectable); + const context = di.inject(clusterFrameContextForNamespacedResourcesInjectable); + + 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 filterText = observable.box(""); + const selectedNames = computed(() => new Set(context.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((): NamespaceSelectFilterOption[] => [ + { + value: selectAllNamespaces, + label: "All Namespaces", + id: "all-namespaces", + }, + ...context + .allNamespaces + .sort(sortNamespacesByIfTheyHaveBeenSelected) + .map(namespace => ({ + value: namespace, + label: namespace, + id: namespace, + })), + ]); + const filteredOptions = computed(() => options.get().filter(filterBasedOnText(filterText.get()))); + const selectedOptions = computed(() => options.get().filter(model.isOptionSelected)); + 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, + filteredOptions, + selectedOptions, + menu: { + close: action(() => { + menuState.set(SelectMenuState.Close); + filterText.set(""); + }), + open: action(() => { + menuState.set(SelectMenuState.Open); + }), + toggle: () => { + if (menuIsOpen.get()) { + model.menu.close(); + } else { + model.menu.open(); + } + }, + isOpen: menuIsOpen, + hasSelectedAll: computed(() => namespaceStore.areAllSelectedImplicitly), + onKeyDown: (event) => { + if (isMultiSelectionKey(event)) { + isMultiSelection = true; + } else if (event.key === "Escape") { + model.menu.close(); + } + }, + onKeyUp: (event) => { + if (isMultiSelectionKey(event)) { + isMultiSelection = false; + + if (didToggle) { + model.menu.close(); + } + } + }, + }, + onClick: action((option) => { + if (option.value === selectAllNamespaces) { + namespaceStore.selectAll(); + model.menu.close(); + } else if (isMultiSelection) { + didToggle = true; + namespaceStore.toggleSingle(option.value); + } else { + namespaceStore.selectSingle(option.value); + model.menu.close(); + } + }), + deselect: action((namespace) => { + namespaceStore.deselectSingle(namespace); + }), + select: action((namespace) => { + namespaceStore.includeSingle(namespace); + }), + filterText, + reset: action(() => { + isMultiSelection = false; + model.menu.close(); + }), + isOptionSelected, + }; + + return model; + }, +}); + +export default namespaceSelectFilterModelInjectable; diff --git a/packages/core/src/renderer/components/namespace-select-filter/namespace-select-filter-model.injectable.ts b/packages/core/src/renderer/components/namespace-select-filter/namespace-select-filter-model.injectable.ts deleted file mode 100644 index b73b339ff2..0000000000 --- a/packages/core/src/renderer/components/namespace-select-filter/namespace-select-filter-model.injectable.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import { namespaceSelectFilterModelFor } from "./namespace-select-filter-model"; -import { getInjectable } from "@ogre-tools/injectable"; -import namespaceStoreInjectable from "../namespaces/store.injectable"; -import isMultiSelectionKeyInjectable from "./is-selection-key.injectable"; -import clusterFrameContextForNamespacedResourcesInjectable from "../../cluster-frame-context/for-namespaced-resources.injectable"; - -const namespaceSelectFilterModelInjectable = getInjectable({ - id: "namespace-select-filter-model", - - instantiate: (di) => namespaceSelectFilterModelFor({ - namespaceStore: di.inject(namespaceStoreInjectable), - isMultiSelectionKey: di.inject(isMultiSelectionKeyInjectable), - context: di.inject(clusterFrameContextForNamespacedResourcesInjectable), - }), -}); - -export default namespaceSelectFilterModelInjectable; diff --git a/packages/core/src/renderer/components/namespace-select-filter/namespace-select-filter-model.tsx b/packages/core/src/renderer/components/namespace-select-filter/namespace-select-filter-model.tsx deleted file mode 100644 index ae56cdc40a..0000000000 --- a/packages/core/src/renderer/components/namespace-select-filter/namespace-select-filter-model.tsx +++ /dev/null @@ -1,188 +0,0 @@ -/** - * Copyright (c) OpenLens Authors. All rights reserved. - * Licensed under MIT License. See LICENSE in root directory for more information. - */ -import type React from "react"; -import type { IComputedValue, IObservableValue } from "mobx"; -import { observable, action, computed, comparer } from "mobx"; -import type { NamespaceStore } from "../namespaces/store"; -import { observableCrate } from "@k8slens/utilities"; -import type { IsMultiSelectionKey } from "./is-selection-key.injectable"; -import type { ClusterContext } from "../../cluster-frame-context/cluster-frame-context"; -import GlobToRegExp from "glob-to-regexp"; - -interface Dependencies { - context: ClusterContext; - namespaceStore: NamespaceStore; - isMultiSelectionKey: IsMultiSelectionKey; -} - -export const selectAllNamespaces = Symbol("all-namespaces-selected"); - -export type SelectAllNamespaces = typeof selectAllNamespaces; -export interface NamespaceSelectFilterOption { - value: string | SelectAllNamespaces; - label: string; - id: string | SelectAllNamespaces; -} - -export interface NamespaceSelectFilterModel { - readonly options: IComputedValue; - readonly filteredOptions: IComputedValue; - readonly selectedOptions: IComputedValue; - readonly menu: { - open: () => void; - close: () => void; - toggle: () => void; - readonly isOpen: IComputedValue; - readonly hasSelectedAll: IComputedValue; - onKeyDown: React.KeyboardEventHandler; - onKeyUp: React.KeyboardEventHandler; - }; - onClick: (options: NamespaceSelectFilterOption) => void; - deselect: (namespace: string) => void; - select: (namespace: string) => void; - readonly filterText: IObservableValue; - reset: () => void; - isOptionSelected: (option: NamespaceSelectFilterOption) => boolean; -} - -enum SelectMenuState { - Close = "close", - Open = "open", -} - -const filterBasedOnText = (filterText: string) => { - const regexp = new RegExp(GlobToRegExp(filterText, { extended: true, flags: "gi" })); - - return (options: NamespaceSelectFilterOption) => { - if (options.value === selectAllNamespaces) { - return true; - } - - return Boolean(options.value.match(regexp)); - }; -}; - -export function namespaceSelectFilterModelFor(dependencies: Dependencies): NamespaceSelectFilterModel { - const { isMultiSelectionKey, namespaceStore, context } = 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 filterText = observable.box(""); - const selectedNames = computed(() => new Set(context.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((): NamespaceSelectFilterOption[] => [ - { - value: selectAllNamespaces, - label: "All Namespaces", - id: "all-namespaces", - }, - ...context - .allNamespaces - .sort(sortNamespacesByIfTheyHaveBeenSelected) - .map(namespace => ({ - value: namespace, - label: namespace, - id: namespace, - })), - ]); - const filteredOptions = computed(() => options.get().filter(filterBasedOnText(filterText.get()))); - const selectedOptions = computed(() => options.get().filter(model.isOptionSelected)); - 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, - filteredOptions, - selectedOptions, - menu: { - close: action(() => { - menuState.set(SelectMenuState.Close); - filterText.set(""); - }), - open: action(() => { - menuState.set(SelectMenuState.Open); - }), - toggle: () => { - if (menuIsOpen.get()) { - model.menu.close(); - } else { - model.menu.open(); - } - }, - isOpen: menuIsOpen, - hasSelectedAll: computed(() => namespaceStore.areAllSelectedImplicitly), - onKeyDown: (event) => { - if (isMultiSelectionKey(event)) { - isMultiSelection = true; - } else if (event.key === "Escape") { - model.menu.close(); - } - }, - onKeyUp: (event) => { - if (isMultiSelectionKey(event)) { - isMultiSelection = false; - - if (didToggle) { - model.menu.close(); - } - } - }, - }, - onClick: action((option) => { - if (option.value === selectAllNamespaces) { - namespaceStore.selectAll(); - model.menu.close(); - } else if (isMultiSelection) { - didToggle = true; - namespaceStore.toggleSingle(option.value); - } else { - namespaceStore.selectSingle(option.value); - model.menu.close(); - } - }), - deselect: action((namespace) => { - namespaceStore.deselectSingle(namespace); - }), - select: action((namespace) => { - namespaceStore.includeSingle(namespace); - }), - filterText, - reset: action(() => { - isMultiSelection = false; - model.menu.close(); - }), - isOptionSelected, - }; - - return model; -} diff --git a/packages/core/src/renderer/components/namespace-select-filter/namespace-select-filter.tsx b/packages/core/src/renderer/components/namespace-select-filter/namespace-select-filter.tsx index 884188507d..56c45cea5a 100644 --- a/packages/core/src/renderer/components/namespace-select-filter/namespace-select-filter.tsx +++ b/packages/core/src/renderer/components/namespace-select-filter/namespace-select-filter.tsx @@ -8,9 +8,8 @@ import "./namespace-select-filter.scss"; import React, { useEffect, useRef } from "react"; import { observer } from "mobx-react"; import { withInjectables } from "@ogre-tools/injectable-react"; -import type { NamespaceSelectFilterModel, NamespaceSelectFilterOption } from "./namespace-select-filter-model"; -import { selectAllNamespaces } from "./namespace-select-filter-model"; -import namespaceSelectFilterModelInjectable from "./namespace-select-filter-model.injectable"; +import type { NamespaceSelectFilterModel, NamespaceSelectFilterOption } from "./model.injectable"; +import namespaceSelectFilterModelInjectable, { selectAllNamespaces } from "./model.injectable"; import { VariableSizeList } from "react-window"; import { Icon } from "../icon"; import { cssNames, prevDefault } from "@k8slens/utilities";