mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
259 lines
7.5 KiB
TypeScript
259 lines
7.5 KiB
TypeScript
/**
|
|
* Copyright (c) OpenLens Authors. All rights reserved.
|
|
* Licensed under MIT License. See LICENSE in root directory for more information.
|
|
*/
|
|
|
|
// Wrapper for "react-select" component
|
|
// API docs: https://react-select.com/
|
|
import "./select.scss";
|
|
|
|
import React from "react";
|
|
import type { ObservableSet } from "mobx";
|
|
import { action, computed, makeObservable } from "mobx";
|
|
import { observer } from "mobx-react";
|
|
import ReactSelect, { components, createFilter } from "react-select";
|
|
import type { Props as ReactSelectProps, GroupBase, MultiValue, OptionsOrGroups, PropsValue, SingleValue } from "react-select";
|
|
import type { ThemeStore } from "../../themes/store";
|
|
import { autoBind, cssNames } from "../../utils";
|
|
import { withInjectables } from "@ogre-tools/injectable-react";
|
|
import themeStoreInjectable from "../../themes/store.injectable";
|
|
|
|
const { Menu } = components;
|
|
|
|
export interface SelectOption<Value> {
|
|
value: Value;
|
|
label: React.ReactNode;
|
|
isDisabled?: boolean;
|
|
isSelected?: boolean;
|
|
id?: string;
|
|
}
|
|
|
|
/**
|
|
* @deprecated This should not be used anymore, convert the options yourself.
|
|
*/
|
|
export type LegacyAutoConvertedOptions = string[];
|
|
|
|
export interface SelectProps<
|
|
Value,
|
|
/**
|
|
* This needs to extend `object` because even though `ReactSelectProps` allows for any `T`, the
|
|
* maintainers of `react-select` says that they don't support it.
|
|
*
|
|
* Ref: https://github.com/JedWatson/react-select/issues/5032
|
|
*
|
|
* Futhermore, we mandate the option is of this shape because it is easier than requiring
|
|
* `getOptionValue` and `getOptionLabel` all over the place.
|
|
*/
|
|
Option extends SelectOption<Value>,
|
|
IsMulti extends boolean,
|
|
Group extends GroupBase<Option> = GroupBase<Option>,
|
|
> extends Omit<ReactSelectProps<Option, IsMulti, Group>, "value" | "options"> {
|
|
id?: string; // Optional only because of Extension API. Required to make Select deterministic in unit tests
|
|
themeName?: "dark" | "light" | "outlined" | "lens";
|
|
menuClass?: string;
|
|
value?: PropsValue<Value>;
|
|
options: NonNullable<ReactSelectProps<Option, IsMulti, Group>["options"]> | LegacyAutoConvertedOptions;
|
|
|
|
/**
|
|
* @deprecated This option does nothing
|
|
*/
|
|
isCreatable?: boolean;
|
|
|
|
/**
|
|
* @deprecated We will always auto convert options if they are of type `string`
|
|
*/
|
|
autoConvertOptions?: boolean;
|
|
}
|
|
|
|
function isGroup<Option, Group extends GroupBase<Option>>(optionOrGroup: Option | Group): optionOrGroup is Group {
|
|
return Array.isArray((optionOrGroup as Group).options);
|
|
}
|
|
|
|
const defaultFilter = createFilter({
|
|
stringify(option) {
|
|
if (typeof option.value === "symbol") {
|
|
return option.label;
|
|
}
|
|
|
|
return `${option.label} ${option.value}`;
|
|
},
|
|
});
|
|
|
|
interface Dependencies {
|
|
themeStore: ThemeStore;
|
|
}
|
|
|
|
export function onMultiSelectFor<Value, Option extends SelectOption<Value>, Group extends GroupBase<Option> = GroupBase<Option>>(collection: Set<Value> | ObservableSet<Value>): SelectProps<Value, Option, true, Group>["onChange"] {
|
|
return action((newValue, meta) => {
|
|
switch (meta.action) {
|
|
case "clear":
|
|
collection.clear();
|
|
break;
|
|
case "deselect-option":
|
|
case "remove-value":
|
|
case "pop-value":
|
|
if (meta.option) {
|
|
collection.delete(meta.option.value);
|
|
}
|
|
break;
|
|
case "select-option":
|
|
if (meta.option) {
|
|
collection.add(meta.option.value);
|
|
}
|
|
break;
|
|
}
|
|
});
|
|
}
|
|
|
|
@observer
|
|
class NonInjectedSelect<
|
|
Value,
|
|
Option extends SelectOption<Value>,
|
|
IsMulti extends boolean = false,
|
|
Group extends GroupBase<Option> = GroupBase<Option>,
|
|
> extends React.Component<SelectProps<Value, Option, IsMulti, Group> & Dependencies> {
|
|
static defaultProps = {
|
|
menuPortalTarget: document.body,
|
|
menuPlacement: "auto" as const,
|
|
};
|
|
|
|
constructor(props: SelectProps<Value, Option, IsMulti, Group> & Dependencies) {
|
|
super(props);
|
|
makeObservable(this);
|
|
autoBind(this);
|
|
}
|
|
|
|
@computed get themeClass() {
|
|
const themeName = this.props.themeName || this.props.themeStore.activeTheme.type;
|
|
|
|
return `theme-${themeName}`;
|
|
}
|
|
|
|
onKeyDown(evt: React.KeyboardEvent<HTMLDivElement>) {
|
|
this.props.onKeyDown?.(evt);
|
|
|
|
if (evt.nativeEvent.code === "Escape") {
|
|
evt.stopPropagation(); // don't close the <Dialog/>
|
|
}
|
|
}
|
|
|
|
private filterSelectedMultiValue(values: MultiValue<Value> | null, options: OptionsOrGroups<Option, Group>): MultiValue<Option> | null {
|
|
if (!values) {
|
|
return null;
|
|
}
|
|
|
|
return options
|
|
.flatMap(option => (
|
|
isGroup(option)
|
|
? option.options
|
|
: option
|
|
))
|
|
.filter(option => values.includes(option.value));
|
|
}
|
|
|
|
private findSelectedSingleValue(value: SingleValue<Value>, options: OptionsOrGroups<Option, Group>): SingleValue<Option> {
|
|
if (value === null) {
|
|
return null;
|
|
}
|
|
|
|
for (const optionOrGroup of options) {
|
|
if (isGroup(optionOrGroup)) {
|
|
for (const option of optionOrGroup.options) {
|
|
if (option.value === value) {
|
|
return option;
|
|
}
|
|
}
|
|
} else if (optionOrGroup.value === value) {
|
|
return optionOrGroup;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private findSelectedPropsValue(value: PropsValue<Value>, options: OptionsOrGroups<Option, Group>, isMulti: IsMulti | undefined): PropsValue<Option> {
|
|
if (isMulti) {
|
|
return this.filterSelectedMultiValue(value as MultiValue<Value>, options);
|
|
}
|
|
|
|
return this.findSelectedSingleValue(value as SingleValue<Value>, options);
|
|
}
|
|
|
|
render() {
|
|
const {
|
|
className,
|
|
menuClass,
|
|
components: {
|
|
Menu: WrappedMenu = Menu,
|
|
...components
|
|
} = {},
|
|
styles,
|
|
value = null,
|
|
options,
|
|
isMulti,
|
|
id: inputId,
|
|
onChange,
|
|
...props
|
|
} = this.props;
|
|
|
|
const convertedOptions = options.map(option => (
|
|
typeof option === "string"
|
|
? {
|
|
value: option,
|
|
label: option,
|
|
} as unknown as Option
|
|
: option
|
|
));
|
|
|
|
if (options.length > 0 && !(options?.[0] as { label?: string }).label) {
|
|
console.warn("[SELECT]: will not display any label in dropdown");
|
|
}
|
|
|
|
return (
|
|
<ReactSelect
|
|
{...props}
|
|
styles={{
|
|
menuPortal: styles => ({
|
|
...styles,
|
|
zIndex: "auto",
|
|
}),
|
|
...styles,
|
|
}}
|
|
instanceId={inputId}
|
|
inputId={inputId}
|
|
filterOption={defaultFilter} // This is done because the default filter crashes on symbols
|
|
isMulti={isMulti}
|
|
options={convertedOptions}
|
|
value={this.findSelectedPropsValue(value, convertedOptions, isMulti)}
|
|
onKeyDown={this.onKeyDown}
|
|
className={cssNames("Select", this.themeClass, className)}
|
|
classNamePrefix="Select"
|
|
onChange={action(onChange)} // This is done so that all changes are actionable
|
|
components={{
|
|
...components,
|
|
Menu: ({ className, ...props }) => (
|
|
<WrappedMenu
|
|
{...props}
|
|
className={cssNames(menuClass, this.themeClass, className, {
|
|
[`${inputId}-options`]: !!inputId,
|
|
})}
|
|
/>
|
|
),
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
}
|
|
|
|
export const Select = withInjectables<Dependencies, SelectProps<unknown, SelectOption<unknown>, boolean>>(NonInjectedSelect, {
|
|
getProps: (di, props) => ({
|
|
...props,
|
|
themeStore: di.inject(themeStoreInjectable),
|
|
}),
|
|
}) as <
|
|
Value,
|
|
Option extends SelectOption<Value>,
|
|
IsMulti extends boolean = false,
|
|
Group extends GroupBase<Option> = GroupBase<Option>,
|
|
>(props: SelectProps<Value, Option, IsMulti, Group>) => React.ReactElement;
|