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:
parent
21914db35b
commit
151a393fb5
@ -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;
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user