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",
}
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 {
name: 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 object from "./objects";
import * as json from "./json";
import * as string from "./string";
export {
iter,
@ -58,5 +57,4 @@ export {
base64,
object,
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.
*/
import type { SetRequired } from "type-fest";
export type RemoveUndefinedFromValues<K> = {
[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> =
(<G>() => G extends T ? 1 : 2) extends
(<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"
themeName="light"
className="box grow"
onChange={option => this.namespace = option?.namespace ?? null}
onChange={option => this.namespace = option?.value ?? null}
/>
<SubTitle title="Values" />
@ -190,26 +190,24 @@ export class AddQuotaDialog extends React.Component<AddQuotaDialogProps> {
className="quota-select"
themeName="light"
placeholder="Select a quota.."
options={Object.keys(this.quotas).map(quota => ({ quota }))}
isMulti={false}
value={(
this.quotaSelectValue
? ({ quota: this.quotaSelectValue })
: null
)}
onChange={option => this.quotaSelectValue = option?.quota ?? null}
formatOptionLabel={({ quota }) => {
const iconMaterial = this.getQuotaOptionLabelIconMaterial(quota);
options={Object.keys(this.quotas).map(quota => ({
value: quota,
label: quota,
}))}
value={this.quotaSelectValue}
onChange={option => this.quotaSelectValue = option?.value ?? null}
formatOptionLabel={({ value }) => {
const iconMaterial = this.getQuotaOptionLabelIconMaterial(value);
return iconMaterial
? (
<span className="nobr">
<Icon material={iconMaterial} />
{" "}
{quota}
{value}
</span>
)
: quota;
: value;
}}
/>
<Input

View File

@ -13,7 +13,7 @@ import { Dialog } from "../dialog";
import { Wizard, WizardStep } from "../wizard";
import { Input } from "../input";
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 { NamespaceSelect } from "../+namespaces/namespace-select";
import { Select } from "../select";
@ -72,7 +72,10 @@ export class AddSecretDialog extends React.Component<AddSecretDialogProps> {
};
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;
@ -224,7 +227,7 @@ export class AddSecretDialog extends React.Component<AddSecretDialogProps> {
id="secret-namespace-input"
themeName="light"
value={namespace}
onChange={value => this.namespace = value?.namespace ?? "default"}
onChange={option => this.namespace = option?.value ?? "default"}
/>
</div>
<div className="secret-type">
@ -233,8 +236,8 @@ export class AddSecretDialog extends React.Component<AddSecretDialogProps> {
id="secret-input"
themeName="light"
options={this.secretTypeOptions}
value={({ type })}
onChange={value => this.type = value?.type ?? SecretType.Opaque}
value={type}
onChange={option => this.type = option?.value ?? SecretType.Opaque}
/>
</div>
</div>

View File

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

View File

@ -8,7 +8,7 @@ import "./helm-chart-details.scss";
import React, { Component } from "react";
import type { HelmChart } 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 { Drawer, DrawerItem } from "../drawer";
import { autoBind, stopPropagation } from "../../utils";
@ -21,6 +21,8 @@ import { Tooltip, withStyles } from "@material-ui/core";
import { withInjectables } from "@ogre-tools/injectable-react";
import createInstallChartTabInjectable from "../dock/install-chart/create-install-chart-tab.injectable";
import { Notifications } from "../notifications";
import HelmLogoPlaceholder from "./helm-placeholder.svg";
import type { SingleValue } from "react-select";
export interface HelmChartDetailsProps {
chart: HelmChart;
@ -42,6 +44,12 @@ class NonInjectedHelmChartDetails extends Component<HelmChartDetailsProps & Depe
readonly chartVersions = observable.array<HelmChart>();
readonly selectedChart = observable.box<HelmChart | undefined>();
readonly readme = observable.box<string | undefined>(undefined);
readonly chartVerionOptions = computed(() => (
this.chartVersions.map(chart => ({
value: chart,
label: chart.version,
}))
));
private abortController = new AbortController();
@ -80,8 +88,8 @@ class NonInjectedHelmChartDetails extends Component<HelmChartDetailsProps & Depe
]);
}
async onVersionChange(c: HelmChart | null | undefined) {
const chart = c ?? this.chartVersions[0];
async onVersionChange(option: SingleValue<{ value: HelmChart }>) {
const chart = option?.value ?? this.chartVersions[0];
runInAction(() => {
this.selectedChart.set(chart ?? undefined);
@ -106,15 +114,12 @@ class NonInjectedHelmChartDetails extends Component<HelmChartDetailsProps & Depe
}
renderIntroduction(selectedChart: HelmChart) {
const { chartVersions, onVersionChange } = this;
const placeholder = require("./helm-placeholder.svg");
return (
<div className="introduction flex align-flex-start">
<img
className="intro-logo"
src={selectedChart.getIcon() || placeholder}
onError={(event) => event.currentTarget.src = placeholder}
src={selectedChart.getIcon() || HelmLogoPlaceholder}
onError={(event) => event.currentTarget.src = HelmLogoPlaceholder}
/>
<div className="intro-contents box grow">
<div className="description flex align-center justify-space-between">
@ -134,9 +139,8 @@ class NonInjectedHelmChartDetails extends Component<HelmChartDetailsProps & Depe
id="chart-version-input"
themeName="outlined"
menuPortalTarget={null}
options={chartVersions.slice()}
getOptionLabel={chart => chart.version}
formatOptionLabel={chart => (
options={this.chartVerionOptions.get()}
formatOptionLabel={({ value: chart }) => (
chart.deprecated
? (
<LargeTooltip title="Deprecated" placement="left">
@ -145,9 +149,9 @@ class NonInjectedHelmChartDetails extends Component<HelmChartDetailsProps & Depe
)
: chart.version
)}
isOptionDisabled={chart => chart.deprecated}
isOptionDisabled={({ value: chart }) => chart.deprecated}
value={selectedChart}
onChange={onVersionChange}
onChange={this.onVersionChange}
/>
</DrawerItem>
<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 flatten from "lodash/flatten";
export interface IChartVersion {
export interface ChartVersion {
repo: string;
version: string;
}
export class HelmChartStore extends ItemStore<HelmChart> {
@observable versions = observable.map<string, IChartVersion[]>();
@observable versions = observable.map<string, ChartVersion[]>();
constructor() {
super();
@ -53,14 +53,14 @@ export class HelmChartStore extends ItemStore<HelmChart> {
return this.items.find(chart => chart.getName() === name && chart.getRepository() === repo);
}
protected sortVersions = (versions: IChartVersion[]) => {
protected sortVersions = (versions: ChartVersion[]) => {
return versions
.map(chartVersion => ({ ...chartVersion, __version: semver.coerce(chartVersion.version, { includePrerelease: true, loose: true }) }))
.sort(sortCompareChartVersions)
.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);
if (versions && !force) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -63,6 +63,21 @@ export class ClusterRoleBindingDialog extends React.Component<ClusterRoleBinding
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() {
return dialogState.data;
}
@ -164,29 +179,28 @@ export class ClusterRoleBindingDialog extends React.Component<ClusterRoleBinding
themeName="light"
placeholder="Select cluster role ..."
isDisabled={this.isEditing}
options={clusterRoleStore.items.slice()}
options={this.clusterRoleOptions}
value={this.selectedRoleRef}
autoFocus={!this.isEditing}
formatOptionLabel={value => (
formatOptionLabel={option => (
<>
<Icon
small
material={value.kind === "Role" ? "person" : "people"}
material="people"
tooltip={{
preferredPositions: TooltipPosition.LEFT,
children: value.kind,
children: option.value.kind,
}}
/>
{" "}
{value.getName()}
{option.value.getName()}
</>
)}
getOptionLabel={value => value.getName()}
onChange={value => {
this.selectedRoleRef = value ?? undefined;
onChange={option => {
this.selectedRoleRef = option?.value;
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
themeName="light"
placeholder="Select service accounts ..."
options={serviceAccountStore.items.slice()}
formatOptionLabel={value => (
options={this.serviceAccountOptions}
formatOptionLabel={option => (
<>
<Icon small material="account_box" />
{` ${value.getName()} (${value.getNs()})`}
{` ${option.label}`}
</>
)}
getOptionLabel={value => `${value.getName()} (${value.getNs()})`}
onChange={selected => {
this.selectedAccounts.replace(selected);
onChange={(selected, meta) => {
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}
/>

View File

@ -21,6 +21,7 @@ import { Icon } from "../../icon";
import { showDetails } from "../../kube-detail-params";
import { SubTitle } from "../../layout/sub-title";
import { Notifications } from "../../notifications";
import type { SelectOption } from "../../select";
import { Select } from "../../select";
import { Wizard, WizardStep } from "../../wizard";
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
.filter(role => role.getNs() === this.bindingNamespace);
const clusterRoles = clusterRoleStore.items;
@ -103,7 +104,18 @@ export class RoleBindingDialog extends React.Component<RoleBindingDialogProps> {
return [
...roles,
...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(() => {
@ -182,7 +194,7 @@ export class RoleBindingDialog extends React.Component<RoleBindingDialogProps> {
isDisabled={this.isEditing}
value={this.bindingNamespace}
autoFocus={!this.isEditing}
onChange={opt => this.bindingNamespace = opt ? opt.namespace : null}
onChange={opt => this.bindingNamespace = opt?.value ?? null}
/>
<SubTitle title="Role Reference" />
@ -193,12 +205,11 @@ export class RoleBindingDialog extends React.Component<RoleBindingDialogProps> {
isDisabled={this.isEditing}
options={this.roleRefOptions}
value={this.selectedRoleRef}
getOptionLabel={ref => ref.getName()}
onChange={roleRef => {
this.selectedRoleRef = roleRef;
onChange={option => {
this.selectedRoleRef = option?.value;
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
themeName="light"
placeholder="Select service accounts ..."
options={serviceAccountStore.items}
isOptionSelected={account => this.selectedAccounts.has(account)}
formatOptionLabel={account => (
options={this.serviceAccountOptions}
formatOptionLabel={option => (
<>
<Icon small material="account_box" />
{` ${account.getName()} (${account.getNs()})`}
{` ${option.label}`}
</>
)}
getOptionLabel={account => `${account.getName()} (${account.getNs()})`}
onChange={selected => this.selectedAccounts.replace(selected)}
onChange={(selected, meta) => {
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}
/>
</>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -36,7 +36,7 @@ const NonInjectedCommandDialog = observer(({ commands, activeEntity, closeComman
try {
closeCommandOverlay();
option.action({
option.value.action({
entity: activeEntity,
navigate: (url, opts = {}) => {
const { forceRootFrame = false } = opts;
@ -49,7 +49,7 @@ const NonInjectedCommandDialog = observer(({ commands, activeEntity, closeComman
},
});
} 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);
}
})
.map(command => ({
value: command,
label: typeof command.title === "string"
? command.title
: command.title(context),
}))
.collect(items => Array.from(items));
return (
@ -78,11 +84,6 @@ const NonInjectedCommandDialog = observer(({ commands, activeEntity, closeComman
}}
menuIsOpen
options={activeCommands}
getOptionLabel={({ title }) => (
typeof title === "string"
? title
: title(context)
)}
autoFocus={true}
escapeClearsValue={false}
data-test-id="command-palette-search"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@ import "./item-list-layout.scss";
import type { ReactNode } from "react";
import React from "react";
import type { IComputedValue } from "mobx";
import { computed, makeObservable, untracked } from "mobx";
import type { ConfirmDialogParams } from "../confirm-dialog";
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 { FilterType, pageFilters } from "./page-filters.store";
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 itemListLayoutStorageInjectable from "./storage.injectable";
import { ItemListLayoutContent } from "./content";
@ -28,6 +27,7 @@ import { ItemListLayoutFilters } from "./filters";
import { observer } from "mobx-react";
import type { Primitive } from "type-fest";
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 SearchFilters<I extends ItemObject> = Record<string, SearchFilter<I>>;
@ -159,7 +159,7 @@ export interface ItemListLayoutStorage {
}
interface Dependencies {
namespaceStore: NamespaceStore;
selectedFilterNamespaces: IComputedValue<string[]>;
itemListLayoutStorage: StorageLayer<ItemListLayoutStorage>;
}
@ -184,7 +184,7 @@ class NonInjectedItemListLayout<I extends ItemObject, PreLoadStores extends bool
const { store, dependentStores = [] } = this.props;
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, {
getProps: (di, props) => ({
namespaceStore: di.inject(namespaceStoreInjectable),
selectedFilterNamespaces: di.inject(selectedFilterNamespacesInjectable),
itemListLayoutStorage: di.inject(itemListLayoutStorageInjectable),
...props,
}),

View File

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

View File

@ -8,51 +8,71 @@
import "./select.scss";
import React from "react";
import { computed, makeObservable } from "mobx";
import { action, computed, makeObservable } from "mobx";
import { observer } from "mobx-react";
import ReactSelect, { components } from "react-select";
import type { Props as ReactSelectProps, GroupBase } from "react-select";
import ReactSelect, { components, createFilter } from "react-select";
import type { Props as ReactSelectProps, GroupBase, MultiValue, OptionsOrGroups, PropsValue, SingleValue } from "react-select";
import { ThemeStore } from "../../theme.store";
import { autoBind, cssNames } from "../../utils";
import type { SetRequired } from "type-fest";
const { Menu } = components;
/**
* @deprecated This type is no longer used
*/
export interface SelectOption<T> {
value: T;
label?: React.ReactElement | string;
export interface SelectOption<Value> {
value: Value;
label: string;
isDisabled?: boolean;
isSelected?: boolean;
}
export interface SelectProps<
Value,
/**
* This needs to extend `object` because even though `ReactSelectProps` allows for any `T`, the
* maintainers of `react-select` says that they don't support it.
*
* Ref: https://github.com/JedWatson/react-select/issues/5032
*
* Futhermore, we mandate the option is of this shape because it is easier than requiring
* `getOptionValue` and `getOptionLabel` all over the place.
*/
Option extends object,
Option extends SelectOption<Value>,
IsMulti extends boolean,
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
themeName?: "dark" | "light" | "outlined" | "lens";
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
export class Select<
Option extends object,
Value,
Option extends SelectOption<Value>,
IsMulti extends boolean = false,
Group extends GroupBase<Option> = GroupBase<Option>,
> extends React.Component<SelectProps<Option, IsMulti, Group>> {
> extends React.Component<SelectProps<Value, Option, IsMulti, Group>> {
static defaultProps = {
menuPortalTarget: document.body,
menuPlacement: "auto",
};
constructor(props: SelectProps<Option, IsMulti, Group>) {
constructor(props: SelectProps<Value, Option, IsMulti, Group>) {
super(props);
makeObservable(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() {
const {
className,
@ -79,10 +141,17 @@ export class Select<
components = {},
styles,
value = null,
options,
isMulti,
onChange,
...props
} = this.props;
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 (
<ReactSelect
{...props}
@ -93,10 +162,14 @@ export class Select<
}),
...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}
className={cssNames("Select", this.themeClass, className)}
classNamePrefix="Select"
onChange={action(onChange)} // This is done so that all changes are actionable
components={{
...components,
Menu: props => (