From 151a393fb5b3a7636af769bedb942b0545dedc5b Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 23 May 2023 10:12:18 -0400 Subject: [PATCH] feat: Improve UX of namespace select filter - Add better support for thousands of namespaces - Add support for mouse only multi-selection Signed-off-by: Sebastian Malton --- .../namespace-select-filter-model.tsx | 152 +++++------ .../namespaces/namespace-select-filter.scss | 129 +++++----- .../namespaces/namespace-select-filter.tsx | 238 +++++++++++++----- .../renderer/components/namespaces/store.ts | 14 ++ .../window/event-listener.injectable.ts | 4 +- .../utilities/src/type-narrowing.ts | 3 +- 6 files changed, 338 insertions(+), 202 deletions(-) diff --git a/packages/core/src/renderer/components/namespaces/namespace-select-filter-model/namespace-select-filter-model.tsx b/packages/core/src/renderer/components/namespaces/namespace-select-filter-model/namespace-select-filter-model.tsx index c0ad194dfb..8a3cac363c 100644 --- a/packages/core/src/renderer/components/namespaces/namespace-select-filter-model/namespace-select-filter-model.tsx +++ b/packages/core/src/renderer/components/namespaces/namespace-select-filter-model/namespace-select-filter-model.tsx @@ -2,16 +2,14 @@ * Copyright (c) OpenLens Authors. All rights reserved. * Licensed under MIT License. See LICENSE in root directory for more information. */ -import React from "react"; -import type { IComputedValue } from "mobx"; +import type React from "react"; +import type { IComputedValue, IObservableValue } from "mobx"; import { observable, action, computed, comparer } from "mobx"; import type { NamespaceStore } from "../store"; -import type { ActionMeta, MultiValue } from "react-select"; -import { Icon } from "@k8slens/icon"; -import type { SelectOption } from "../../select"; 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; @@ -22,22 +20,31 @@ interface Dependencies { export const selectAllNamespaces = Symbol("all-namespaces-selected"); export type SelectAllNamespaces = typeof selectAllNamespaces; -export type NamespaceSelectFilterOption = SelectOption; +export interface NamespaceSelectFilterOption { + value: string | SelectAllNamespaces; + label: string; + id: string | SelectAllNamespaces; +} export interface NamespaceSelectFilterModel { - readonly options: IComputedValue; + 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; }; - onChange: (newValue: MultiValue, actionMeta: ActionMeta) => void; - onClick: () => void; - 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; - formatOptionLabel: (option: NamespaceSelectFilterOption) => JSX.Element; } enum SelectMenuState { @@ -45,6 +52,18 @@ enum SelectMenuState { 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; @@ -58,6 +77,7 @@ export function namespaceSelectFilterModelFor(dependencies: Dependencies): Names didToggle = false; }, }]); + const filterText = observable.box(""); const selectedNames = computed(() => new Set(context.contextNamespaces), { equals: comparer.structural, }); @@ -74,7 +94,7 @@ export function namespaceSelectFilterModelFor(dependencies: Dependencies): Names ? 1 : -1; }; - const options = computed((): readonly NamespaceSelectFilterOption[] => [ + const options = computed((): NamespaceSelectFilterOption[] => [ { value: selectAllNamespaces, label: "All Namespaces", @@ -89,6 +109,8 @@ export function namespaceSelectFilterModelFor(dependencies: Dependencies): Names 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) { @@ -100,82 +122,66 @@ export function namespaceSelectFilterModelFor(dependencies: Dependencies): Names 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, - }, - 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) { + 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, - formatOptionLabel: (option) => { - if (option.value === selectAllNamespaces) { - return <>All Namespaces; - } - - return ( -
- - {option.value} - {isOptionSelected(option) && ( - - )} -
- ); - }, }; return model; diff --git a/packages/core/src/renderer/components/namespaces/namespace-select-filter.scss b/packages/core/src/renderer/components/namespaces/namespace-select-filter.scss index f227976b46..2eead588e1 100644 --- a/packages/core/src/renderer/components/namespaces/namespace-select-filter.scss +++ b/packages/core/src/renderer/components/namespaces/namespace-select-filter.scss @@ -4,75 +4,86 @@ } } -.NamespaceSelectFilterParent { - max-width: 300px; -} +.namespace-select-filter { + width: 300px; + position: relative; -.NamespaceSelectFilter { - --gradientColor: var(--select-menu-bgc); + .list-container { + position: absolute; + top: 30px; + z-index: 10; + background: var(--mainBackground); + border-radius: var(--border-radius); - .Select { - &__placeholder { - white-space: nowrap; - overflow: scroll hidden!important; - text-overflow: unset!important; - margin-left: -8px; - padding-left: 8px; - margin-right: -8px; - padding-right: 8px; - line-height: 1.1; + li.option { + cursor: pointer; + padding: calc(var(--padding) / 2) var(--padding); + list-style: none; - &::-webkit-scrollbar { - display: none; + &:hover { + color: white; + } + + .selected-icon:hover { + color: var(--buttonAccentBackground); + } + + .add-selection-icon:hover { + color: var(--colorSuccess); } } + } - &__value-container { + .menu { + width: 300px; + border-radius: var(--border-radius); + border: 1px solid; + border-color: var(--halfGray); + padding: calc(var(--padding) / 2) var(--padding); + display: flex; + + .non-icon { + width: -webkit-fill-available; position: relative; - &::before, &::after { - content: ' '; - position: absolute; - z-index: 20; - display: block; - width: 8px; - height: var(--font-size); - } - - &::before { - left: 0; - background: linear-gradient(to right, var(--gradientColor) 0px, transparent); - } - - &::after { - right: 0; - background: linear-gradient(to left, var(--gradientColor) 0px, transparent); - } - } - } -} - -.NamespaceSelectFilterMenu { - right: 0; - - .Select { - &__menu-list { - max-width: 400px; - } - - &__option { - white-space: normal; - word-break: break-all; - padding: 4px 8px; - border-radius: 3px; - - &--is-selected:not(&--is-focused) { + input { + width: 100%; background: transparent; } + + label { + white-space: nowrap; + overflow: scroll hidden!important; + text-overflow: unset!important; + margin-left: -16px; + padding-left: 8px; + padding-right: 8px; + width: calc(100% + 4px); + position: absolute; + left: 9px; + + &::-webkit-scrollbar { + display: none; + } + } + + .gradient { + position: absolute; + width: 10px; + top: 0; + height: 20px; + z-index: 21; + + &.left { + left: -7px; + background: linear-gradient(to right, var(--contentColor) 0%, rgba(255, 255, 255, 0) 100%); + } + + &.right { + right: 2px; + background: linear-gradient(to left, var(--contentColor) 0%, rgba(255, 255, 255, 0) 100%); + } + } } } - - .Icon { - margin-right: $margin * 0.5; - } } diff --git a/packages/core/src/renderer/components/namespaces/namespace-select-filter.tsx b/packages/core/src/renderer/components/namespaces/namespace-select-filter.tsx index de1c2a8cd6..b22ba43114 100644 --- a/packages/core/src/renderer/components/namespaces/namespace-select-filter.tsx +++ b/packages/core/src/renderer/components/namespaces/namespace-select-filter.tsx @@ -5,16 +5,17 @@ import "./namespace-select-filter.scss"; -import React from "react"; +import React, { useEffect, useRef } from "react"; import { observer } from "mobx-react"; -import type { PlaceholderProps } from "react-select"; -import { components } from "react-select"; -import type { NamespaceStore } from "./store"; -import { Select } from "../select"; import { withInjectables } from "@ogre-tools/injectable-react"; -import type { NamespaceSelectFilterModel, NamespaceSelectFilterOption, SelectAllNamespaces } from "./namespace-select-filter-model/namespace-select-filter-model"; +import type { NamespaceSelectFilterModel, NamespaceSelectFilterOption } from "./namespace-select-filter-model/namespace-select-filter-model"; +import { selectAllNamespaces } from "./namespace-select-filter-model/namespace-select-filter-model"; import namespaceSelectFilterModelInjectable from "./namespace-select-filter-model/namespace-select-filter-model.injectable"; -import namespaceStoreInjectable from "./store.injectable"; +import { VariableSizeList } from "react-window"; +import { Icon } from "../icon"; +import { cssNames, prevDefault } from "@k8slens/utilities"; +import { addWindowEventListener } from "../../window/event-listener.injectable"; +import { TooltipPosition } from "@k8slens/tooltip"; interface NamespaceSelectFilterProps { id: string; @@ -24,33 +25,171 @@ interface Dependencies { model: NamespaceSelectFilterModel; } -const NonInjectedNamespaceSelectFilter = observer(({ model, id }: Dependencies & NamespaceSelectFilterProps) => ( -
- +const Gradient = ({ type }: { type: "left" | "right" }) => ( +
+); + +const NamespaceSelectFilterMenu = observer(({ id, model }: Dependencies & NamespaceSelectFilterProps) => { + const selectedOptions = model.selectedOptions.get(); + const prefix = selectedOptions.length === 1 + ? "Namespace" + : "Namespaces"; + + return ( +
+
+ model.filterText.set(event.target.value)} + onClick={model.menu.open} + /> + + + +
+ +
+ ); +}); + +const rowHeight = 29; + +const NonInjectedNamespaceSelectFilter = observer(({ model, id }: Dependencies & NamespaceSelectFilterProps) => { + const divRef = useRef(null); + + useEffect(() => { + return addWindowEventListener("click", (event) => { + if (!model.menu.isOpen.get()) { + return; + } + + if (divRef.current?.contains(event.target as Node)) { + return; + } + + model.menu.close(); + }); + }, []); + + return ( +
+ + {model.menu.isOpen.get() && ( +
+ rowHeight} + itemCount={model.filteredOptions.get().length} + itemData={{ + items: model.filteredOptions.get(), + model, + }} + overscanCount={5} + innerElementType={"ul"} + > + {NamespaceSelectFilterRow} + +
+ )} +
+ ); +}); + +interface FilterRowProps { + index: number; + style: React.CSSProperties; + data: { + model: NamespaceSelectFilterModel; + items: NamespaceSelectFilterOption[]; + }; +} + +const renderSingleOptionIcons = (namespace: string, option: NamespaceSelectFilterOption, model: NamespaceSelectFilterModel) => { + if (model.isOptionSelected(option)) { + return ( + model.deselect(namespace))} + /> + ); + } + + return ( + model.select(namespace))} /> -
-)); + ); +}; + +const NamespaceSelectFilterRow = observer(({ index, style, data: { model, items }}: FilterRowProps) => { + const option = items[index]; + + return ( +
  • model.onClick(option)} + > + {option.value === selectAllNamespaces + ? All Namespaces + : ( + <> + model.onClick(option))} + tooltip={{ + preferredPositions: TooltipPosition.LEFT, + children: `Select only ${option.value}`, + }} + /> + {option.value} + {renderSingleOptionIcons(option.value, option, model)} + + )} +
  • + ); +}); export const NamespaceSelectFilter = withInjectables(NonInjectedNamespaceSelectFilter, { getProps: (di, props) => ({ @@ -58,38 +197,3 @@ export const NamespaceSelectFilter = withInjectables {} - -interface PlaceholderDependencies { - namespaceStore: NamespaceStore; -} - -const NonInjectedPlaceholder = observer(({ namespaceStore, ...props }: CustomPlaceholderProps & PlaceholderDependencies) => { - const getPlaceholder = () => { - const namespaces = namespaceStore.contextNamespaces; - - if (namespaceStore.areAllSelectedImplicitly || namespaces.length === 0) { - return "All namespaces"; - } - - const prefix = namespaces.length === 1 - ? "Namespace" - : "Namespaces"; - - return `${prefix}: ${namespaces.join(", ")}`; - }; - - return ( - - {getPlaceholder()} - - ); -}); - -const Placeholder = withInjectables( NonInjectedPlaceholder, { - getProps: (di, props) => ({ - namespaceStore: di.inject(namespaceStoreInjectable), - ...props, - }), -}); diff --git a/packages/core/src/renderer/components/namespaces/store.ts b/packages/core/src/renderer/components/namespaces/store.ts index 1bbb4446dd..cce4a2e4c1 100644 --- a/packages/core/src/renderer/components/namespaces/store.ts +++ b/packages/core/src/renderer/components/namespaces/store.ts @@ -171,6 +171,20 @@ export class NamespaceStore extends KubeObjectStore { this.dependencies.storage.set([...nextState]); } + deselectSingle(namespace: string) { + const nextState = new Set(this.contextNamespaces); + + nextState.delete(namespace); + this.dependencies.storage.set([...nextState]); + } + + includeSingle(namespace: string) { + const nextState = new Set(this.contextNamespaces); + + nextState.add(namespace); + this.dependencies.storage.set([...nextState]); + } + /** * Makes the given namespace the sole selected namespace */ diff --git a/packages/core/src/renderer/window/event-listener.injectable.ts b/packages/core/src/renderer/window/event-listener.injectable.ts index 631fec116e..4be1022424 100644 --- a/packages/core/src/renderer/window/event-listener.injectable.ts +++ b/packages/core/src/renderer/window/event-listener.injectable.ts @@ -9,10 +9,10 @@ import type { Disposer } from "@k8slens/utilities"; export type AddWindowEventListener = typeof addWindowEventListener; export type WindowEventListener = (this: Window, ev: WindowEventMap[K]) => any; -function addWindowEventListener(type: K, listener: WindowEventListener, options?: boolean | AddEventListenerOptions): Disposer { +export function addWindowEventListener(type: K, listener: WindowEventListener, options?: boolean | AddEventListenerOptions): Disposer { window.addEventListener(type, listener, options); - return () => void window.removeEventListener(type, listener); + return () => void window.removeEventListener(type, listener, options); } const windowAddEventListenerInjectable = getInjectable({ diff --git a/packages/utility-features/utilities/src/type-narrowing.ts b/packages/utility-features/utilities/src/type-narrowing.ts index 68d5265237..8c545608df 100644 --- a/packages/utility-features/utilities/src/type-narrowing.ts +++ b/packages/utility-features/utilities/src/type-narrowing.ts @@ -123,7 +123,8 @@ export function isDefined(val: T | undefined | null): val is T { return val != null; } -export function isFunction(val: unknown): val is (...args: unknown[]) => unknown { +// @ts-expect-error 2677 +export function isFunction(val: T): val is Extract extends never ? ((...args: unknown[]) => unknown) : Extract { return typeof val === "function"; }