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

more crash fixing

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2022-04-19 09:46:40 -04:00
parent 16dbc63bba
commit 6a93ef0358
38 changed files with 618 additions and 434 deletions

View File

@ -21,6 +21,17 @@ export enum SecretType {
BootstrapToken = "bootstrap.kubernetes.io/token", BootstrapToken = "bootstrap.kubernetes.io/token",
} }
export const reverseSecretTypeMap = {
[SecretType.Opaque]: "Opaque",
[SecretType.ServiceAccountToken]: "ServiceAccountToken",
[SecretType.Dockercfg]: "Dockercfg",
[SecretType.DockerConfigJson]: "DockerConfigJson",
[SecretType.BasicAuth]: "BasicAuth",
[SecretType.SSHAuth]: "SSHAuth",
[SecretType.TLS]: "TLS",
[SecretType.BootstrapToken]: "BootstrapToken",
};
export interface SecretReference { export interface SecretReference {
name: string; name: string;
namespace?: string; namespace?: string;

View File

@ -0,0 +1,24 @@
/**
* 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 { computed } from "mobx";
import namespaceStoreInjectable from "../../renderer/components/+namespaces/store.injectable";
import { createStoresAndApisInjectionToken } from "./create-stores-apis.token";
const selectedFilterNamespacesInjectable = getInjectable({
id: "selected-filter-namespaces",
instantiate: (di) => {
if (!di.inject(createStoresAndApisInjectionToken)) {
// Dummy value so that this works in all environments
return computed(() => []);
}
const store = di.inject(namespaceStoreInjectable);
return computed(() => [...store.contextNamespaces]);
},
});
export default selectedFilterNamespacesInjectable;

View File

@ -49,7 +49,6 @@ import * as tuple from "./tuple";
import * as base64 from "./base64"; import * as base64 from "./base64";
import * as object from "./objects"; import * as object from "./objects";
import * as json from "./json"; import * as json from "./json";
import * as string from "./string";
export { export {
iter, iter,
@ -58,5 +57,4 @@ export {
base64, base64,
object, object,
json, json,
string,
}; };

View File

@ -1,21 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
/**
* Transforms `val` so that the first character is uppercased
*/
export function uppercaseFirst(val: string): string {
if (val.length === 0) {
return "";
}
if (val.length === 1) {
return val.toUpperCase();
}
const [first, ...rest] = val;
return first.toUpperCase() + rest;
}

View File

@ -3,6 +3,8 @@
* 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 type { SetRequired } from "type-fest";
export type RemoveUndefinedFromValues<K> = { export type RemoveUndefinedFromValues<K> = {
[P in keyof K]: NonNullable<K[P]>; [P in keyof K]: NonNullable<K[P]>;
}; };
@ -23,3 +25,7 @@ export type SingleOrMany<T> = T | T[];
export type IfEquals<T, U, Y=unknown, N=never> = export type IfEquals<T, U, Y=unknown, N=never> =
(<G>() => G extends T ? 1 : 2) extends (<G>() => G extends T ? 1 : 2) extends
(<G>() => G extends U ? 1 : 2) ? Y : N; (<G>() => G extends U ? 1 : 2) ? Y : N;
export type MaybeSetRequired<BaseType, Keys extends keyof BaseType, Query> = Query extends true
? SetRequired<BaseType, Keys>
: BaseType;

View File

@ -180,7 +180,7 @@ export class AddQuotaDialog extends React.Component<AddQuotaDialogProps> {
placeholder="Namespace" placeholder="Namespace"
themeName="light" themeName="light"
className="box grow" className="box grow"
onChange={option => this.namespace = option?.namespace ?? null} onChange={option => this.namespace = option?.value ?? null}
/> />
<SubTitle title="Values" /> <SubTitle title="Values" />
@ -190,26 +190,24 @@ export class AddQuotaDialog extends React.Component<AddQuotaDialogProps> {
className="quota-select" className="quota-select"
themeName="light" themeName="light"
placeholder="Select a quota.." placeholder="Select a quota.."
options={Object.keys(this.quotas).map(quota => ({ quota }))} options={Object.keys(this.quotas).map(quota => ({
isMulti={false} value: quota,
value={( label: quota,
this.quotaSelectValue }))}
? ({ quota: this.quotaSelectValue }) value={this.quotaSelectValue}
: null onChange={option => this.quotaSelectValue = option?.value ?? null}
)} formatOptionLabel={({ value }) => {
onChange={option => this.quotaSelectValue = option?.quota ?? null} const iconMaterial = this.getQuotaOptionLabelIconMaterial(value);
formatOptionLabel={({ quota }) => {
const iconMaterial = this.getQuotaOptionLabelIconMaterial(quota);
return iconMaterial return iconMaterial
? ( ? (
<span className="nobr"> <span className="nobr">
<Icon material={iconMaterial} /> <Icon material={iconMaterial} />
{" "} {" "}
{quota} {value}
</span> </span>
) )
: quota; : value;
}} }}
/> />
<Input <Input

View File

@ -13,7 +13,7 @@ import { Dialog } from "../dialog";
import { Wizard, WizardStep } from "../wizard"; import { Wizard, WizardStep } from "../wizard";
import { Input } from "../input"; import { Input } from "../input";
import { systemName } from "../input/input_validators"; import { systemName } from "../input/input_validators";
import { secretApi, SecretType } from "../../../common/k8s-api/endpoints"; import { reverseSecretTypeMap, secretApi, SecretType } from "../../../common/k8s-api/endpoints";
import { SubTitle } from "../layout/sub-title"; import { SubTitle } from "../layout/sub-title";
import { NamespaceSelect } from "../+namespaces/namespace-select"; import { NamespaceSelect } from "../+namespaces/namespace-select";
import { Select } from "../select"; import { Select } from "../select";
@ -72,7 +72,10 @@ export class AddSecretDialog extends React.Component<AddSecretDialogProps> {
}; };
private get secretTypeOptions() { private get secretTypeOptions() {
return object.keys(this.secretTemplate).map(type => ({ type })); return object.keys(this.secretTemplate).map(type => ({
value: type,
label: reverseSecretTypeMap[type],
}));
} }
@observable secret = this.secretTemplate; @observable secret = this.secretTemplate;
@ -224,7 +227,7 @@ export class AddSecretDialog extends React.Component<AddSecretDialogProps> {
id="secret-namespace-input" id="secret-namespace-input"
themeName="light" themeName="light"
value={namespace} value={namespace}
onChange={value => this.namespace = value?.namespace ?? "default"} onChange={option => this.namespace = option?.value ?? "default"}
/> />
</div> </div>
<div className="secret-type"> <div className="secret-type">
@ -233,8 +236,8 @@ export class AddSecretDialog extends React.Component<AddSecretDialogProps> {
id="secret-input" id="secret-input"
themeName="light" themeName="light"
options={this.secretTypeOptions} options={this.secretTypeOptions}
value={({ type })} value={type}
onChange={value => this.type = value?.type ?? SecretType.Opaque} onChange={option => this.type = option?.value ?? SecretType.Opaque}
/> />
</div> </div>
</div> </div>

View File

@ -48,8 +48,17 @@ export class CustomResourceDefinitions extends React.Component {
return customResourceDefinitionStore.items; // show all by default return customResourceDefinitionStore.items; // show all by default
} }
toggleSelection = (options: readonly ({ group: string })[]) => { @computed get groupSelectOptions() {
const groups = options.map(({ group }) => group); return Object.keys(customResourceDefinitionStore.groups)
.map(group => ({
value: group,
label: group,
isSelected: this.selectedGroups.has(group),
}));
}
toggleSelection = (options: readonly ({ value: string })[]) => {
const groups = options.map(({ value }) => value);
this.selectedGroups.replace(groups); this.selectedGroups.replace(groups);
crdGroupsUrlParam.set(groups); crdGroupsUrlParam.set(groups);
@ -68,8 +77,6 @@ export class CustomResourceDefinitions extends React.Component {
} }
render() { render() {
const { items, selectedGroups } = this;
return ( return (
<TabLayout> <TabLayout>
<KubeObjectListLayout <KubeObjectListLayout
@ -79,7 +86,7 @@ export class CustomResourceDefinitions extends React.Component {
store={customResourceDefinitionStore} store={customResourceDefinitionStore}
// Don't subscribe the `customResourceDefinitionStore` because <Sidebar> already has and is always mounted // Don't subscribe the `customResourceDefinitionStore` because <Sidebar> already has and is always mounted
subscribeStores={false} subscribeStores={false}
items={items} items={this.items}
sortingCallbacks={{ sortingCallbacks={{
[columnId.kind]: crd => crd.getResourceKind(), [columnId.kind]: crd => crd.getResourceKind(),
[columnId.group]: crd => crd.getGroup(), [columnId.group]: crd => crd.getGroup(),
@ -103,17 +110,16 @@ export class CustomResourceDefinitions extends React.Component {
<Select <Select
className="group-select" className="group-select"
placeholder={this.getPlaceholder()} placeholder={this.getPlaceholder()}
options={Object.keys(customResourceDefinitionStore.groups).map(group => ({ group }))} options={this.groupSelectOptions}
onChange={this.toggleSelection} onChange={this.toggleSelection}
closeMenuOnSelect={false} closeMenuOnSelect={false}
controlShouldRenderValue={false} controlShouldRenderValue={false}
isOptionSelected={opt => this.selectedGroups.has(opt.group)}
isMulti={true} isMulti={true}
formatOptionLabel={({ group }) => ( formatOptionLabel={({ value, isSelected }) => (
<div className="flex gaps align-center"> <div className="flex gaps align-center">
<Icon small material="folder" /> <Icon small material="folder" />
<span>{group}</span> <span>{value}</span>
{selectedGroups.has(group) && ( {isSelected && (
<Icon <Icon
small small
material="check" material="check"

View File

@ -8,7 +8,7 @@ import "./helm-chart-details.scss";
import React, { Component } from "react"; import React, { Component } from "react";
import type { HelmChart } from "../../../common/k8s-api/endpoints/helm-charts.api"; import type { HelmChart } from "../../../common/k8s-api/endpoints/helm-charts.api";
import { getChartDetails } from "../../../common/k8s-api/endpoints/helm-charts.api"; import { getChartDetails } from "../../../common/k8s-api/endpoints/helm-charts.api";
import { observable, reaction, runInAction } from "mobx"; import { computed, observable, reaction, runInAction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { Drawer, DrawerItem } from "../drawer"; import { Drawer, DrawerItem } from "../drawer";
import { autoBind, stopPropagation } from "../../utils"; import { autoBind, stopPropagation } from "../../utils";
@ -21,6 +21,8 @@ import { Tooltip, withStyles } from "@material-ui/core";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import createInstallChartTabInjectable from "../dock/install-chart/create-install-chart-tab.injectable"; import createInstallChartTabInjectable from "../dock/install-chart/create-install-chart-tab.injectable";
import { Notifications } from "../notifications"; import { Notifications } from "../notifications";
import HelmLogoPlaceholder from "./helm-placeholder.svg";
import type { SingleValue } from "react-select";
export interface HelmChartDetailsProps { export interface HelmChartDetailsProps {
chart: HelmChart; chart: HelmChart;
@ -42,6 +44,12 @@ class NonInjectedHelmChartDetails extends Component<HelmChartDetailsProps & Depe
readonly chartVersions = observable.array<HelmChart>(); readonly chartVersions = observable.array<HelmChart>();
readonly selectedChart = observable.box<HelmChart | undefined>(); readonly selectedChart = observable.box<HelmChart | undefined>();
readonly readme = observable.box<string | undefined>(undefined); readonly readme = observable.box<string | undefined>(undefined);
readonly chartVerionOptions = computed(() => (
this.chartVersions.map(chart => ({
value: chart,
label: chart.version,
}))
));
private abortController = new AbortController(); private abortController = new AbortController();
@ -80,8 +88,8 @@ class NonInjectedHelmChartDetails extends Component<HelmChartDetailsProps & Depe
]); ]);
} }
async onVersionChange(c: HelmChart | null | undefined) { async onVersionChange(option: SingleValue<{ value: HelmChart }>) {
const chart = c ?? this.chartVersions[0]; const chart = option?.value ?? this.chartVersions[0];
runInAction(() => { runInAction(() => {
this.selectedChart.set(chart ?? undefined); this.selectedChart.set(chart ?? undefined);
@ -106,15 +114,12 @@ class NonInjectedHelmChartDetails extends Component<HelmChartDetailsProps & Depe
} }
renderIntroduction(selectedChart: HelmChart) { renderIntroduction(selectedChart: HelmChart) {
const { chartVersions, onVersionChange } = this;
const placeholder = require("./helm-placeholder.svg");
return ( return (
<div className="introduction flex align-flex-start"> <div className="introduction flex align-flex-start">
<img <img
className="intro-logo" className="intro-logo"
src={selectedChart.getIcon() || placeholder} src={selectedChart.getIcon() || HelmLogoPlaceholder}
onError={(event) => event.currentTarget.src = placeholder} onError={(event) => event.currentTarget.src = HelmLogoPlaceholder}
/> />
<div className="intro-contents box grow"> <div className="intro-contents box grow">
<div className="description flex align-center justify-space-between"> <div className="description flex align-center justify-space-between">
@ -134,9 +139,8 @@ class NonInjectedHelmChartDetails extends Component<HelmChartDetailsProps & Depe
id="chart-version-input" id="chart-version-input"
themeName="outlined" themeName="outlined"
menuPortalTarget={null} menuPortalTarget={null}
options={chartVersions.slice()} options={this.chartVerionOptions.get()}
getOptionLabel={chart => chart.version} formatOptionLabel={({ value: chart }) => (
formatOptionLabel={chart => (
chart.deprecated chart.deprecated
? ( ? (
<LargeTooltip title="Deprecated" placement="left"> <LargeTooltip title="Deprecated" placement="left">
@ -145,9 +149,9 @@ class NonInjectedHelmChartDetails extends Component<HelmChartDetailsProps & Depe
) )
: chart.version : chart.version
)} )}
isOptionDisabled={chart => chart.deprecated} isOptionDisabled={({ value: chart }) => chart.deprecated}
value={selectedChart} value={selectedChart}
onChange={onVersionChange} onChange={this.onVersionChange}
/> />
</DrawerItem> </DrawerItem>
<DrawerItem name="Home"> <DrawerItem name="Home">

View File

@ -11,13 +11,13 @@ import { getChartDetails, listCharts } from "../../../common/k8s-api/endpoints/h
import { ItemStore } from "../../../common/item.store"; import { ItemStore } from "../../../common/item.store";
import flatten from "lodash/flatten"; import flatten from "lodash/flatten";
export interface IChartVersion { export interface ChartVersion {
repo: string; repo: string;
version: string; version: string;
} }
export class HelmChartStore extends ItemStore<HelmChart> { export class HelmChartStore extends ItemStore<HelmChart> {
@observable versions = observable.map<string, IChartVersion[]>(); @observable versions = observable.map<string, ChartVersion[]>();
constructor() { constructor() {
super(); super();
@ -53,14 +53,14 @@ export class HelmChartStore extends ItemStore<HelmChart> {
return this.items.find(chart => chart.getName() === name && chart.getRepository() === repo); return this.items.find(chart => chart.getName() === name && chart.getRepository() === repo);
} }
protected sortVersions = (versions: IChartVersion[]) => { protected sortVersions = (versions: ChartVersion[]) => {
return versions return versions
.map(chartVersion => ({ ...chartVersion, __version: semver.coerce(chartVersion.version, { includePrerelease: true, loose: true }) })) .map(chartVersion => ({ ...chartVersion, __version: semver.coerce(chartVersion.version, { includePrerelease: true, loose: true }) }))
.sort(sortCompareChartVersions) .sort(sortCompareChartVersions)
.map(({ __version, ...chartVersion }) => chartVersion); .map(({ __version, ...chartVersion }) => chartVersion);
}; };
async getVersions(chartName: string, force?: boolean): Promise<IChartVersion[]> { async getVersions(chartName: string, force?: boolean): Promise<ChartVersion[]> {
const versions = this.versions.get(chartName); const versions = this.versions.get(chartName);
if (versions && !force) { if (versions && !force) {

View File

@ -7,7 +7,7 @@ import "./dialog.scss";
import React from "react"; import React from "react";
import type { IObservableValue } from "mobx"; import type { IObservableValue } from "mobx";
import { observable, runInAction } from "mobx"; import { computed, observable, runInAction } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import type { DialogProps } from "../../dialog"; import type { DialogProps } from "../../dialog";
import { Dialog } from "../../dialog"; import { Dialog } from "../../dialog";
@ -34,6 +34,12 @@ class NonInjectedReleaseRollbackDialog extends React.Component<ReleaseRollbackDi
readonly isLoading = observable.box(false); readonly isLoading = observable.box(false);
readonly revision = observable.box<HelmReleaseRevision | undefined>(); readonly revision = observable.box<HelmReleaseRevision | undefined>();
readonly revisions = observable.array<HelmReleaseRevision>(); readonly revisions = observable.array<HelmReleaseRevision>();
readonly revisionOptions = computed(() => (
this.revisions.map(revision => ({
value: revision,
label: `${revision.revision} - ${revision.chart} - ${revision.app_version}, updated: ${new Date(revision.updated).toLocaleString()}`,
}))
));
close = () => { close = () => {
this.props.state.set(undefined); this.props.state.set(undefined);
@ -79,9 +85,8 @@ class NonInjectedReleaseRollbackDialog extends React.Component<ReleaseRollbackDi
<Select <Select
themeName="light" themeName="light"
value={revision} value={revision}
options={this.revisions} options={this.revisionOptions.get()}
formatOptionLabel={value => `${value.revision} - ${value.chart} - ${value.app_version}, updated: ${new Date(value.updated).toLocaleString()}`} onChange={option => this.revision.set(option?.value)}
onChange={value => this.revision.set(value ?? undefined)}
/> />
</div> </div>
); );

View File

@ -3,11 +3,12 @@
* 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 React from "react";
import { observable, makeObservable, action, untracked, computed } from "mobx"; import { observable, action, untracked, computed } from "mobx";
import type { NamespaceStore } from "../store"; import type { NamespaceStore } from "../store";
import { isMac } from "../../../../common/vars"; import { isMac } from "../../../../common/vars";
import type { ActionMeta } from "react-select"; import type { ActionMeta } from "react-select";
import { Icon } from "../../icon"; import { Icon } from "../../icon";
import type { SelectOption } from "../../select";
interface Dependencies { interface Dependencies {
readonly namespaceStore: NamespaceStore; readonly namespaceStore: NamespaceStore;
@ -17,19 +18,10 @@ export const selectAllNamespaces = Symbol("all-namespaces-selected");
export type SelectAllNamespaces = typeof selectAllNamespaces; export type SelectAllNamespaces = typeof selectAllNamespaces;
export interface NamespaceSelectFilterOption { export type NamespaceSelectFilterOption = SelectOption<string | SelectAllNamespaces>;
namespace: string | SelectAllNamespaces;
}
export class NamespaceSelectFilterModel { export class NamespaceSelectFilterModel {
constructor(private readonly dependencies: Dependencies) { constructor(private readonly dependencies: Dependencies) {}
makeObservable(this, {
menuIsOpen: observable,
closeMenu: action,
openMenu: action,
reset: action,
});
}
readonly options = computed((): readonly NamespaceSelectFilterOption[] => { readonly options = computed((): readonly NamespaceSelectFilterOption[] => {
const baseOptions = this.dependencies.namespaceStore.items.map(ns => ns.getName()); const baseOptions = this.dependencies.namespaceStore.items.map(ns => ns.getName());
@ -40,22 +32,29 @@ export class NamespaceSelectFilterModel {
- +this.selectedNames.has(left) - +this.selectedNames.has(left)
)); ));
const res = [selectAllNamespaces, ...baseOptions] as const; return [
{
return res.map(namespace => ({ namespace })); value: selectAllNamespaces,
label: "All Namespaces",
isSelected: false,
},
...baseOptions.map(namespace => ({
value: namespace,
label: namespace,
isSelected: this.selectedNames.has(namespace),
})),
];
}); });
formatOptionLabel = ({ namespace }: NamespaceSelectFilterOption) => { formatOptionLabel = ({ value, isSelected }: NamespaceSelectFilterOption) => {
if (namespace === selectAllNamespaces) { if (value === selectAllNamespaces) {
return "All Namespaces"; return "All Namespaces";
} }
const isSelected = this.isSelected(namespace);
return ( return (
<div className="flex gaps align-center"> <div className="flex gaps align-center">
<Icon small material="layers" /> <Icon small material="layers" />
<span>{namespace}</span> <span>{value}</span>
{isSelected && ( {isSelected && (
<Icon <Icon
small small
@ -67,23 +66,15 @@ export class NamespaceSelectFilterModel {
); );
}; };
getOptionLabel = ({ namespace }: NamespaceSelectFilterOption) => { readonly menuIsOpen = observable.box(false);
if (namespace === selectAllNamespaces) {
return "All Namespaces";
}
return namespace; closeMenu = action(() => {
}; this.menuIsOpen.set(false);
});
menuIsOpen = false; openMenu = action(() => {
this.menuIsOpen.set(true);
closeMenu = () => { });
this.menuIsOpen = false;
};
openMenu = () => {
this.menuIsOpen = true;
};
get selectedNames() { get selectedNames() {
return untracked(() => this.dependencies.namespaceStore.selectedNames); return untracked(() => this.dependencies.namespaceStore.selectedNames);
@ -111,13 +102,13 @@ export class NamespaceSelectFilterModel {
} }
break; break;
case "select-option": case "select-option":
if (action.option?.namespace === selectAllNamespaces) { if (action.option?.value === selectAllNamespaces) {
this.dependencies.namespaceStore.selectAll(); this.dependencies.namespaceStore.selectAll();
} else if (action.option) { } else if (action.option) {
if (this.isMultiSelection) { if (this.isMultiSelection) {
this.dependencies.namespaceStore.toggleSingle(action.option.namespace); this.dependencies.namespaceStore.toggleSingle(action.option.value);
} else { } else {
this.dependencies.namespaceStore.selectSingle(action.option.namespace); this.dependencies.namespaceStore.selectSingle(action.option.value);
} }
} }
break; break;
@ -146,10 +137,10 @@ export class NamespaceSelectFilterModel {
} }
}; };
reset = () => { reset = action(() => {
this.isMultiSelection = false; this.isMultiSelection = false;
this.closeMenu(); this.closeMenu();
}; });
} }
const isSelectionKey = (event: React.KeyboardEvent): boolean => { const isSelectionKey = (event: React.KeyboardEvent): boolean => {

View File

@ -31,18 +31,17 @@ const NonInjectedNamespaceSelectFilter = observer(({ model, id }: Dependencies &
onKeyDown={model.onKeyDown} onKeyDown={model.onKeyDown}
onClick={model.onClick} onClick={model.onClick}
> >
<Select<{ namespace: string | SelectAllNamespaces }, true> <Select<string | SelectAllNamespaces, NamespaceSelectFilterOption, true>
id={id} id={id}
isMulti={true} isMulti={true}
isClearable={false} isClearable={false}
menuIsOpen={model.menuIsOpen} menuIsOpen={model.menuIsOpen.get()}
components={{ Placeholder }} components={{ Placeholder }}
closeMenuOnSelect={false} closeMenuOnSelect={false}
controlShouldRenderValue={false} controlShouldRenderValue={false}
onChange={model.onChange} onChange={model.onChange}
onBlur={model.reset} onBlur={model.reset}
formatOptionLabel={model.formatOptionLabel} formatOptionLabel={model.formatOptionLabel}
getOptionLabel={model.getOptionLabel}
options={model.options.get()} options={model.options.get()}
className="NamespaceSelect NamespaceSelectFilter" className="NamespaceSelect NamespaceSelectFilter"
menuClass="NamespaceSelectFilterMenu" menuClass="NamespaceSelectFilterMenu"

View File

@ -18,7 +18,7 @@ import namespaceStoreInjectable from "./store.injectable";
export type NamespaceSelectSort = (left: string, right: string) => number; export type NamespaceSelectSort = (left: string, right: string) => number;
export interface NamespaceSelectProps<IsMulti extends boolean> extends Omit<SelectProps<{ namespace: string }, IsMulti>, "options" | "value"> { export interface NamespaceSelectProps<IsMulti extends boolean> extends Omit<SelectProps<string, { value: string; label: string }, IsMulti>, "options" | "value"> {
showIcons?: boolean; showIcons?: boolean;
sort?: NamespaceSelectSort; sort?: NamespaceSelectSort;
value: string | null | undefined; value: string | null | undefined;
@ -36,7 +36,10 @@ function getOptions(namespaceStore: NamespaceStore, sort: NamespaceSelectSort |
baseOptions.sort(sort); baseOptions.sort(sort);
} }
return baseOptions.map(namespace => ({ namespace })); return baseOptions.map(namespace => ({
value: namespace,
label: namespace,
}));
}); });
} }
@ -46,7 +49,6 @@ const NonInjectedNamespaceSelect = observer(({
formatOptionLabel, formatOptionLabel,
sort, sort,
className, className,
value,
...selectProps ...selectProps
}: Dependencies & NamespaceSelectProps<boolean>) => { }: Dependencies & NamespaceSelectProps<boolean>) => {
const [baseOptions, setBaseOptions] = useState(getOptions(namespaceStore, sort)); const [baseOptions, setBaseOptions] = useState(getOptions(namespaceStore, sort));
@ -58,16 +60,14 @@ const NonInjectedNamespaceSelect = observer(({
className={cssNames("NamespaceSelect", className)} className={cssNames("NamespaceSelect", className)}
menuClass="NamespaceSelectMenu" menuClass="NamespaceSelectMenu"
formatOptionLabel={showIcons formatOptionLabel={showIcons
? ({ namespace }) => ( ? ({ value }) => (
<> <>
<Icon small material="layers" /> <Icon small material="layers" />
{namespace} {value}
</> </>
) )
: undefined : undefined
} }
getOptionLabel={({ namespace }) => namespace}
value={value ? ({ namespace: value }) : null}
options={baseOptions.get()} options={baseOptions.get()}
{...selectProps} {...selectProps}
/> />

View File

@ -31,21 +31,44 @@ interface Dependencies {
themeStore: ThemeStore; themeStore: ThemeStore;
} }
const timezoneOptions = moment.tz.names().map(timezone => ({ timezone })); const timezoneOptions = moment.tz.names()
.map(timezone => ({
value: timezone,
label: timezone.replace("_", " "),
}));
const updateChannelOptions = Array.from(updateChannels, ([channel, { label }]) => ({
value: channel,
label,
}));
const extensionInstallRegistryOptions = [
{
value: "default",
label: "Default Url",
},
{
value: "npmrc",
label: "Global .npmrc file's Url",
},
{
value: "custom",
label: "Custom Url",
},
] as const;
const NonInjectedApplication: React.FC<Dependencies> = ({ appPreferenceItems, userStore, themeStore }) => { const NonInjectedApplication: React.FC<Dependencies> = ({ appPreferenceItems, userStore, themeStore }) => {
const [customUrl, setCustomUrl] = React.useState(userStore.extensionRegistryUrl.customUrl || ""); const [customUrl, setCustomUrl] = React.useState(userStore.extensionRegistryUrl.customUrl || "");
const extensionSettings = appPreferenceItems.get().filter((preference) => preference.showInPreferencesTab === "application"); const extensionSettings = appPreferenceItems.get()
.filter((preference) => preference.showInPreferencesTab === "application");
const themeOptions = [ const themeOptions = [
"system", {
...themeStore.themes.keys(), value: "system", // TODO: replace with a sentinal value that isn't string (and serialize it differently)
].map(theme => ({ theme })); label: "Sync with computer",
const extensionInstallRegistryOptions = ([ },
"default", ...Array.from(themeStore.themes, ([themeId, { name }]) => ({
"npmrc", value: themeId,
"custom", label: name,
] as const).map(registry => ({ registry })); })),
const updateChannelOptions = [...updateChannels.keys()].map(channel => ({ channel })); ];
return ( return (
<Preferences data-testid="application-preferences-page"> <Preferences data-testid="application-preferences-page">
@ -55,9 +78,8 @@ const NonInjectedApplication: React.FC<Dependencies> = ({ appPreferenceItems, us
<SubTitle title="Theme" /> <SubTitle title="Theme" />
<Select <Select
options={themeOptions} options={themeOptions}
getOptionLabel={option => themeStore.themes.get(option.theme)?.name ?? "Sync with computer"} value={userStore.colorTheme}
value={({ theme: userStore.colorTheme })} onChange={value => userStore.colorTheme = value?.value ?? defaultTheme}
onChange={value => userStore.colorTheme = value?.theme ?? defaultTheme}
themeName="lens" themeName="lens"
/> />
</section> </section>
@ -68,19 +90,9 @@ const NonInjectedApplication: React.FC<Dependencies> = ({ appPreferenceItems, us
<SubTitle title="Extension Install Registry" /> <SubTitle title="Extension Install Registry" />
<Select <Select
options={extensionInstallRegistryOptions} options={extensionInstallRegistryOptions}
value={({ registry: userStore.extensionRegistryUrl.location })} value={userStore.extensionRegistryUrl.location}
getOptionLabel={option => {
switch (option.registry) {
case "custom":
return "Custom Url";
case "default":
return "Default Url";
case "npmrc":
return "Global .npmrc file's Url";
}
}}
onChange={value => runInAction(() => { onChange={value => runInAction(() => {
userStore.extensionRegistryUrl.location = value?.registry ?? defaultExtensionRegistryUrlLocation; userStore.extensionRegistryUrl.location = value?.value ?? defaultExtensionRegistryUrlLocation;
if (userStore.extensionRegistryUrl.location === "custom") { if (userStore.extensionRegistryUrl.location === "custom") {
userStore.extensionRegistryUrl.customUrl = ""; userStore.extensionRegistryUrl.customUrl = "";
@ -129,8 +141,8 @@ const NonInjectedApplication: React.FC<Dependencies> = ({ appPreferenceItems, us
<SubTitle title="Update Channel" /> <SubTitle title="Update Channel" />
<Select <Select
options={updateChannelOptions} options={updateChannelOptions}
value={({ channel: userStore.updateChannel })} value={userStore.updateChannel}
onChange={value => userStore.updateChannel = value?.channel ?? defaultUpdateChannel} onChange={value => userStore.updateChannel = value?.value ?? defaultUpdateChannel}
themeName="lens" themeName="lens"
/> />
</section> </section>
@ -141,9 +153,8 @@ const NonInjectedApplication: React.FC<Dependencies> = ({ appPreferenceItems, us
<SubTitle title="Locale Timezone" /> <SubTitle title="Locale Timezone" />
<Select <Select
options={timezoneOptions} options={timezoneOptions}
value={({ timezone: userStore.localeTimezone })} value={userStore.localeTimezone}
getOptionLabel={option => option.timezone} onChange={value => userStore.localeTimezone = value?.value ?? defaultLocaleTimezone}
onChange={value => userStore.localeTimezone = value?.timezone ?? defaultLocaleTimezone}
themeName="lens" themeName="lens"
/> />
</section> </section>

View File

@ -13,23 +13,30 @@ import { Input, InputValidators } from "../input";
import { Preferences } from "./preferences"; import { Preferences } from "./preferences";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import userStoreInjectable from "../../../common/user-store/user-store.injectable"; import userStoreInjectable from "../../../common/user-store/user-store.injectable";
import { string } from "../../utils";
import { defaultEditorConfig } from "../../../common/user-store/preferences-helpers"; import { defaultEditorConfig } from "../../../common/user-store/preferences-helpers";
import { capitalize } from "lodash";
interface Dependencies { interface Dependencies {
userStore: UserStore; userStore: UserStore;
} }
const minimapPositionOptions = (["left", "right"] as const)
.map(side => ({
value: side,
label: side,
}));
const lineNumberOptions = ([
"on",
"off",
"relative",
"interval",
] as const).map(lineNumbers => ({
value: lineNumbers,
label: capitalize(lineNumbers),
}));
const NonInjectedEditor = observer(({ userStore }: Dependencies) => { const NonInjectedEditor = observer(({ userStore }: Dependencies) => {
const editorConfiguration = userStore.editorConfiguration; const editorConfiguration = userStore.editorConfiguration;
const minimapPositionOptions = (["left", "right"] as const)
.map(side => ({ side }));
const lineNumberOptions = ([
"on",
"off",
"relative",
"interval",
] as const).map(lineNumbers => ({ lineNumbers }));
return ( return (
<Preferences data-testid="editor-preferences-page"> <Preferences data-testid="editor-preferences-page">
@ -52,8 +59,8 @@ const NonInjectedEditor = observer(({ userStore }: Dependencies) => {
<Select <Select
themeName="lens" themeName="lens"
options={minimapPositionOptions} options={minimapPositionOptions}
value={editorConfiguration.minimap.side ? ({ side: editorConfiguration.minimap.side }) : null} value={editorConfiguration.minimap.side}
onChange={value => editorConfiguration.minimap.side = value?.side} onChange={option => editorConfiguration.minimap.side = option?.value}
/> />
</div> </div>
</div> </div>
@ -63,9 +70,8 @@ const NonInjectedEditor = observer(({ userStore }: Dependencies) => {
<SubTitle title="Line numbers"/> <SubTitle title="Line numbers"/>
<Select <Select
options={lineNumberOptions} options={lineNumberOptions}
getOptionLabel={option => string.uppercaseFirst(option.lineNumbers)} value={editorConfiguration.lineNumbers}
value={{ lineNumbers: editorConfiguration.lineNumbers }} onChange={option => editorConfiguration.lineNumbers = option?.value ?? defaultEditorConfig.lineNumbers}
onChange={value => editorConfiguration.lineNumbers = value?.lineNumbers ?? defaultEditorConfig.lineNumbers}
themeName="lens" themeName="lens"
/> />
</section> </section>

View File

@ -6,13 +6,14 @@
import styles from "./helm-charts.module.scss"; import styles from "./helm-charts.module.scss";
import React from "react"; import React from "react";
import { observable, makeObservable } from "mobx"; import { observable, makeObservable, computed } from "mobx";
import type { HelmRepo } from "../../../main/helm/helm-repo-manager"; import type { HelmRepo } from "../../../main/helm/helm-repo-manager";
import { HelmRepoManager } from "../../../main/helm/helm-repo-manager"; import { HelmRepoManager } from "../../../main/helm/helm-repo-manager";
import { Button } from "../button"; import { Button } from "../button";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { Notifications } from "../notifications"; import { Notifications } from "../notifications";
import type { SelectOption } from "../select";
import { Select } from "../select"; import { Select } from "../select";
import { AddHelmRepoDialog } from "./add-helm-repo-dialog"; import { AddHelmRepoDialog } from "./add-helm-repo-dialog";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
@ -20,6 +21,7 @@ import { RemovableItem } from "./removable-item";
import { Notice } from "../+extensions/notice"; import { Notice } from "../+extensions/notice";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
import { noop } from "../../utils"; import { noop } from "../../utils";
import type { SingleValue } from "react-select";
@observer @observer
export class HelmCharts extends React.Component { export class HelmCharts extends React.Component {
@ -33,6 +35,14 @@ export class HelmCharts extends React.Component {
makeObservable(this); makeObservable(this);
} }
@computed get repoOptions() {
return this.repos.map(repo => ({
value: repo,
label: repo.name,
isSelected: this.addedRepos.has(repo.name),
}));
}
componentDidMount() { componentDidMount() {
this.loadAvailableRepos().catch(noop); this.loadAvailableRepos().catch(noop);
this.loadRepos().catch(noop); this.loadRepos().catch(noop);
@ -98,40 +108,35 @@ export class HelmCharts extends React.Component {
} }
} }
onRepoSelect = async (repo: HelmRepo | null): Promise<void> => { onRepoSelect = async (option: SingleValue<{ value: HelmRepo }>): Promise<void> => {
if (!repo) { if (!option) {
return; return;
} }
if (this.addedRepos.has(repo.name)) { if (this.addedRepos.has(option.value.name)) {
return void Notifications.ok(( return void Notifications.ok((
<> <>
{"Helm repo "} {"Helm repo "}
<b>{repo.name}</b> <b>{option.value.name}</b>
{" already in use."} {" already in use."}
</> </>
)); ));
} }
await this.addRepo(repo); await this.addRepo(option.value);
}; };
formatOptionLabel = (repo: HelmRepo) => { formatOptionLabel = ({ value, isSelected }: SelectOption<HelmRepo>) => (
const isAdded = this.addedRepos.has(repo.name); <div className="flex gaps">
<span>{value.name}</span>
return ( {isSelected && (
<div className="flex gaps"> <Icon
<span>{repo.name}</span> small
{isAdded && ( material="check"
<Icon className="box right" />
small )}
material="check" </div>
className="box right" );
/>
)}
</div>
);
};
renderRepositories() { renderRepositories() {
const repos = Array.from(this.addedRepos); const repos = Array.from(this.addedRepos);
@ -173,10 +178,10 @@ export class HelmCharts extends React.Component {
placeholder="Repositories" placeholder="Repositories"
isLoading={this.loadingAvailableRepos} isLoading={this.loadingAvailableRepos}
isDisabled={this.loadingAvailableRepos} isDisabled={this.loadingAvailableRepos}
options={this.repos} options={this.repoOptions}
onChange={this.onRepoSelect} onChange={this.onRepoSelect}
value={this.repos}
formatOptionLabel={this.formatOptionLabel} formatOptionLabel={this.formatOptionLabel}
getOptionLabel={repo => repo.name}
controlShouldRenderValue={false} controlShouldRenderValue={false}
className="box grow" className="box grow"
themeName="lens" themeName="lens"

View File

@ -21,6 +21,11 @@ interface Dependencies {
defaultPathForKubectlBinaries: string; defaultPathForKubectlBinaries: string;
userStore: UserStore; userStore: UserStore;
} }
const downloadMirrorOptions = Array.from(packageMirrors, ([name, mirror]) => ({
value: name,
label: mirror.label,
isDisabled: !mirror.platforms.has(process.platform),
}));
const NonInjectedKubectlBinaries= observer(({ const NonInjectedKubectlBinaries= observer(({
defaultPathForGeneralBinaries, defaultPathForGeneralBinaries,
@ -30,7 +35,6 @@ const NonInjectedKubectlBinaries= observer(({
const [downloadPath, setDownloadPath] = useState(userStore.downloadBinariesPath || ""); const [downloadPath, setDownloadPath] = useState(userStore.downloadBinariesPath || "");
const [binariesPath, setBinariesPath] = useState(userStore.kubectlBinariesPath || ""); const [binariesPath, setBinariesPath] = useState(userStore.kubectlBinariesPath || "");
const pathValidator = downloadPath ? InputValidators.isPath : undefined; const pathValidator = downloadPath ? InputValidators.isPath : undefined;
const downloadMirrorOptions = [...packageMirrors].map(([name, mirror]) => ({ name, mirror }));
const save = () => { const save = () => {
userStore.downloadBinariesPath = downloadPath; userStore.downloadBinariesPath = downloadPath;
@ -54,11 +58,9 @@ const NonInjectedKubectlBinaries= observer(({
<Select <Select
placeholder="Download mirror for kubectl" placeholder="Download mirror for kubectl"
options={downloadMirrorOptions} options={downloadMirrorOptions}
value={downloadMirrorOptions.find(opt => opt.name === userStore.downloadMirror)} value={userStore.downloadMirror}
onChange={option => userStore.downloadMirror = option?.name ?? defaultPackageMirror} onChange={option => userStore.downloadMirror = option?.value ?? defaultPackageMirror}
getOptionLabel={option => option.mirror.label}
isDisabled={!userStore.downloadKubectlBinaries} isDisabled={!userStore.downloadKubectlBinaries}
isOptionDisabled={option => option.mirror.platforms.has(process.platform)}
themeName="lens" themeName="lens"
/> />
</section> </section>

View File

@ -23,11 +23,21 @@ interface Dependencies {
defaultShell: string; defaultShell: string;
} }
const NonInjectedTerminal = observer(({ userStore, themeStore, defaultShell }: Dependencies) => { const NonInjectedTerminal = observer(({
userStore,
themeStore,
defaultShell,
}: Dependencies) => {
const themeOptions = [ const themeOptions = [
"", {
...themeStore.themes.keys(), value: "", // TODO: replace with a sentinal value that isn't string (and serialize it differently)
].map(name => ({ name })); label: "Match Lens Theme",
},
...Array.from(themeStore.themes, ([themeId, { name }]) => ({
value: themeId,
label: name,
})),
];
return ( return (
<Preferences data-testid="terminal-preferences-page"> <Preferences data-testid="terminal-preferences-page">
@ -59,17 +69,8 @@ const NonInjectedTerminal = observer(({ userStore, themeStore, defaultShell }: D
<Select <Select
themeName="lens" themeName="lens"
options={themeOptions} options={themeOptions}
getOptionLabel={option => { value={userStore.terminalTheme}
const theme = themeStore.themes.get(option.name); onChange={option => userStore.terminalTheme = option?.value ?? ""}
if (theme) {
return theme.name;
}
return "Match System Theme";
}}
value={{ name: userStore.terminalTheme }}
onChange={option => userStore.terminalTheme = option?.name ?? ""}
/> />
</section> </section>

View File

@ -63,6 +63,21 @@ export class ClusterRoleBindingDialog extends React.Component<ClusterRoleBinding
dialogState.isOpen = false; dialogState.isOpen = false;
} }
@computed get clusterRoleOptions() {
return clusterRoleStore.items.map(clusterRole => ({
value: clusterRole,
label: clusterRole.getName(),
}));
}
@computed get serviceAccountOptions() {
return serviceAccountStore.items.map(serviceAccount => ({
value: serviceAccount,
label: `${serviceAccount.getName()} (${serviceAccount.getNs()})`,
isSelected: this.selectedAccounts.has(serviceAccount),
}));
}
get clusterRoleBinding() { get clusterRoleBinding() {
return dialogState.data; return dialogState.data;
} }
@ -164,29 +179,28 @@ export class ClusterRoleBindingDialog extends React.Component<ClusterRoleBinding
themeName="light" themeName="light"
placeholder="Select cluster role ..." placeholder="Select cluster role ..."
isDisabled={this.isEditing} isDisabled={this.isEditing}
options={clusterRoleStore.items.slice()} options={this.clusterRoleOptions}
value={this.selectedRoleRef} value={this.selectedRoleRef}
autoFocus={!this.isEditing} autoFocus={!this.isEditing}
formatOptionLabel={value => ( formatOptionLabel={option => (
<> <>
<Icon <Icon
small small
material={value.kind === "Role" ? "person" : "people"} material="people"
tooltip={{ tooltip={{
preferredPositions: TooltipPosition.LEFT, preferredPositions: TooltipPosition.LEFT,
children: value.kind, children: option.value.kind,
}} }}
/> />
{" "} {" "}
{value.getName()} {option.value.getName()}
</> </>
)} )}
getOptionLabel={value => value.getName()} onChange={option => {
onChange={value => { this.selectedRoleRef = option?.value;
this.selectedRoleRef = value ?? undefined;
if (!this.selectedRoleRef || this.bindingName === this.selectedRoleRef.getName()) { if (!this.selectedRoleRef || this.bindingName === this.selectedRoleRef.getName()) {
this.bindingName = value?.getName() ?? ""; this.bindingName = option?.value?.getName() ?? "";
} }
}} }}
/> />
@ -223,16 +237,31 @@ export class ClusterRoleBindingDialog extends React.Component<ClusterRoleBinding
isMulti isMulti
themeName="light" themeName="light"
placeholder="Select service accounts ..." placeholder="Select service accounts ..."
options={serviceAccountStore.items.slice()} options={this.serviceAccountOptions}
formatOptionLabel={value => ( formatOptionLabel={option => (
<> <>
<Icon small material="account_box" /> <Icon small material="account_box" />
{` ${value.getName()} (${value.getNs()})`} {` ${option.label}`}
</> </>
)} )}
getOptionLabel={value => `${value.getName()} (${value.getNs()})`} onChange={(selected, meta) => {
onChange={selected => { switch (meta.action) {
this.selectedAccounts.replace(selected); case "clear":
this.selectedAccounts.clear();
break;
case "deselect-option":
case "remove-value":
case "pop-value":
if (meta.option) {
this.selectedAccounts.delete(meta.option.value);
}
break;
case "select-option":
if (meta.option) {
this.selectedAccounts.add(meta.option.value);
}
break;
}
}} }}
maxMenuHeight={200} maxMenuHeight={200}
/> />

View File

@ -21,6 +21,7 @@ import { Icon } from "../../icon";
import { showDetails } from "../../kube-detail-params"; import { showDetails } from "../../kube-detail-params";
import { SubTitle } from "../../layout/sub-title"; import { SubTitle } from "../../layout/sub-title";
import { Notifications } from "../../notifications"; import { Notifications } from "../../notifications";
import type { SelectOption } from "../../select";
import { Select } from "../../select"; import { Select } from "../../select";
import { Wizard, WizardStep } from "../../wizard"; import { Wizard, WizardStep } from "../../wizard";
import { roleBindingStore } from "./legacy-store"; import { roleBindingStore } from "./legacy-store";
@ -95,7 +96,7 @@ export class RoleBindingDialog extends React.Component<RoleBindingDialogProps> {
]; ];
} }
@computed get roleRefOptions(): (Role | ClusterRole)[] { @computed get roleRefOptions(): SelectOption<Role | ClusterRole>[] {
const roles = roleStore.items const roles = roleStore.items
.filter(role => role.getNs() === this.bindingNamespace); .filter(role => role.getNs() === this.bindingNamespace);
const clusterRoles = clusterRoleStore.items; const clusterRoles = clusterRoleStore.items;
@ -103,7 +104,18 @@ export class RoleBindingDialog extends React.Component<RoleBindingDialogProps> {
return [ return [
...roles, ...roles,
...clusterRoles, ...clusterRoles,
]; ].map(r => ({
value: r,
label: r.getName(),
}));
}
@computed get serviceAccountOptions(): SelectOption<ServiceAccount>[] {
return serviceAccountStore.items.map(serviceAccount => ({
value: serviceAccount,
label: `${serviceAccount.getName()} (${serviceAccount.getNs()})`,
isSelected: this.selectedAccounts.has(serviceAccount),
}));
} }
onOpen = action(() => { onOpen = action(() => {
@ -182,7 +194,7 @@ export class RoleBindingDialog extends React.Component<RoleBindingDialogProps> {
isDisabled={this.isEditing} isDisabled={this.isEditing}
value={this.bindingNamespace} value={this.bindingNamespace}
autoFocus={!this.isEditing} autoFocus={!this.isEditing}
onChange={opt => this.bindingNamespace = opt ? opt.namespace : null} onChange={opt => this.bindingNamespace = opt?.value ?? null}
/> />
<SubTitle title="Role Reference" /> <SubTitle title="Role Reference" />
@ -193,12 +205,11 @@ export class RoleBindingDialog extends React.Component<RoleBindingDialogProps> {
isDisabled={this.isEditing} isDisabled={this.isEditing}
options={this.roleRefOptions} options={this.roleRefOptions}
value={this.selectedRoleRef} value={this.selectedRoleRef}
getOptionLabel={ref => ref.getName()} onChange={option => {
onChange={roleRef => { this.selectedRoleRef = option?.value;
this.selectedRoleRef = roleRef;
if (!this.selectedRoleRef || this.bindingName === this.selectedRoleRef.getName()) { if (!this.selectedRoleRef || this.bindingName === this.selectedRoleRef.getName()) {
this.bindingName = roleRef?.getName() ?? ""; this.bindingName = option?.value.getName() ?? "";
} }
}} }}
/> />
@ -234,16 +245,32 @@ export class RoleBindingDialog extends React.Component<RoleBindingDialogProps> {
isMulti isMulti
themeName="light" themeName="light"
placeholder="Select service accounts ..." placeholder="Select service accounts ..."
options={serviceAccountStore.items} options={this.serviceAccountOptions}
isOptionSelected={account => this.selectedAccounts.has(account)} formatOptionLabel={option => (
formatOptionLabel={account => (
<> <>
<Icon small material="account_box" /> <Icon small material="account_box" />
{` ${account.getName()} (${account.getNs()})`} {` ${option.label}`}
</> </>
)} )}
getOptionLabel={account => `${account.getName()} (${account.getNs()})`} onChange={(selected, meta) => {
onChange={selected => this.selectedAccounts.replace(selected)} switch (meta.action) {
case "clear":
this.selectedAccounts.clear();
break;
case "deselect-option":
case "remove-value":
case "pop-value":
if (meta.option) {
this.selectedAccounts.delete(meta.option.value);
}
break;
case "select-option":
if (meta.option) {
this.selectedAccounts.add(meta.option.value);
}
break;
}
}}
maxMenuHeight={200} maxMenuHeight={200}
/> />
</> </>

View File

@ -90,7 +90,7 @@ export class AddRoleDialog extends React.Component<AddRoleDialogProps> {
id="add-dialog-namespace-select-input" id="add-dialog-namespace-select-input"
themeName="light" themeName="light"
value={this.namespace} value={this.namespace}
onChange={option => this.namespace = option?.namespace ?? "default"} onChange={option => this.namespace = option?.value ?? "default"}
/> />
</WizardStep> </WizardStep>
</Wizard> </Wizard>

View File

@ -86,7 +86,7 @@ export class CreateServiceAccountDialog extends React.Component<CreateServiceAcc
id="create-dialog-namespace-select-input" id="create-dialog-namespace-select-input"
themeName="light" themeName="light"
value={namespace} value={namespace}
onChange={option => this.namespace = option?.namespace ?? "default"} onChange={option => this.namespace = option?.value ?? "default"}
/> />
</WizardStep> </WizardStep>
</Wizard> </Wizard>

View File

@ -20,29 +20,30 @@ interface Dependencies {
entities: IComputedValue<CatalogEntity[]>; entities: IComputedValue<CatalogEntity[]>;
} }
const NonInjectedActivateEntityCommand = observer(({ closeCommandOverlay, entities }: Dependencies) => { const NonInjectedActivateEntityCommand = observer(({ closeCommandOverlay, entities }: Dependencies) => (
const onSelect = (entity: CatalogEntity | null): void => { <Select
if (entity) { id="activate-entity-input"
broadcastMessage(catalogEntityRunListener, entity.getId()); menuPortalTarget={null}
closeCommandOverlay(); onChange={(option) => {
} if (option) {
}; broadcastMessage(catalogEntityRunListener, option.value.getId());
closeCommandOverlay();
return ( }
<Select }}
id="activate-entity-input" components={{ DropdownIndicator: null, IndicatorSeparator: null }}
menuPortalTarget={null} menuIsOpen={true}
onChange={onSelect} options={(
components={{ DropdownIndicator: null, IndicatorSeparator: null }} entities.get()
menuIsOpen={true} .map(entity => ({
options={entities.get()} value: entity,
getOptionLabel={entity => `${entity.kind}: ${entity.getName()}`} label: `${entity.kind}: ${entity.getName()}`,
autoFocus={true} }))
escapeClearsValue={false} )}
placeholder="Activate entity ..." autoFocus={true}
/> escapeClearsValue={false}
); placeholder="Activate entity ..."
}); />
));
export const ActivateEntityCommand = withInjectables<Dependencies>(NonInjectedActivateEntityCommand, { export const ActivateEntityCommand = withInjectables<Dependencies>(NonInjectedActivateEntityCommand, {
getProps: di => ({ getProps: di => ({

View File

@ -10,7 +10,7 @@ import { Icon } from "../../icon/icon";
import { Button } from "../../button/button"; import { Button } from "../../button/button";
import { SubTitle } from "../../layout/sub-title"; import { SubTitle } from "../../layout/sub-title";
import type { Cluster } from "../../../../common/cluster/cluster"; import type { Cluster } from "../../../../common/cluster/cluster";
import { observable, reaction, makeObservable, runInAction } from "mobx"; import { observable, reaction, makeObservable } from "mobx";
import { ClusterMetricsResourceType } from "../../../../common/cluster-types"; import { ClusterMetricsResourceType } from "../../../../common/cluster-types";
export interface ClusterMetricsSettingProps { export interface ClusterMetricsSettingProps {
@ -52,7 +52,11 @@ export class ClusterMetricsSetting extends React.Component<ClusterMetricsSetting
renderMetricsSelect() { renderMetricsSelect() {
const metricResourceTypeOptions = Object.values(ClusterMetricsResourceType) const metricResourceTypeOptions = Object.values(ClusterMetricsResourceType)
.map(type => ({ type })); .map(type => ({
value: type,
label: type,
isSelected: this.hiddenMetrics.has(type),
}));
return ( return (
<> <>
@ -67,16 +71,13 @@ export class ClusterMetricsSetting extends React.Component<ClusterMetricsSetting
controlShouldRenderValue={false} controlShouldRenderValue={false}
options={metricResourceTypeOptions} options={metricResourceTypeOptions}
onChange={(options) => { onChange={(options) => {
runInAction(() => { this.hiddenMetrics.replace(options.map(opt => opt.value));
this.hiddenMetrics.replace(options.map(opt => opt.type)); this.save();
this.save();
});
}} }}
getOptionLabel={option => option.type}
formatOptionLabel={(option) => ( formatOptionLabel={(option) => (
<div className="flex gaps align-center"> <div className="flex gaps align-center">
<span>{option.type}</span> <span>{option.value}</span>
{this.hiddenMetrics.has(option.type) && ( {option.isSelected && (
<Icon <Icon
smallest smallest
material="check" material="check"

View File

@ -7,6 +7,7 @@ import React from "react";
import { observer, disposeOnUnmount } from "mobx-react"; import { observer, disposeOnUnmount } from "mobx-react";
import type { Cluster } from "../../../../common/cluster/cluster"; import type { Cluster } from "../../../../common/cluster/cluster";
import { SubTitle } from "../../layout/sub-title"; import { SubTitle } from "../../layout/sub-title";
import type { SelectOption } from "../../select";
import { Select } from "../../select"; import { Select } from "../../select";
import { Input } from "../../input"; import { Input } from "../../input";
import { observable, computed, autorun, makeObservable } from "mobx"; import { observable, computed, autorun, makeObservable } from "mobx";
@ -14,7 +15,6 @@ import { productName } from "../../../../common/vars";
import type { MetricProviderInfo } from "../../../../common/k8s-api/endpoints/metrics.api"; import type { MetricProviderInfo } from "../../../../common/k8s-api/endpoints/metrics.api";
import { metricsApi } from "../../../../common/k8s-api/endpoints/metrics.api"; import { metricsApi } from "../../../../common/k8s-api/endpoints/metrics.api";
import { Spinner } from "../../spinner"; import { Spinner } from "../../spinner";
import type { GroupBase } from "react-select";
export interface ClusterPrometheusSettingProps { export interface ClusterPrometheusSettingProps {
cluster: Cluster; cluster: Cluster;
@ -22,23 +22,28 @@ export interface ClusterPrometheusSettingProps {
const autoDetectPrometheus = Symbol("auto-detect-prometheus"); const autoDetectPrometheus = Symbol("auto-detect-prometheus");
interface ProviderOption { type ProviderValue = typeof autoDetectPrometheus | string;
provider: typeof autoDetectPrometheus | string;
}
@observer @observer
export class ClusterPrometheusSetting extends React.Component<ClusterPrometheusSettingProps> { export class ClusterPrometheusSetting extends React.Component<ClusterPrometheusSettingProps> {
@observable path = ""; @observable path = "";
@observable selectedOption = this.options.find(opt => opt.provider === autoDetectPrometheus); @observable selectedOption: ProviderValue = autoDetectPrometheus;
@observable loading = true; @observable loading = true;
loadedOptions = observable.map<string, MetricProviderInfo>(); readonly loadedOptions = observable.map<string, MetricProviderInfo>();
@computed get options(): ProviderOption[] { @computed get options(): SelectOption<ProviderValue>[] {
return ([ return [
autoDetectPrometheus, {
...this.loadedOptions.keys(), value: autoDetectPrometheus,
] as const) label: "Auto Detect Prometheus",
.map(provider => ({ provider })); isSelected: autoDetectPrometheus === this.selectedOption,
},
...Array.from(this.loadedOptions, ([id, provider]) => ({
value: id,
label: provider.name,
isSelected: id === this.selectedOption,
})),
];
} }
constructor(props: ClusterPrometheusSettingProps) { constructor(props: ClusterPrometheusSettingProps) {
@ -47,11 +52,11 @@ export class ClusterPrometheusSetting extends React.Component<ClusterPrometheusS
} }
@computed get canEditPrometheusPath(): boolean { @computed get canEditPrometheusPath(): boolean {
if (!this.selectedOption || this.selectedOption.provider === autoDetectPrometheus) { if (!this.selectedOption || this.selectedOption === autoDetectPrometheus) {
return false; return false;
} }
return this.loadedOptions.get(this.selectedOption.provider)?.isConfigurable ?? false; return this.loadedOptions.get(this.selectedOption)?.isConfigurable ?? false;
} }
componentDidMount() { componentDidMount() {
@ -68,9 +73,9 @@ export class ClusterPrometheusSetting extends React.Component<ClusterPrometheusS
} }
if (prometheusProvider) { if (prometheusProvider) {
this.selectedOption = this.options.find(opt => opt.provider === prometheusProvider.type); this.selectedOption = this.options.find(opt => opt.value === prometheusProvider.type)?.value ?? autoDetectPrometheus;
} else { } else {
this.selectedOption = undefined; this.selectedOption = autoDetectPrometheus;
} }
}), }),
); );
@ -122,11 +127,11 @@ export class ClusterPrometheusSetting extends React.Component<ClusterPrometheusS
? <Spinner /> ? <Spinner />
: ( : (
<> <>
<Select<ProviderOption, false, GroupBase<ProviderOption>> <Select
id="cluster-prometheus-settings-input" id="cluster-prometheus-settings-input"
value={this.selectedOption} value={this.selectedOption}
onChange={provider => { onChange={option => {
this.selectedOption = provider ?? { provider: autoDetectPrometheus }; this.selectedOption = option?.value ?? autoDetectPrometheus;
this.onSaveProvider(); this.onSaveProvider();
}} }}
options={this.options} options={this.options}

View File

@ -36,7 +36,7 @@ const NonInjectedCommandDialog = observer(({ commands, activeEntity, closeComman
try { try {
closeCommandOverlay(); closeCommandOverlay();
option.action({ option.value.action({
entity: activeEntity, entity: activeEntity,
navigate: (url, opts = {}) => { navigate: (url, opts = {}) => {
const { forceRootFrame = false } = opts; const { forceRootFrame = false } = opts;
@ -49,7 +49,7 @@ const NonInjectedCommandDialog = observer(({ commands, activeEntity, closeComman
}, },
}); });
} catch (error) { } catch (error) {
console.error("[COMMAND-DIALOG] failed to execute command", option.id, error); console.error("[COMMAND-DIALOG] failed to execute command", option.value.id, error);
} }
}; };
@ -65,6 +65,12 @@ const NonInjectedCommandDialog = observer(({ commands, activeEntity, closeComman
return void console.error(`[COMMAND-DIALOG]: isActive for ${command.id} threw an error, defaulting to false`, error); return void console.error(`[COMMAND-DIALOG]: isActive for ${command.id} threw an error, defaulting to false`, error);
} }
}) })
.map(command => ({
value: command,
label: typeof command.title === "string"
? command.title
: command.title(context),
}))
.collect(items => Array.from(items)); .collect(items => Array.from(items));
return ( return (
@ -78,11 +84,6 @@ const NonInjectedCommandDialog = observer(({ commands, activeEntity, closeComman
}} }}
menuIsOpen menuIsOpen
options={activeCommands} options={activeCommands}
getOptionLabel={({ title }) => (
typeof title === "string"
? title
: title(context)
)}
autoFocus={true} autoFocus={true}
escapeClearsValue={false} escapeClearsValue={false}
data-test-id="command-palette-search" data-test-id="command-palette-search"

View File

@ -99,22 +99,23 @@ class NonInjectedDeleteClusterDialog extends React.Component<Dependencies> {
return null; return null;
} }
const contextName = this.newCurrentContext.get();
const selectOptions = config const selectOptions = config
.contexts .contexts
.filter(context => context.name !== cluster.contextName); .filter(context => context.name !== cluster.contextName)
const selectedOption = selectOptions.find(ctx => ctx.name === contextName); .map(context => ({
value: context.name,
label: context.name,
}));
return ( return (
<div className="mt-4"> <div className="mt-4">
<Select <Select
id="delete-cluster-input" id="delete-cluster-input"
options={selectOptions} options={selectOptions}
getOptionLabel={opt => opt.name} value={this.newCurrentContext.get()}
value={selectedOption}
onChange={opt => { onChange={opt => {
if (opt) { if (opt) {
this.newCurrentContext.set(opt.name); this.newCurrentContext.set(opt.value);
} }
}} }}
themeName="light" themeName="light"

View File

@ -4,6 +4,7 @@
*/ */
import React from "react"; import React from "react";
import type { SelectOption } from "../../select";
import { Select } from "../../select"; import { Select } from "../../select";
import yaml from "js-yaml"; import yaml from "js-yaml";
import type { IComputedValue } from "mobx"; import type { IComputedValue } from "mobx";
@ -107,7 +108,7 @@ class NonInjectedCreateResource extends React.Component<CreateResourceProps & De
renderControls() { renderControls() {
return ( return (
<div className="flex gaps align-center"> <div className="flex gaps align-center">
<Select<{ label: string; value: string }, false> <Select<string, SelectOption<string>, false>
id="create-resource-resource-templates-input" id="create-resource-resource-templates-input"
controlShouldRenderValue={false} // always keep initial placeholder controlShouldRenderValue={false} // always keep initial placeholder
className="TemplateSelect" className="TemplateSelect"
@ -116,9 +117,9 @@ class NonInjectedCreateResource extends React.Component<CreateResourceProps & De
formatGroupLabel={group => group.label} formatGroupLabel={group => group.label}
menuPlacement="top" menuPlacement="top"
themeName="outlined" themeName="outlined"
onChange={(item) => { onChange={(option) => {
if (item) { if (option) {
this.props.createResourceTabStore.setData(this.tabId, item.value); this.props.createResourceTabStore.setData(this.tabId, option.value);
} }
}} }}
/> />

View File

@ -18,6 +18,7 @@ import { Spinner } from "../../spinner";
import { Icon } from "../../icon"; import { Icon } from "../../icon";
import { Button } from "../../button"; import { Button } from "../../button";
import { LogsDialog } from "../../dialog/logs-dialog"; import { LogsDialog } from "../../dialog/logs-dialog";
import type { SelectOption } from "../../select";
import { Select } from "../../select"; import { Select } from "../../select";
import { Input } from "../../input"; import { Input } from "../../input";
import { EditorPanel } from "../editor-panel"; import { EditorPanel } from "../editor-panel";
@ -88,7 +89,7 @@ class NonInjectedInstallChart extends Component<InstallCharProps & Dependencies>
this.props.installChartStore.setData(this.tabId, { ...this.chartData, ...data }); this.props.installChartStore.setData(this.tabId, { ...this.chartData, ...data });
} }
onVersionChange = (option: SingleValue<{ version: string }>) => { onVersionChange = (option: SingleValue<SelectOption<string>>) => {
if (option) { if (option) {
this.save({ ...option, values: "" }); this.save({ ...option, values: "" });
this.props.installChartStore.loadValues(this.tabId); this.props.installChartStore.loadValues(this.tabId);
@ -104,9 +105,9 @@ class NonInjectedInstallChart extends Component<InstallCharProps & Dependencies>
this.error = error.toString(); this.error = error.toString();
}); });
onNamespaceChange = (option: SingleValue<{ namespace: string }>) => { onNamespaceChange = (option: SingleValue<SelectOption<string>>) => {
if (option) { if (option) {
this.save(option); this.save({ namespace: option.value });
} }
}; };
@ -179,8 +180,10 @@ class NonInjectedInstallChart extends Component<InstallCharProps & Dependencies>
} }
const { repo, name, version, namespace, releaseName } = chartData; const { repo, name, version, namespace, releaseName } = chartData;
const versionOptions = versions.map(version => ({ version })); const versionOptions = versions.map(version => ({
const selectedVersionOption = versionOptions.find(opt => opt.version === version); value: version,
label: version,
}));
return ( return (
<div className="InstallChart flex column"> <div className="InstallChart flex column">
@ -193,7 +196,7 @@ class NonInjectedInstallChart extends Component<InstallCharProps & Dependencies>
<span>Version</span> <span>Version</span>
<Select <Select
className="chart-version" className="chart-version"
value={selectedVersionOption} value={version}
options={versionOptions} options={versionOptions}
onChange={this.onVersionChange} onChange={this.onVersionChange}
menuPlacement="top" menuPlacement="top"

View File

@ -9,10 +9,11 @@ import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Badge } from "../../badge"; import { Badge } from "../../badge";
import type { SelectOption } from "../../select";
import { Select } from "../../select"; import { Select } from "../../select";
import type { LogTabViewModel } from "./logs-view-model"; import type { LogTabViewModel } from "./logs-view-model";
import type { PodContainer, Pod } from "../../../../common/k8s-api/endpoints"; import type { PodContainer, Pod } from "../../../../common/k8s-api/endpoints";
import type { GroupBase } from "react-select"; import type { SingleValue } from "react-select";
export interface LogResourceSelectorProps { export interface LogResourceSelectorProps {
model: LogTabViewModel; model: LogTabViewModel;
@ -33,40 +34,50 @@ export const LogResourceSelector = observer(({ model }: LogResourceSelectorProps
return null; return null;
} }
const podOptions = pods.map(pod => ({
value: pod,
label: pod.getName(),
}));
const allContainers = pod.getAllContainers(); const allContainers = pod.getAllContainers();
const container = allContainers.find(container => container.name === selectedContainer) ?? null; const container = allContainers.find(container => container.name === selectedContainer) ?? null;
const onContainerChange = (container: PodContainer | null) => { const onContainerChange = (option: SingleValue<SelectOption<PodContainer>>) => {
if (!container) { if (!option) {
return; return;
} }
model.updateLogTabData({ model.updateLogTabData({
selectedContainer: container.name, selectedContainer: option.value.name,
}); });
model.reloadLogs(); model.reloadLogs();
}; };
const onPodChange = (value: Pod | null) => { const onPodChange = (option: SingleValue<SelectOption<Pod>>) => {
if (!value) { if (!option) {
return; return;
} }
model.updateLogTabData({ model.updateLogTabData({
selectedPodId: value.getId(), selectedPodId: option.value.getId(),
selectedContainer: value.getAllContainers()[0]?.name, selectedContainer: option.value.getAllContainers()[0]?.name,
}); });
model.renameTab(`Pod ${value.getName()}`); model.renameTab(`Pod ${option.value.getName()}`);
model.reloadLogs(); model.reloadLogs();
}; };
const containerSelectOptions = [ const containerSelectOptions = [
{ {
label: "Containers", label: "Containers",
options: pod.getContainers(), options: pod.getContainers().map(container => ({
value: container,
label: container.name,
})),
}, },
{ {
label: "Init Containers", label: "Init Containers",
options: pod.getInitContainers(), options: pod.getInitContainers().map(container => ({
value: container,
label: container.name,
})),
}, },
]; ];
@ -86,21 +97,19 @@ export const LogResourceSelector = observer(({ model }: LogResourceSelectorProps
} }
<span>Pod</span> <span>Pod</span>
<Select <Select
options={pods} options={podOptions}
value={pod} value={pod}
isClearable={false} isClearable={false}
formatOptionLabel={option => option.getName()}
onChange={onPodChange} onChange={onPodChange}
className="pod-selector" className="pod-selector"
menuClass="pod-selector-menu" menuClass="pod-selector-menu"
/> />
<span>Container</span> <span>Container</span>
<Select<PodContainer, false, GroupBase<PodContainer>> <Select<PodContainer, SelectOption<PodContainer>, false>
id="container-selector-input" id="container-selector-input"
options={containerSelectOptions} options={containerSelectOptions}
value={container} value={container}
onChange={onContainerChange} onChange={onContainerChange}
getOptionLabel={opt => opt.name}
className="container-selector" className="container-selector"
menuClass="container-selector-menu" menuClass="container-selector-menu"
controlShouldRenderValue controlShouldRenderValue

View File

@ -15,15 +15,15 @@ import type { UpgradeChartTabStore } from "./store";
import { Spinner } from "../../spinner"; import { Spinner } from "../../spinner";
import { Badge } from "../../badge"; import { Badge } from "../../badge";
import { EditorPanel } from "../editor-panel"; import { EditorPanel } from "../editor-panel";
import { helmChartStore, type IChartVersion } from "../../+helm-charts/helm-chart.store"; import { helmChartStore, type ChartVersion } from "../../+helm-charts/helm-chart.store";
import type { HelmRelease, HelmReleaseUpdateDetails, HelmReleaseUpdatePayload } from "../../../../common/k8s-api/endpoints/helm-releases.api"; import type { HelmRelease, HelmReleaseUpdateDetails, HelmReleaseUpdatePayload } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import type { SelectOption } from "../../select";
import { Select } from "../../select"; import { Select } from "../../select";
import type { IAsyncComputed } from "@ogre-tools/injectable-react"; import type { IAsyncComputed } from "@ogre-tools/injectable-react";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import upgradeChartTabStoreInjectable from "./store.injectable"; import upgradeChartTabStoreInjectable from "./store.injectable";
import updateReleaseInjectable from "../../+helm-releases/update-release/update-release.injectable"; import updateReleaseInjectable from "../../+helm-releases/update-release/update-release.injectable";
import releasesInjectable from "../../+helm-releases/releases.injectable"; import releasesInjectable from "../../+helm-releases/releases.injectable";
import type { GroupBase } from "react-select";
export interface UpgradeChartProps { export interface UpgradeChartProps {
className?: string; className?: string;
@ -39,8 +39,8 @@ interface Dependencies {
@observer @observer
export class NonInjectedUpgradeChart extends React.Component<UpgradeChartProps & Dependencies> { export class NonInjectedUpgradeChart extends React.Component<UpgradeChartProps & Dependencies> {
@observable error?: string; @observable error?: string;
@observable versions = observable.array<IChartVersion>(); @observable versions = observable.array<ChartVersion>();
@observable version: IChartVersion | undefined = undefined; @observable version: ChartVersion | undefined = undefined;
constructor(props: UpgradeChartProps & Dependencies) { constructor(props: UpgradeChartProps & Dependencies) {
super(props); super(props);
@ -133,13 +133,6 @@ export class NonInjectedUpgradeChart extends React.Component<UpgradeChartProps &
); );
}; };
formatVersionLabel = (value: IChartVersion) => {
const chartName = this.release?.getChart() ?? "<unknown chart>";
const { repo, version } = value;
return `${repo}/${chartName}-${version}`;
};
render() { render() {
const { tabId, release, value, error, onChange, onError, upgrade, versions, version } = this; const { tabId, release, value, error, onChange, onError, upgrade, versions, version } = this;
const { className } = this.props; const { className } = this.props;
@ -148,6 +141,10 @@ export class NonInjectedUpgradeChart extends React.Component<UpgradeChartProps &
return <Spinner center />; return <Spinner center />;
} }
const currentVersion = release.getVersion(); const currentVersion = release.getVersion();
const versionOptions = versions.map(version => ({
value: version,
label: `${version.repo}/${release.getChart()}-${version.version}`,
}));
const controlsAndInfo = ( const controlsAndInfo = (
<div className="upgrade flex gaps align-center"> <div className="upgrade flex gaps align-center">
<span>Release</span> <span>Release</span>
@ -160,15 +157,14 @@ export class NonInjectedUpgradeChart extends React.Component<UpgradeChartProps &
{" "} {" "}
<Badge label={currentVersion} /> <Badge label={currentVersion} />
<span>Upgrade version</span> <span>Upgrade version</span>
<Select<IChartVersion, false, GroupBase<IChartVersion>> <Select<ChartVersion, SelectOption<ChartVersion>, false>
id="char-version-input" id="char-version-input"
className="chart-version" className="chart-version"
menuPlacement="top" menuPlacement="top"
themeName="outlined" themeName="outlined"
value={version} value={version}
options={versions} options={versionOptions}
getOptionLabel={this.formatVersionLabel} onChange={option => this.version = option?.value}
onChange={value => this.version = value ?? undefined}
/> />
</div> </div>
); );

View File

@ -23,8 +23,8 @@ const NonInjectedHotbarRemoveCommand = observer(({
}: Dependencies) => ( }: Dependencies) => (
<Select <Select
menuPortalTarget={null} menuPortalTarget={null}
onChange={hotbar => { onChange={option => {
if (!hotbar) { if (!option) {
return; return;
} }
@ -36,14 +36,14 @@ const NonInjectedHotbarRemoveCommand = observer(({
primary: false, primary: false,
accent: true, accent: true,
}, },
ok: () => hotbarStore.remove(hotbar), ok: () => hotbarStore.remove(option.value),
message: ( message: (
<div className="confirm flex column gaps"> <div className="confirm flex column gaps">
<p> <p>
Are you sure you want remove hotbar Are you sure you want remove hotbar
{" "} {" "}
<b> <b>
{hotbar.name} {option.value.name}
</b> </b>
? ?
</p> </p>
@ -53,8 +53,13 @@ const NonInjectedHotbarRemoveCommand = observer(({
} } } }
components={{ DropdownIndicator: null, IndicatorSeparator: null }} components={{ DropdownIndicator: null, IndicatorSeparator: null }}
menuIsOpen={true} menuIsOpen={true}
options={hotbarStore.hotbars} options={(
getOptionLabel={hotbar => hotbarStore.getDisplayLabel(hotbar)} hotbarStore.hotbars
.map(hotbar => ({
value: hotbar,
label: hotbarStore.getDisplayLabel(hotbar),
}))
)}
autoFocus={true} autoFocus={true}
escapeClearsValue={false} escapeClearsValue={false}
placeholder="Remove hotbar" placeholder="Remove hotbar"

View File

@ -9,7 +9,6 @@ import { Select } from "../select";
import hotbarStoreInjectable from "../../../common/hotbars/store.injectable"; import hotbarStoreInjectable from "../../../common/hotbars/store.injectable";
import type { InputValidator } from "../input"; import type { InputValidator } from "../input";
import { Input } from "../input"; import { Input } from "../input";
import type { Hotbar } from "../../../common/hotbars/types";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; import commandOverlayInjectable from "../command-palette/command-overlay.injectable";
import uniqueHotbarNameInjectable from "../input/validators/unique-hotbar-name.injectable"; import uniqueHotbarNameInjectable from "../input/validators/unique-hotbar-name.injectable";
@ -29,12 +28,6 @@ const NonInjectedHotbarRenameCommand = observer(({
const [hotbarId, setHotbarId] = useState(""); const [hotbarId, setHotbarId] = useState("");
const [hotbarName, setHotbarName] = useState(""); const [hotbarName, setHotbarName] = useState("");
const onSelect = (hotbar: Hotbar | null) => {
if (hotbar) {
setHotbarId(hotbar.id);
setHotbarName(hotbar.name);
}
};
const onSubmit = (name: string) => { const onSubmit = (name: string) => {
if (!name.trim()) { if (!name.trim()) {
return; return;
@ -69,11 +62,21 @@ const NonInjectedHotbarRenameCommand = observer(({
<Select <Select
id="rename-hotbar-input" id="rename-hotbar-input"
menuPortalTarget={null} menuPortalTarget={null}
onChange={onSelect} onChange={(option) => {
if (option) {
setHotbarId(option.value.id);
setHotbarName(option.value.name);
}
}}
components={{ DropdownIndicator: null, IndicatorSeparator: null }} components={{ DropdownIndicator: null, IndicatorSeparator: null }}
menuIsOpen={true} menuIsOpen={true}
options={hotbarStore.hotbars} options={(
getOptionLabel={hotbar => hotbarStore.getDisplayLabel(hotbar)} hotbarStore.hotbars
.map(hotbar => ({
value: hotbar,
label: hotbarStore.getDisplayLabel(hotbar),
}))
)}
autoFocus={true} autoFocus={true}
escapeClearsValue={false} escapeClearsValue={false}
placeholder="Rename hotbar" placeholder="Rename hotbar"

View File

@ -14,7 +14,6 @@ import { HotbarRenameCommand } from "./hotbar-rename-command";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import commandOverlayInjectable from "../command-palette/command-overlay.injectable"; import commandOverlayInjectable from "../command-palette/command-overlay.injectable";
import type { HotbarStore } from "../../../common/hotbars/store"; import type { HotbarStore } from "../../../common/hotbars/store";
import type { Hotbar } from "../../../common/hotbars/types";
const hotbarAddAction = Symbol("hotbar-add"); const hotbarAddAction = Symbol("hotbar-add");
const hotbarRemoveAction = Symbol("hotbar-remove"); const hotbarRemoveAction = Symbol("hotbar-remove");
@ -29,31 +28,6 @@ function ignoreIf<T>(check: boolean, menuItems: T[]): T[] {
return check ? [] : menuItems; return check ? [] : menuItems;
} }
interface HotbarSwitchActionOption {
action: typeof hotbarAddAction | typeof hotbarRemoveAction | typeof hotbarRenameAction;
}
interface SwitchToHotbarOption {
hotbar: Hotbar;
}
type HotbarSwitchOption = SwitchToHotbarOption | HotbarSwitchActionOption;
function getHotbarSwitchOptions(hotbars: Hotbar[]): HotbarSwitchOption[] {
return [
...hotbars.map(hotbar => ({ hotbar })),
{ action: hotbarAddAction },
...ignoreIf(hotbars.length > 1, [
{ action: hotbarRemoveAction } as const,
]),
{ action: hotbarRenameAction },
];
}
function isActionOption(option: HotbarSwitchOption): option is HotbarSwitchActionOption {
return Boolean((option as HotbarSwitchActionOption).action);
}
const NonInjectedHotbarSwitchCommand = observer(({ const NonInjectedHotbarSwitchCommand = observer(({
hotbarStore, hotbarStore,
commandOverlay, commandOverlay,
@ -66,8 +40,8 @@ const NonInjectedHotbarSwitchCommand = observer(({
return; return;
} }
if (isActionOption(option)) { if (typeof option.value === "symbol") {
switch (option.action) { switch (option.value) {
case hotbarAddAction: case hotbarAddAction:
return commandOverlay.open(<HotbarAddCommand />); return commandOverlay.open(<HotbarAddCommand />);
case hotbarRemoveAction: case hotbarRemoveAction:
@ -75,28 +49,33 @@ const NonInjectedHotbarSwitchCommand = observer(({
case hotbarRenameAction: case hotbarRenameAction:
return commandOverlay.open(<HotbarRenameCommand />); return commandOverlay.open(<HotbarRenameCommand />);
} }
} else {
hotbarStore.setActiveHotbar(option.value);
commandOverlay.close();
} }
hotbarStore.setActiveHotbar(option.hotbar);
commandOverlay.close();
}} }}
components={{ DropdownIndicator: null, IndicatorSeparator: null }} components={{ DropdownIndicator: null, IndicatorSeparator: null }}
menuIsOpen={true} menuIsOpen={true}
options={getHotbarSwitchOptions(hotbarStore.hotbars)} options={[
getOptionLabel={option => { ...hotbarStore.hotbars.map(hotbar => ({
if (isActionOption(option)) { value: hotbar,
switch (option.action) { label: hotbarStore.getDisplayLabel(hotbar),
case hotbarAddAction: })),
return "Add hotbar ..."; {
case hotbarRemoveAction: value: hotbarAddAction,
return "Remove hotbar ..."; label: "Add hotbar ...",
case hotbarRenameAction: },
return "Rename hotbar ..."; ...ignoreIf(hotbarStore.hotbars.length > 1, [
} {
} value: hotbarRemoveAction,
label: "Remove hotbar ...",
return hotbarStore.getDisplayLabel(option.hotbar); },
}} ]),
{
value: hotbarRenameAction,
label: "Rename hotbar ...",
},
]}
autoFocus={true} autoFocus={true}
escapeClearsValue={false} escapeClearsValue={false}
isClearable={false} isClearable={false}

View File

@ -7,6 +7,7 @@ import "./item-list-layout.scss";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import React from "react"; import React from "react";
import type { IComputedValue } from "mobx";
import { computed, makeObservable, untracked } from "mobx"; import { computed, makeObservable, untracked } from "mobx";
import type { ConfirmDialogParams } from "../confirm-dialog"; import type { ConfirmDialogParams } from "../confirm-dialog";
import type { TableCellProps, TableProps, TableRowProps, TableSortCallbacks } from "../table"; import type { TableCellProps, TableProps, TableRowProps, TableSortCallbacks } from "../table";
@ -17,8 +18,6 @@ import type { ItemObject } from "../../../common/item.store";
import type { SearchInputUrlProps } from "../input"; import type { SearchInputUrlProps } from "../input";
import { FilterType, pageFilters } from "./page-filters.store"; import { FilterType, pageFilters } from "./page-filters.store";
import { PageFiltersList } from "./page-filters-list"; import { PageFiltersList } from "./page-filters-list";
import type { NamespaceStore } from "../+namespaces/store";
import namespaceStoreInjectable from "../+namespaces/store.injectable";
import { withInjectables } from "@ogre-tools/injectable-react"; import { withInjectables } from "@ogre-tools/injectable-react";
import itemListLayoutStorageInjectable from "./storage.injectable"; import itemListLayoutStorageInjectable from "./storage.injectable";
import { ItemListLayoutContent } from "./content"; import { ItemListLayoutContent } from "./content";
@ -28,6 +27,7 @@ import { ItemListLayoutFilters } from "./filters";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import type { Primitive } from "type-fest"; import type { Primitive } from "type-fest";
import type { SubscribableStore } from "../../kube-watch-api/kube-watch-api"; import type { SubscribableStore } from "../../kube-watch-api/kube-watch-api";
import selectedFilterNamespacesInjectable from "../../../common/k8s-api/selected-filter-namespaces.injectable";
export type SearchFilter<I extends ItemObject> = (item: I) => SingleOrMany<string | number | undefined | null>; export type SearchFilter<I extends ItemObject> = (item: I) => SingleOrMany<string | number | undefined | null>;
export type SearchFilters<I extends ItemObject> = Record<string, SearchFilter<I>>; export type SearchFilters<I extends ItemObject> = Record<string, SearchFilter<I>>;
@ -159,7 +159,7 @@ export interface ItemListLayoutStorage {
} }
interface Dependencies { interface Dependencies {
namespaceStore: NamespaceStore; selectedFilterNamespaces: IComputedValue<string[]>;
itemListLayoutStorage: StorageLayer<ItemListLayoutStorage>; itemListLayoutStorage: StorageLayer<ItemListLayoutStorage>;
} }
@ -184,7 +184,7 @@ class NonInjectedItemListLayout<I extends ItemObject, PreLoadStores extends bool
const { store, dependentStores = [] } = this.props; const { store, dependentStores = [] } = this.props;
const stores = Array.from(new Set([store, ...dependentStores])) as ItemListStore<I, true>[]; const stores = Array.from(new Set([store, ...dependentStores])) as ItemListStore<I, true>[];
stores.forEach(store => store.loadAll(this.props.namespaceStore.contextNamespaces)); stores.forEach(store => store.loadAll(this.props.selectedFilterNamespaces.get()));
} }
} }
@ -328,7 +328,7 @@ class NonInjectedItemListLayout<I extends ItemObject, PreLoadStores extends bool
const InjectedItemListLayout = withInjectables<Dependencies, ItemListLayoutProps<ItemObject, boolean>>(NonInjectedItemListLayout, { const InjectedItemListLayout = withInjectables<Dependencies, ItemListLayoutProps<ItemObject, boolean>>(NonInjectedItemListLayout, {
getProps: (di, props) => ({ getProps: (di, props) => ({
namespaceStore: di.inject(namespaceStoreInjectable), selectedFilterNamespaces: di.inject(selectedFilterNamespacesInjectable),
itemListLayoutStorage: di.inject(itemListLayoutStorageInjectable), itemListLayoutStorage: di.inject(itemListLayoutStorageInjectable),
...props, ...props,
}), }),

View File

@ -4,6 +4,7 @@
*/ */
import React from "react"; import React from "react";
import "@testing-library/jest-dom/extend-expect"; import "@testing-library/jest-dom/extend-expect";
import type { SelectOption } from "./select";
import { Select } from "./select"; import { Select } from "./select";
import { UserStore } from "../../../common/user-store"; import { UserStore } from "../../../common/user-store";
import { ThemeStore } from "../../theme.store"; import { ThemeStore } from "../../theme.store";
@ -94,7 +95,7 @@ describe("<Select />", () => {
const { container } = render(( const { container } = render((
<Select <Select
value={options[0]} value={options[0].value}
onChange={onChange} onChange={onChange}
options={options} options={options}
/> />
@ -120,7 +121,7 @@ describe("<Select />", () => {
const { container, rerender } = render(( const { container, rerender } = render((
<Select <Select
value={options[0]} value={options[0].value}
onChange={onChange} onChange={onChange}
options={options} options={options}
/> />
@ -131,7 +132,7 @@ describe("<Select />", () => {
rerender(( rerender((
<Select <Select
value={options[1]} value={options[1].value}
onChange={onChange} onChange={onChange}
options={options} options={options}
/> />
@ -156,7 +157,7 @@ describe("<Select />", () => {
const { container, rerender } = render(( const { container, rerender } = render((
<Select <Select
value={options[0]} value={options[0].value}
onChange={onChange} onChange={onChange}
options={options} options={options}
/> />
@ -166,7 +167,7 @@ describe("<Select />", () => {
expect(selectedValueContainer?.textContent).toBe(options[0].label); expect(selectedValueContainer?.textContent).toBe(options[0].label);
rerender(( rerender((
<Select <Select<string, SelectOption<string>>
value={null} value={null}
onChange={onChange} onChange={onChange}
options={options} options={options}
@ -192,7 +193,7 @@ describe("<Select />", () => {
const { container, rerender } = render(( const { container, rerender } = render((
<Select <Select
value={options[0]} value={options[0].value}
onChange={onChange} onChange={onChange}
options={options} options={options}
/> />
@ -202,7 +203,7 @@ describe("<Select />", () => {
expect(selectedValueContainer?.textContent).toBe(options[0].label); expect(selectedValueContainer?.textContent).toBe(options[0].label);
rerender(( rerender((
<Select <Select<string, SelectOption<string>>
value={undefined} value={undefined}
onChange={onChange} onChange={onChange}
options={options} options={options}

View File

@ -8,51 +8,71 @@
import "./select.scss"; import "./select.scss";
import React from "react"; import React from "react";
import { computed, makeObservable } from "mobx"; import { action, computed, makeObservable } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import ReactSelect, { components } from "react-select"; import ReactSelect, { components, createFilter } from "react-select";
import type { Props as ReactSelectProps, GroupBase } from "react-select"; import type { Props as ReactSelectProps, GroupBase, MultiValue, OptionsOrGroups, PropsValue, SingleValue } from "react-select";
import { ThemeStore } from "../../theme.store"; import { ThemeStore } from "../../theme.store";
import { autoBind, cssNames } from "../../utils"; import { autoBind, cssNames } from "../../utils";
import type { SetRequired } from "type-fest";
const { Menu } = components; const { Menu } = components;
/** export interface SelectOption<Value> {
* @deprecated This type is no longer used value: Value;
*/ label: string;
export interface SelectOption<T> { isDisabled?: boolean;
value: T; isSelected?: boolean;
label?: React.ReactElement | string;
} }
export interface SelectProps< export interface SelectProps<
Value,
/** /**
* This needs to extend `object` because even though `ReactSelectProps` allows for any `T`, the * 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. * maintainers of `react-select` says that they don't support it.
* *
* Ref: https://github.com/JedWatson/react-select/issues/5032 * 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 object, Option extends SelectOption<Value>,
IsMulti extends boolean, IsMulti extends boolean,
Group extends GroupBase<Option> = GroupBase<Option>, Group extends GroupBase<Option> = GroupBase<Option>,
> extends ReactSelectProps<Option, IsMulti, Group> { > extends SetRequired<Omit<ReactSelectProps<Option, IsMulti, Group>, "value">, "options"> {
id?: string; // Optional only because of Extension API. Required to make Select deterministic in unit tests id?: string; // Optional only because of Extension API. Required to make Select deterministic in unit tests
themeName?: "dark" | "light" | "outlined" | "lens"; themeName?: "dark" | "light" | "outlined" | "lens";
menuClass?: string; menuClass?: string;
value?: PropsValue<Value>;
} }
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.data === "symbol") {
return option.label;
}
return `${option.label} ${option.value}`;
},
});
@observer @observer
export class Select< export class Select<
Option extends object, Value,
Option extends SelectOption<Value>,
IsMulti extends boolean = false, IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>, Group extends GroupBase<Option> = GroupBase<Option>,
> extends React.Component<SelectProps<Option, IsMulti, Group>> { > extends React.Component<SelectProps<Value, Option, IsMulti, Group>> {
static defaultProps = { static defaultProps = {
menuPortalTarget: document.body, menuPortalTarget: document.body,
menuPlacement: "auto", menuPlacement: "auto",
}; };
constructor(props: SelectProps<Option, IsMulti, Group>) { constructor(props: SelectProps<Value, Option, IsMulti, Group>) {
super(props); super(props);
makeObservable(this); makeObservable(this);
autoBind(this); autoBind(this);
@ -72,6 +92,48 @@ export class Select<
} }
} }
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() { render() {
const { const {
className, className,
@ -79,10 +141,17 @@ export class Select<
components = {}, components = {},
styles, styles,
value = null, value = null,
options,
isMulti,
onChange,
...props ...props
} = this.props; } = this.props;
const WrappedMenu = components.Menu ?? Menu; const WrappedMenu = components.Menu ?? Menu;
if (options.length > 0 && !(options?.[0] as { label?: string }).label) {
console.warn("[SELECT]: will not display any label in dropdown");
}
return ( return (
<ReactSelect <ReactSelect
{...props} {...props}
@ -93,10 +162,14 @@ export class Select<
}), }),
...styles, ...styles,
}} }}
value={value} filterOption={defaultFilter} // This is done because the default filter crashes on symbols
isMulti={isMulti}
options={options}
value={this.findSelectedPropsValue(value, options, isMulti)}
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
className={cssNames("Select", this.themeClass, className)} className={cssNames("Select", this.themeClass, className)}
classNamePrefix="Select" classNamePrefix="Select"
onChange={action(onChange)} // This is done so that all changes are actionable
components={{ components={{
...components, ...components,
Menu: props => ( Menu: props => (