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

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 <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2023-05-23 10:12:18 -04:00
parent 21914db35b
commit 151a393fb5
6 changed files with 338 additions and 202 deletions

View File

@ -2,16 +2,14 @@
* Copyright (c) OpenLens Authors. All rights reserved. * Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import React from "react"; import type React from "react";
import type { IComputedValue } from "mobx"; import type { IComputedValue, IObservableValue } from "mobx";
import { observable, action, computed, comparer } from "mobx"; import { observable, action, computed, comparer } from "mobx";
import type { NamespaceStore } from "../store"; 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 { observableCrate } from "@k8slens/utilities";
import type { IsMultiSelectionKey } from "./is-selection-key.injectable"; import type { IsMultiSelectionKey } from "./is-selection-key.injectable";
import type { ClusterContext } from "../../../cluster-frame-context/cluster-frame-context"; import type { ClusterContext } from "../../../cluster-frame-context/cluster-frame-context";
import GlobToRegExp from "glob-to-regexp";
interface Dependencies { interface Dependencies {
context: ClusterContext; context: ClusterContext;
@ -22,22 +20,31 @@ interface Dependencies {
export const selectAllNamespaces = Symbol("all-namespaces-selected"); export const selectAllNamespaces = Symbol("all-namespaces-selected");
export type SelectAllNamespaces = typeof selectAllNamespaces; export type SelectAllNamespaces = typeof selectAllNamespaces;
export type NamespaceSelectFilterOption = SelectOption<string | SelectAllNamespaces>; export interface NamespaceSelectFilterOption {
value: string | SelectAllNamespaces;
label: string;
id: string | SelectAllNamespaces;
}
export interface NamespaceSelectFilterModel { export interface NamespaceSelectFilterModel {
readonly options: IComputedValue<readonly NamespaceSelectFilterOption[]>; readonly options: IComputedValue<NamespaceSelectFilterOption[]>;
readonly filteredOptions: IComputedValue<NamespaceSelectFilterOption[]>;
readonly selectedOptions: IComputedValue<NamespaceSelectFilterOption[]>;
readonly menu: { readonly menu: {
open: () => void; open: () => void;
close: () => void; close: () => void;
toggle: () => void;
readonly isOpen: IComputedValue<boolean>; readonly isOpen: IComputedValue<boolean>;
}; readonly hasSelectedAll: IComputedValue<boolean>;
onChange: (newValue: MultiValue<NamespaceSelectFilterOption>, actionMeta: ActionMeta<NamespaceSelectFilterOption>) => void;
onClick: () => void;
onKeyDown: React.KeyboardEventHandler; onKeyDown: React.KeyboardEventHandler;
onKeyUp: React.KeyboardEventHandler; onKeyUp: React.KeyboardEventHandler;
};
onClick: (options: NamespaceSelectFilterOption) => void;
deselect: (namespace: string) => void;
select: (namespace: string) => void;
readonly filterText: IObservableValue<string>;
reset: () => void; reset: () => void;
isOptionSelected: (option: NamespaceSelectFilterOption) => boolean; isOptionSelected: (option: NamespaceSelectFilterOption) => boolean;
formatOptionLabel: (option: NamespaceSelectFilterOption) => JSX.Element;
} }
enum SelectMenuState { enum SelectMenuState {
@ -45,6 +52,18 @@ enum SelectMenuState {
Open = "open", 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 { export function namespaceSelectFilterModelFor(dependencies: Dependencies): NamespaceSelectFilterModel {
const { isMultiSelectionKey, namespaceStore, context } = dependencies; const { isMultiSelectionKey, namespaceStore, context } = dependencies;
@ -58,6 +77,7 @@ export function namespaceSelectFilterModelFor(dependencies: Dependencies): Names
didToggle = false; didToggle = false;
}, },
}]); }]);
const filterText = observable.box("");
const selectedNames = computed(() => new Set(context.contextNamespaces), { const selectedNames = computed(() => new Set(context.contextNamespaces), {
equals: comparer.structural, equals: comparer.structural,
}); });
@ -74,7 +94,7 @@ export function namespaceSelectFilterModelFor(dependencies: Dependencies): Names
? 1 ? 1
: -1; : -1;
}; };
const options = computed((): readonly NamespaceSelectFilterOption[] => [ const options = computed((): NamespaceSelectFilterOption[] => [
{ {
value: selectAllNamespaces, value: selectAllNamespaces,
label: "All Namespaces", label: "All Namespaces",
@ -89,6 +109,8 @@ export function namespaceSelectFilterModelFor(dependencies: Dependencies): Names
id: 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 menuIsOpen = computed(() => menuState.get() === SelectMenuState.Open);
const isOptionSelected: NamespaceSelectFilterModel["isOptionSelected"] = (option) => { const isOptionSelected: NamespaceSelectFilterModel["isOptionSelected"] = (option) => {
if (option.value === selectAllNamespaces) { if (option.value === selectAllNamespaces) {
@ -100,46 +122,30 @@ export function namespaceSelectFilterModelFor(dependencies: Dependencies): Names
const model: NamespaceSelectFilterModel = { const model: NamespaceSelectFilterModel = {
options, options,
filteredOptions,
selectedOptions,
menu: { menu: {
close: action(() => { close: action(() => {
menuState.set(SelectMenuState.Close); menuState.set(SelectMenuState.Close);
filterText.set("");
}), }),
open: action(() => { open: action(() => {
menuState.set(SelectMenuState.Open); menuState.set(SelectMenuState.Open);
}), }),
isOpen: menuIsOpen, toggle: () => {
}, if (menuIsOpen.get()) {
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(); model.menu.close();
} else {
model.menu.open();
} }
}, },
isOpen: menuIsOpen,
hasSelectedAll: computed(() => namespaceStore.areAllSelectedImplicitly),
onKeyDown: (event) => { onKeyDown: (event) => {
if (isMultiSelectionKey(event)) { if (isMultiSelectionKey(event)) {
isMultiSelection = true; isMultiSelection = true;
} else if (event.key === "Escape") {
model.menu.close();
} }
}, },
onKeyUp: (event) => { onKeyUp: (event) => {
@ -151,31 +157,31 @@ export function namespaceSelectFilterModelFor(dependencies: Dependencies): Names
} }
} }
}, },
},
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(() => { reset: action(() => {
isMultiSelection = false; isMultiSelection = false;
model.menu.close(); model.menu.close();
}), }),
isOptionSelected, isOptionSelected,
formatOptionLabel: (option) => {
if (option.value === selectAllNamespaces) {
return <>All Namespaces</>;
}
return (
<div className="flex gaps align-center">
<Icon small material="layers" />
<span>{option.value}</span>
{isOptionSelected(option) && (
<Icon
small
material="check"
className="box right"
data-testid={`namespace-select-filter-option-${option.value}-selected`}
/>
)}
</div>
);
},
}; };
return model; return model;

View File

@ -4,75 +4,86 @@
} }
} }
.NamespaceSelectFilterParent { .namespace-select-filter {
max-width: 300px; width: 300px;
position: relative;
.list-container {
position: absolute;
top: 30px;
z-index: 10;
background: var(--mainBackground);
border-radius: var(--border-radius);
li.option {
cursor: pointer;
padding: calc(var(--padding) / 2) var(--padding);
list-style: none;
&:hover {
color: white;
} }
.NamespaceSelectFilter { .selected-icon:hover {
--gradientColor: var(--select-menu-bgc); color: var(--buttonAccentBackground);
}
.Select { .add-selection-icon:hover {
&__placeholder { color: var(--colorSuccess);
}
}
}
.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;
input {
width: 100%;
background: transparent;
}
label {
white-space: nowrap; white-space: nowrap;
overflow: scroll hidden!important; overflow: scroll hidden!important;
text-overflow: unset!important; text-overflow: unset!important;
margin-left: -8px; margin-left: -16px;
padding-left: 8px; padding-left: 8px;
margin-right: -8px;
padding-right: 8px; padding-right: 8px;
line-height: 1.1; width: calc(100% + 4px);
position: absolute;
left: 9px;
&::-webkit-scrollbar { &::-webkit-scrollbar {
display: none; display: none;
} }
} }
&__value-container { .gradient {
position: relative;
&::before, &::after {
content: ' ';
position: absolute; position: absolute;
z-index: 20; width: 10px;
display: block; top: 0;
width: 8px; height: 20px;
height: var(--font-size); z-index: 21;
&.left {
left: -7px;
background: linear-gradient(to right, var(--contentColor) 0%, rgba(255, 255, 255, 0) 100%);
} }
&::before { &.right {
left: 0; right: 2px;
background: linear-gradient(to right, var(--gradientColor) 0px, transparent); background: linear-gradient(to left, var(--contentColor) 0%, rgba(255, 255, 255, 0) 100%);
}
&::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) {
background: transparent;
}
}
}
.Icon {
margin-right: $margin * 0.5;
}
} }

View File

@ -5,16 +5,17 @@
import "./namespace-select-filter.scss"; import "./namespace-select-filter.scss";
import React from "react"; import React, { useEffect, useRef } from "react";
import { observer } from "mobx-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 { 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 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 { interface NamespaceSelectFilterProps {
id: string; id: string;
@ -24,33 +25,171 @@ interface Dependencies {
model: NamespaceSelectFilterModel; model: NamespaceSelectFilterModel;
} }
const NonInjectedNamespaceSelectFilter = observer(({ model, id }: Dependencies & NamespaceSelectFilterProps) => ( const Gradient = ({ type }: { type: "left" | "right" }) => (
<div <div className={cssNames("gradient", type)} />
onKeyUp={model.onKeyUp} );
onKeyDown={model.onKeyDown}
onClick={model.onClick} const NamespaceSelectFilterMenu = observer(({ id, model }: Dependencies & NamespaceSelectFilterProps) => {
className="NamespaceSelectFilterParent" const selectedOptions = model.selectedOptions.get();
data-testid="namespace-select-filter" const prefix = selectedOptions.length === 1
> ? "Namespace"
<Select<string | SelectAllNamespaces, NamespaceSelectFilterOption, true> : "Namespaces";
id={id}
isMulti={true} return (
isClearable={false} <div className="menu">
menuIsOpen={model.menu.isOpen.get()} <div className="non-icon">
components={{ Placeholder }} <input
closeMenuOnSelect={false} type="text"
controlShouldRenderValue={false} id={`${id}-filter`}
onChange={model.onChange} value={model.filterText.get()}
onBlur={model.reset} onChange={(event) => model.filterText.set(event.target.value)}
formatOptionLabel={model.formatOptionLabel} onClick={model.menu.open}
options={model.options.get()} />
className="NamespaceSelect NamespaceSelectFilter" <Gradient type="left" />
menuClass="NamespaceSelectFilterMenu" <label htmlFor={`${id}-filter`}>
isOptionSelected={model.isOptionSelected} {(
hideSelectedOptions={false} model.filterText.get() !== ""
? model.filterText.get()
: (
model.menu.hasSelectedAll.get()
? "All namespaces"
: `${prefix}: ${selectedOptions.map(option => option.value).join(", ")}`
)
)}
</label>
<Gradient type="right" />
</div>
<Icon
className="expand-icon"
material={model.menu.isOpen.get() ? "expand_less" : "expand_more"}
onClick={model.menu.toggle}
/> />
</div> </div>
)); );
});
const rowHeight = 29;
const NonInjectedNamespaceSelectFilter = observer(({ model, id }: Dependencies & NamespaceSelectFilterProps) => {
const divRef = useRef<HTMLDivElement | null>(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 (
<div
onKeyUp={model.menu.onKeyUp}
onKeyDown={model.menu.onKeyDown}
className="namespace-select-filter"
data-testid="namespace-select-filter"
id={id}
ref={divRef}
tabIndex={1}
>
<NamespaceSelectFilterMenu model={model} id={id} />
{model.menu.isOpen.get() && (
<div
className="list-container"
>
<VariableSizeList
className="list"
width={300}
height={Math.min(model.filteredOptions.get().length * rowHeight, 300)}
itemSize={() => rowHeight}
itemCount={model.filteredOptions.get().length}
itemData={{
items: model.filteredOptions.get(),
model,
}}
overscanCount={5}
innerElementType={"ul"}
>
{NamespaceSelectFilterRow}
</VariableSizeList>
</div>
)}
</div>
);
});
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 (
<Icon
small
material="check"
className="selected-icon box right"
data-testid={`namespace-select-filter-option-${namespace}-selected`}
tooltip={`Remove ${namespace} from selection`}
onClick={prevDefault(() => model.deselect(namespace))}
/>
);
}
return (
<Icon
small
material="add_box"
className="add-selection-icon box right"
data-testid={`namespace-select-filter-option-${namespace}-add-to-selection`}
tooltip={`Add ${namespace} to selection`}
onClick={prevDefault(() => model.select(namespace))}
/>
);
};
const NamespaceSelectFilterRow = observer(({ index, style, data: { model, items }}: FilterRowProps) => {
const option = items[index];
return (
<li
style={style}
className={cssNames("option", "flex gaps align-center", {
"all-namespaces": option.value === selectAllNamespaces,
"single-namespace": option.value !== selectAllNamespaces,
})}
onClick={() => model.onClick(option)}
>
{option.value === selectAllNamespaces
? <span className="data">All Namespaces</span>
: (
<>
<Icon
small
material="layers"
onClick={prevDefault(() => model.onClick(option))}
tooltip={{
preferredPositions: TooltipPosition.LEFT,
children: `Select only ${option.value}`,
}}
/>
<span className="data">{option.value}</span>
{renderSingleOptionIcons(option.value, option, model)}
</>
)}
</li>
);
});
export const NamespaceSelectFilter = withInjectables<Dependencies, NamespaceSelectFilterProps>(NonInjectedNamespaceSelectFilter, { export const NamespaceSelectFilter = withInjectables<Dependencies, NamespaceSelectFilterProps>(NonInjectedNamespaceSelectFilter, {
getProps: (di, props) => ({ getProps: (di, props) => ({
@ -58,38 +197,3 @@ export const NamespaceSelectFilter = withInjectables<Dependencies, NamespaceSele
...props, ...props,
}), }),
}); });
export interface CustomPlaceholderProps extends PlaceholderProps<NamespaceSelectFilterOption, true> {}
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 (
<components.Placeholder {...props}>
{getPlaceholder()}
</components.Placeholder>
);
});
const Placeholder = withInjectables<PlaceholderDependencies, CustomPlaceholderProps>( NonInjectedPlaceholder, {
getProps: (di, props) => ({
namespaceStore: di.inject(namespaceStoreInjectable),
...props,
}),
});

View File

@ -171,6 +171,20 @@ export class NamespaceStore extends KubeObjectStore<Namespace, NamespaceApi> {
this.dependencies.storage.set([...nextState]); 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 * Makes the given namespace the sole selected namespace
*/ */

View File

@ -9,10 +9,10 @@ import type { Disposer } from "@k8slens/utilities";
export type AddWindowEventListener = typeof addWindowEventListener; export type AddWindowEventListener = typeof addWindowEventListener;
export type WindowEventListener<K extends keyof WindowEventMap> = (this: Window, ev: WindowEventMap[K]) => any; export type WindowEventListener<K extends keyof WindowEventMap> = (this: Window, ev: WindowEventMap[K]) => any;
function addWindowEventListener<K extends keyof WindowEventMap>(type: K, listener: WindowEventListener<K>, options?: boolean | AddEventListenerOptions): Disposer { export function addWindowEventListener<K extends keyof WindowEventMap>(type: K, listener: WindowEventListener<K>, options?: boolean | AddEventListenerOptions): Disposer {
window.addEventListener(type, listener, options); window.addEventListener(type, listener, options);
return () => void window.removeEventListener(type, listener); return () => void window.removeEventListener(type, listener, options);
} }
const windowAddEventListenerInjectable = getInjectable({ const windowAddEventListenerInjectable = getInjectable({

View File

@ -123,7 +123,8 @@ export function isDefined<T>(val: T | undefined | null): val is T {
return val != null; return val != null;
} }
export function isFunction(val: unknown): val is (...args: unknown[]) => unknown { // @ts-expect-error 2677
export function isFunction<T>(val: T): val is Extract<T, Function> extends never ? ((...args: unknown[]) => unknown) : Extract<T, Function> {
return typeof val === "function"; return typeof val === "function";
} }