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

Rework installation of helm charts to get rid of the majority of bugs

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>
This commit is contained in:
Janne Savolainen 2022-07-18 15:40:27 +03:00
parent 5dbb968d91
commit 4a61fffe45
No known key found for this signature in database
GPG Key ID: 8C6CFB2FFFE8F68A
29 changed files with 26772 additions and 359 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,38 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { asyncComputed } from "@ogre-tools/injectable-react";
import callForHelmChartReadmeInjectable from "./readme/call-for-helm-chart-readme.injectable";
import helmChartDetailsVersionSelectionInjectable from "./versions/helm-chart-details-version-selection.injectable";
import type { HelmChart } from "../../../../common/k8s-api/endpoints/helm-charts.api";
const readmeOfSelectedHelmChartInjectable = getInjectable({
id: "readme-of-selected-helm-chart",
instantiate: (di, chart: HelmChart) => {
const selection = di.inject(helmChartDetailsVersionSelectionInjectable, chart);
const callForHelmChartReadme = di.inject(callForHelmChartReadmeInjectable);
return asyncComputed(async () => {
const chartVersion = selection.value.get();
if (!chartVersion) {
return "";
}
return await callForHelmChartReadme(
chartVersion.getRepository(),
chartVersion.getName(),
chartVersion.getVersion(),
);
}, "");
},
lifecycle: lifecycleEnum.keyedSingleton({
getInstanceKey: (di, chart: HelmChart) => chart.getId(),
}),
});
export default readmeOfSelectedHelmChartInjectable;

View File

@ -0,0 +1,27 @@
/**
* 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 { getChartDetails } from "../../../../../common/k8s-api/endpoints/helm-charts.api";
export type CallForHelmChartReadme = (
repo: string,
name: string,
version: string,
) => Promise<string>;
const callForHelmChartReadmeInjectable = getInjectable({
id: "call-for-helm-chart-readme",
instantiate:
(): CallForHelmChartReadme =>
async (repository: string, name: string, version: string) => {
// TODO: Dismantle wrong abstraction
const details = await getChartDetails(repository, name, { version });
return details.readme;
},
});
export default callForHelmChartReadmeInjectable;

View File

@ -0,0 +1,28 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { asyncComputed } from "@ogre-tools/injectable-react";
import callForHelmChartVersionsInjectable from "./versions/call-for-helm-chart-versions.injectable";
import type { HelmChart } from "../../../../common/k8s-api/endpoints/helm-charts.api";
const versionsOfSelectedHelmChartInjectable = getInjectable({
id: "versions-of-selected-helm-chart",
instantiate: (di, chart: HelmChart) => {
const callForHelmChartVersions = di.inject(callForHelmChartVersionsInjectable);
return asyncComputed(
async () =>
await callForHelmChartVersions(chart.getRepository(), chart.getName()),
[],
);
},
lifecycle: lifecycleEnum.keyedSingleton({
getInstanceKey: (di, chart: HelmChart) => chart.getId(),
}),
});
export default versionsOfSelectedHelmChartInjectable;

View File

@ -0,0 +1,26 @@
/**
* 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 type { HelmChart } from "../../../../../common/k8s-api/endpoints/helm-charts.api";
import { getChartDetails } from "../../../../../common/k8s-api/endpoints/helm-charts.api";
export type CallForHelmChartVersions = (
repo: string,
name: string
) => Promise<HelmChart[]>;
const callForHelmChartVersionsInjectable = getInjectable({
id: "call-for-helm-chart-versions",
instantiate:
(): CallForHelmChartVersions => async (repository: string, name: string) => {
// TODO: Dismantle wrong abstraction
const details = await getChartDetails(repository, name);
return details.versions;
},
});
export default callForHelmChartVersionsInjectable;

View File

@ -0,0 +1,59 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import type { IComputedValue } from "mobx";
import { computed, observable } from "mobx";
import versionsOfSelectedHelmChartInjectable from "../versions-of-selected-helm-chart.injectable";
import type { HelmChart } from "../../../../../common/k8s-api/endpoints/helm-charts.api";
import type { SingleValue } from "react-select";
interface VersionSelectionOption {
label: string;
value: HelmChart;
}
export interface HelmChartDetailsVersionSelection {
value: IComputedValue<HelmChart | undefined>;
options: IComputedValue<VersionSelectionOption[]>;
onChange: (option: SingleValue<VersionSelectionOption>) => void;
}
const helmChartDetailsVersionSelectionInjectable = getInjectable({
id: "helm-chart-details-version-selection",
instantiate: (di, chart: HelmChart): HelmChartDetailsVersionSelection => {
const versionsOfSelectedHelmChart = di.inject(
versionsOfSelectedHelmChartInjectable,
chart,
);
const state = observable.box<HelmChart>();
return {
value: computed(
() => state.get() || versionsOfSelectedHelmChart.value.get()[0],
),
options: computed(() =>
versionsOfSelectedHelmChart.value.get().map((chartVersion) => ({
label: chartVersion.version,
value: chartVersion,
})),
),
onChange: (option) => {
if (option) {
state.set(option.value);
}
},
};
},
lifecycle: lifecycleEnum.keyedSingleton({
getInstanceKey: (di, chart: HelmChart) => chart.getId(),
}),
});
export default helmChartDetailsVersionSelectionInjectable;

View File

@ -7,8 +7,7 @@ import "./helm-chart-details.scss";
import React, { Component } from "react";
import type { HelmChart } from "../../../common/k8s-api/endpoints/helm-charts.api";
import { computed, observable, reaction, runInAction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { observer } from "mobx-react";
import { Drawer, DrawerItem } from "../drawer";
import { autoBind, stopPropagation } from "../../utils";
import { MarkdownViewer } from "../markdown-viewer";
@ -17,19 +16,21 @@ import { Button } from "../button";
import { Select } from "../select";
import { Badge } from "../badge";
import { Tooltip, withStyles } from "@material-ui/core";
import type { IAsyncComputed } from "@ogre-tools/injectable-react";
import { withInjectables } from "@ogre-tools/injectable-react";
import createInstallChartTabInjectable from "../dock/install-chart/create-install-chart-tab.injectable";
import type { ShowCheckedErrorNotification } from "../notifications/show-checked-error.injectable";
import type { SingleValue } from "react-select";
import AbortController from "abort-controller";
import showCheckedErrorNotificationInjectable from "../notifications/show-checked-error.injectable";
import type { GetChartDetails } from "./get-char-details.injectable";
import getChartDetailsInjectable from "./get-char-details.injectable";
import { HelmChartIcon } from "./icon";
import readmeOfSelectHelmChartInjectable from "./details/readme-of-selected-helm-chart.injectable";
import versionsOfSelectedHelmChartInjectable from "./details/versions-of-selected-helm-chart.injectable";
import type { HelmChartDetailsVersionSelection } from "./details/versions/helm-chart-details-version-selection.injectable";
import helmChartDetailsVersionSelectionInjectable from "./details/versions/helm-chart-details-version-selection.injectable";
import assert from "assert";
export interface HelmChartDetailsProps {
chart: HelmChart;
hideDetails(): void;
chart: HelmChart;
}
const LargeTooltip = withStyles({
@ -41,84 +42,34 @@ const LargeTooltip = withStyles({
interface Dependencies {
createInstallChartTab: (helmChart: HelmChart) => void;
showCheckedErrorNotification: ShowCheckedErrorNotification;
getChartDetails: GetChartDetails;
versions: IAsyncComputed<HelmChart[]>;
readme: IAsyncComputed<string>;
versionSelection: HelmChartDetailsVersionSelection;
}
@observer
class NonInjectedHelmChartDetails extends Component<HelmChartDetailsProps & Dependencies> {
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();
constructor(props: HelmChartDetailsProps & Dependencies) {
super(props);
autoBind(this);
}
componentWillUnmount() {
this.abortController.abort();
get chart() {
return this.props.chart;
}
componentDidMount() {
disposeOnUnmount(this, [
reaction(() => this.props.chart, async ({ name, repo, version }) => {
runInAction(() => {
this.selectedChart.set(undefined);
this.chartVersions.clear();
this.readme.set("");
});
install() {
const chart = this.props.versionSelection.value.get();
try {
const { readme, versions } = await this.props.getChartDetails(repo, name, { version });
assert(chart);
runInAction(() => {
this.readme.set(readme);
this.chartVersions.replace(versions);
this.selectedChart.set(versions[0]);
});
} catch (error) {
this.props.showCheckedErrorNotification(error, "Unknown error occured while getting chart details");
}
}, {
fireImmediately: true,
}),
]);
}
async onVersionChange(option: SingleValue<{ value: HelmChart }>) {
const chart = option?.value ?? this.chartVersions[0];
runInAction(() => {
this.selectedChart.set(chart ?? undefined);
this.readme.set(undefined);
});
try {
this.abortController.abort();
this.abortController = new AbortController();
const { chart: { name, repo }} = this.props;
const { readme } = await this.props.getChartDetails(repo, name, { version: chart.version, reqInit: { signal: this.abortController.signal }});
this.readme.set(readme);
} catch (error) {
this.props.showCheckedErrorNotification(error, "Unknown error occured while getting chart details");
}
}
install(selectedChart: HelmChart) {
this.props.createInstallChartTab(selectedChart);
this.props.createInstallChartTab(chart);
this.props.hideDetails();
}
renderIntroduction(selectedChart: HelmChart) {
const testId = selectedChart.getFullName("-");
return (
<div className="introduction flex align-flex-start">
<HelmChartIcon
@ -131,7 +82,8 @@ class NonInjectedHelmChartDetails extends Component<HelmChartDetailsProps & Depe
<Button
primary
label="Install"
onClick={() => this.install(selectedChart)}
onClick={this.install}
data-testid={`install-chart-for-${testId}`}
/>
</div>
<DrawerItem
@ -140,10 +92,10 @@ class NonInjectedHelmChartDetails extends Component<HelmChartDetailsProps & Depe
onClick={stopPropagation}
>
<Select
id="chart-version-input"
id={`helm-chart-version-selector-${testId}`}
themeName="outlined"
menuPortalTarget={null}
options={this.chartVerionOptions.get()}
options={this.props.versionSelection.options.get()}
formatOptionLabel={({ value: chart }) => (
chart.deprecated
? (
@ -154,8 +106,8 @@ class NonInjectedHelmChartDetails extends Component<HelmChartDetailsProps & Depe
: chart.version
)}
isOptionDisabled={({ value: chart }) => chart.deprecated}
value={selectedChart}
onChange={this.onVersionChange}
value={this.props.versionSelection.value.get()}
onChange={this.props.versionSelection.onChange}
/>
</DrawerItem>
<DrawerItem name="Home">
@ -190,44 +142,42 @@ class NonInjectedHelmChartDetails extends Component<HelmChartDetailsProps & Depe
}
renderReadme() {
const readme = this.readme.get();
if (readme === undefined) {
return <Spinner center />;
}
return (
<div className="chart-description" data-testid="helmchart-readme">
<MarkdownViewer markdown={readme} />
<MarkdownViewer markdown={this.props.readme.value.get()} />
</div>
);
}
renderContent() {
const selectedChart = this.selectedChart.get();
const readmeIsLoading = this.props.readme.pending.get();
const versionsAreLoading = this.props.versions.pending.get();
if (!selectedChart) {
return <Spinner center />;
if (!this.chart || versionsAreLoading) {
return <Spinner center data-testid="spinner-for-chart-details" />;
}
return (
<div className="box grow">
{this.renderIntroduction(selectedChart)}
{this.renderReadme()}
{this.renderIntroduction(this.chart)}
{readmeIsLoading ? (
<Spinner center data-testid="spinner-for-chart-readme" />
) : (
this.renderReadme()
)}
</div>
);
}
render() {
const { chart, hideDetails } = this.props;
return (
<Drawer
className="HelmChartDetails"
usePortal={true}
open={!!chart}
title={chart ? `Chart: ${chart.getFullName()}` : ""}
onClose={hideDetails}
open={!!this.chart}
title={this.chart ? `Chart: ${this.chart.getFullName()}` : ""}
onClose={this.props.hideDetails}
>
{this.renderContent()}
</Drawer>
@ -240,6 +190,8 @@ export const HelmChartDetails = withInjectables<Dependencies, HelmChartDetailsPr
...props,
createInstallChartTab: di.inject(createInstallChartTabInjectable),
showCheckedErrorNotification: di.inject(showCheckedErrorNotificationInjectable),
getChartDetails: di.inject(getChartDetailsInjectable),
readme: di.inject(readmeOfSelectHelmChartInjectable, props.chart),
versions: di.inject(versionsOfSelectedHelmChartInjectable, props.chart),
versionSelection: di.inject(helmChartDetailsVersionSelectionInjectable, props.chart),
}),
});

View File

@ -0,0 +1,13 @@
/**
* 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 { HelmChartStore } from "./helm-chart.store";
const helmChartStoreInjectable = getInjectable({
id: "helm-chart-store",
instantiate: () => new HelmChartStore(),
});
export default helmChartStoreInjectable;

View File

@ -12,12 +12,15 @@ import type { HelmChart } from "../../../common/k8s-api/endpoints/helm-charts.ap
import { HelmChartDetails } from "./helm-chart-details";
import { ItemListLayout } from "../item-object-list/list-layout";
import type { IComputedValue } from "mobx";
import type { IAsyncComputed } from "@ogre-tools/injectable-react";
import { withInjectables } from "@ogre-tools/injectable-react";
import { SiblingsInTabLayout } from "../layout/siblings-in-tab-layout";
import helmChartsRouteParametersInjectable from "./helm-charts-route-parameters.injectable";
import type { NavigateToHelmCharts } from "../../../common/front-end-routing/routes/cluster/helm/charts/navigate-to-helm-charts.injectable";
import navigateToHelmChartsInjectable from "../../../common/front-end-routing/routes/cluster/helm/charts/navigate-to-helm-charts.injectable";
import { HelmChartIcon } from "./icon";
import helmChartsInjectable from "./helm-charts/helm-charts.injectable";
import selectedHelmChartInjectable from "./helm-charts/selected-helm-chart.injectable";
enum columnId {
name = "name",
@ -34,27 +37,15 @@ interface Dependencies {
};
navigateToHelmCharts: NavigateToHelmCharts;
charts: IAsyncComputed<HelmChart[]>;
selectedChart: IComputedValue<HelmChart | undefined>;
}
@observer
class NonInjectedHelmCharts extends Component<Dependencies> {
componentDidMount() {
helmChartStore.loadAll();
}
get selectedChart() {
const chartName = this.props.routeParameters.chartName.get();
const repo = this.props.routeParameters.repo.get();
if (!chartName || !repo) {
return undefined;
}
return helmChartStore.getByName(chartName, repo);
}
onDetails = (chart: HelmChart) => {
if (chart === this.selectedChart) {
if (chart === this.props.selectedChart.get()) {
this.hideDetails();
} else {
this.showDetails(chart);
@ -78,6 +69,8 @@ class NonInjectedHelmCharts extends Component<Dependencies> {
};
render() {
const selectedChart = this.props.selectedChart.get();
return (
<SiblingsInTabLayout>
<div data-testid="page-for-helm-charts" style={{ display: "none" }}/>
@ -87,7 +80,7 @@ class NonInjectedHelmCharts extends Component<Dependencies> {
tableId="helm_charts"
className="HelmCharts"
store={helmChartStore}
getItems={() => helmChartStore.items}
getItems={() => this.props.charts.value.get()}
isSelectable={false}
sortingCallbacks={{
[columnId.name]: chart => chart.getName(),
@ -105,6 +98,7 @@ class NonInjectedHelmCharts extends Component<Dependencies> {
placeholder: "Search Helm Charts...",
},
})}
customizeTableRowProps={(item) => ({ testId: `helm-chart-row-for-${item.getFullName("-")}` })}
renderTableHeader={[
{ className: "icon", showWithColumn: columnId.name },
{ title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
@ -124,12 +118,12 @@ class NonInjectedHelmCharts extends Component<Dependencies> {
{ title: chart.getRepository(), className: chart.getRepository().toLowerCase() },
{ className: "menu" },
]}
detailsItem={this.selectedChart}
detailsItem={selectedChart}
onDetails={this.onDetails}
/>
{this.selectedChart && (
{selectedChart && (
<HelmChartDetails
chart={this.selectedChart}
chart={selectedChart}
hideDetails={this.hideDetails}
/>
)}
@ -145,6 +139,8 @@ export const HelmCharts = withInjectables<Dependencies>(
getProps: (di) => ({
routeParameters: di.inject(helmChartsRouteParametersInjectable),
navigateToHelmCharts: di.inject(navigateToHelmChartsInjectable),
charts: di.inject(helmChartsInjectable),
selectedChart: di.inject(selectedHelmChartInjectable),
}),
},
);

View File

@ -0,0 +1,17 @@
/**
* 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 type { HelmChart } from "../../../../common/k8s-api/endpoints/helm-charts.api";
import { listCharts } from "../../../../common/k8s-api/endpoints/helm-charts.api";
export type CallForHelmCharts = () => Promise<HelmChart[]>;
const callForHelmChartsInjectable = getInjectable({
id: "call-for-helm-charts",
instantiate: (): CallForHelmCharts => async () => await listCharts(),
});
export default callForHelmChartsInjectable;

View File

@ -0,0 +1,19 @@
/**
* 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 { asyncComputed } from "@ogre-tools/injectable-react";
import callForHelmChartsInjectable from "./call-for-helm-charts.injectable";
const helmChartsInjectable = getInjectable({
id: "helm-charts",
instantiate: (di) => {
const callForHelmCharts = di.inject(callForHelmChartsInjectable);
return asyncComputed(async () => await callForHelmCharts(), []);
},
});
export default helmChartsInjectable;

View File

@ -0,0 +1,36 @@
/**
* 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 helmChartsRouteParametersInjectable from "../helm-charts-route-parameters.injectable";
import helmChartsInjectable from "./helm-charts.injectable";
const selectedHelmChartInjectable = getInjectable({
id: "selected-helm-chart",
instantiate: (di) => {
const { chartName, repo } = di.inject(helmChartsRouteParametersInjectable);
const helmCharts = di.inject(helmChartsInjectable);
return computed(() => {
const dereferencedChartName = chartName.get();
const deferencedRepository = repo.get();
if (!dereferencedChartName || !deferencedRepository) {
return undefined;
}
return helmCharts.value
.get()
.find(
(chart) =>
chart.getName() === dereferencedChartName &&
chart.getRepository() === deferencedRepository,
);
});
},
});
export default selectedHelmChartInjectable;

View File

@ -0,0 +1,19 @@
/**
* 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 type { HelmReleaseCreatePayload, HelmReleaseUpdateDetails } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import { createRelease } from "../../../../common/k8s-api/endpoints/helm-releases.api";
export type CallForCreateHelmRelease = (
payload: HelmReleaseCreatePayload
) => Promise<HelmReleaseUpdateDetails>;
const callForCreateHelmReleaseInjectable = getInjectable({
id: "call-for-create-helm-release",
instantiate: (): CallForCreateHelmRelease => createRelease,
causesSideEffects: true,
});
export default callForCreateHelmReleaseInjectable;

View File

@ -6,19 +6,18 @@ import { getInjectable } from "@ogre-tools/injectable";
import type {
HelmReleaseCreatePayload } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import {
createRelease,
} from "../../../../common/k8s-api/endpoints/helm-releases.api";
import releasesInjectable from "../releases.injectable";
import callForCreateHelmReleaseInjectable from "./call-for-create-helm-release.injectable";
const createReleaseInjectable = getInjectable({
id: "create-release",
instantiate: (di) => {
const releases = di.inject(releasesInjectable);
const callForCreateRelease = di.inject(callForCreateHelmReleaseInjectable);
return async (payload: HelmReleaseCreatePayload) => {
const release = await createRelease(payload);
const release = await callForCreateRelease(payload);
releases.invalidate();

View File

@ -13,8 +13,7 @@ import { Notifications } from "../notifications";
import { Button } from "../button";
import { Icon } from "../icon";
import { clipboard } from "electron";
// todo: make as external BrowserWindow (?)
import { kebabCase } from "lodash/fp";
export interface LogsDialogProps extends DialogProps {
title: string;
@ -26,6 +25,7 @@ export function LogsDialog({ title, logs, ...dialogProps }: LogsDialogProps) {
<Dialog
{...dialogProps}
className="LogsDialog"
data-testid={`logs-dialog-for-${kebabCase(title)}`}
>
<Wizard
header={<h5>{title}</h5>}

View File

@ -115,6 +115,7 @@ class NonInjectedDockTab extends React.Component<DockTabProps & Dependencies> {
</Tooltip>
</div>
)}
data-testid={`dock-tab-for-${id}`}
/>
{this.renderMenu(id)}
</>

View File

@ -11,8 +11,8 @@ import { DockTab } from "./dock-tab";
import type { DockTab as DockTabModel } from "./dock/store";
import { TabKind } from "./dock/store";
import { TerminalTab } from "./terminal/dock-tab";
import { useResizeObserver } from "../../hooks";
import { cssVar } from "../../utils";
import { useResizeObserver } from "../../hooks";
export interface DockTabsProps {
tabs: DockTabModel[];

View File

@ -124,7 +124,10 @@ class NonInjectedDock extends React.Component<DockProps & Dependencies> {
if (!isOpen || !selectedTab) return null;
return (
<div className={`tab-content ${selectedTab.kind}`} style={{ flexBasis: height }}>
<div
className={`tab-content ${selectedTab.kind}`}
style={{ flexBasis: height }}
data-testid={`dock-tab-content-for-${selectedTab.id}`}>
{this.renderTab(selectedTab)}
</div>
);

View File

@ -8,3 +8,7 @@
flex: 1;
height: 100%;
}
.hidden {
display: none;
}

View File

@ -22,6 +22,7 @@ export interface EditorPanelProps {
autoFocus?: boolean; // default: true
onChange: MonacoEditorProps["onChange"];
onError?: MonacoEditorProps["onError"];
hidden?: boolean;
}
interface Dependencies {
@ -36,6 +37,7 @@ const NonInjectedEditorPanel = observer(({
autoFocus = true,
className,
onError,
hidden,
}: Dependencies & EditorPanelProps) => {
const editor = createRef<MonacoEditorRef>();
@ -59,7 +61,7 @@ const NonInjectedEditorPanel = observer(({
autoFocus={autoFocus}
id={tabId}
value={value}
className={cssNames(styles.EditorPanel, className)}
className={cssNames(styles.EditorPanel, className, { hidden })}
onChange={onChange}
onError={onError}
ref={editor}

View File

@ -14,9 +14,12 @@ import { Button } from "../button";
import { Icon } from "../icon";
import { Spinner } from "../spinner";
import type { DockStore, TabId } from "./dock/store";
import { Notifications } from "../notifications";
import type { ShowNotification } from "../notifications";
import { withInjectables } from "@ogre-tools/injectable-react";
import dockStoreInjectable from "./dock/store.injectable";
import type { ShowCheckedErrorNotification } from "../notifications/show-checked-error.injectable";
import showSuccessNotificationInjectable from "../notifications/show-success-notification.injectable";
import showCheckedErrorNotificationInjectable from "../notifications/show-checked-error.injectable";
export interface InfoPanelProps extends OptionalProps {
tabId: TabId;
@ -35,10 +38,15 @@ export interface OptionalProps {
showInlineInfo?: boolean;
showNotifications?: boolean;
showStatusPanel?: boolean;
submitTestId?: string;
cancelTestId?: string;
submittingTestId?: string;
}
interface Dependencies {
dockStore: DockStore;
showSuccessNotification: ShowNotification;
showCheckedErrorNotification: ShowCheckedErrorNotification;
}
@observer
@ -82,11 +90,11 @@ class NonInjectedInfoPanel extends Component<InfoPanelProps & Dependencies> {
const result = await this.props.submit?.();
if (showNotifications && result) {
Notifications.ok(result);
this.props.showSuccessNotification(result);
}
} catch (error) {
if (showNotifications) {
Notifications.checkedError(error, "Unknown error while submitting");
this.props.showCheckedErrorNotification(error, "Unknown error while submitting");
}
} finally {
this.waiting = false;
@ -128,7 +136,7 @@ class NonInjectedInfoPanel extends Component<InfoPanelProps & Dependencies> {
<div className="flex gaps align-center">
{waiting ? (
<>
<Spinner />
<Spinner data-testid={this.props.submittingTestId} />
{" "}
{submittingMessage}
</>
@ -140,7 +148,8 @@ class NonInjectedInfoPanel extends Component<InfoPanelProps & Dependencies> {
<Button
plain
label="Cancel"
onClick={close}
onClick={close}
data-testid={this.props.cancelTestId}
/>
<Button
active
@ -149,6 +158,7 @@ class NonInjectedInfoPanel extends Component<InfoPanelProps & Dependencies> {
label={submitLabel}
onClick={submit}
disabled={isDisabled}
data-testid={this.props.submitTestId}
/>
{showSubmitClose && (
<Button
@ -172,6 +182,8 @@ export const InfoPanel = withInjectables<Dependencies, InfoPanelProps>(
{
getProps: (di, props) => ({
dockStore: di.inject(dockStoreInjectable),
showSuccessNotification: di.inject(showSuccessNotificationInjectable),
showCheckedErrorNotification: di.inject(showCheckedErrorNotificationInjectable),
...props,
}),
},

View File

@ -0,0 +1,19 @@
/**
* 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 { getChartValues } from "../../../../../common/k8s-api/endpoints/helm-charts.api";
export type CallForHelmChartValues = (
repo: string,
name: string,
version: string
) => Promise<string>;
const callForHelmChartValuesInjectable = getInjectable({
id: "call-for-helm-chart-values",
instantiate: (): CallForHelmChartValues => getChartValues,
});
export default callForHelmChartValuesInjectable;

View File

@ -5,52 +5,46 @@
import { getInjectable } from "@ogre-tools/injectable";
import installChartTabStoreInjectable from "./store.injectable";
import type { HelmChart } from "../../../../common/k8s-api/endpoints/helm-charts.api";
import type {
DockTab,
DockTabCreate,
DockTabCreateSpecific } from "../dock/store";
import type { DockTab, DockTabCreateSpecific } from "../dock/store";
import { TabKind } from "../dock/store";
import type { InstallChartTabStore } from "./store";
import createDockTabInjectable from "../dock/create-dock-tab.injectable";
interface Dependencies {
createDockTab: (rawTab: DockTabCreate, addNumber: boolean) => DockTab;
installChartStore: InstallChartTabStore;
}
import getRandomInstallChartTabIdInjectable from "./get-random-install-chart-tab-id.injectable";
export type CreateInstallChartTab = (chart: HelmChart, tabParams?: DockTabCreateSpecific) => DockTab;
const createInstallChartTab = ({ createDockTab, installChartStore }: Dependencies) => (chart: HelmChart, tabParams: DockTabCreateSpecific = {}) => {
const { name, repo, version } = chart;
const tab = createDockTab(
{
title: `Helm Install: ${repo}/${name}`,
...tabParams,
kind: TabKind.INSTALL_CHART,
},
false,
);
installChartStore.setData(tab.id, {
name,
repo,
version,
namespace: "default",
releaseName: "",
description: "",
});
return tab;
};
const createInstallChartTabInjectable = getInjectable({
id: "create-install-chart-tab",
instantiate: (di) => createInstallChartTab({
installChartStore: di.inject(installChartTabStoreInjectable),
createDockTab: di.inject(createDockTabInjectable),
}),
instantiate: (di) => {
const installChartStore = di.inject(installChartTabStoreInjectable);
const createDockTab = di.inject(createDockTabInjectable);
const getRandomId = di.inject(getRandomInstallChartTabIdInjectable);
return (chart: HelmChart, tabParams: DockTabCreateSpecific = {}) => {
const { name, repo, version } = chart;
const tab = createDockTab(
{
id: getRandomId(),
title: `Helm Install: ${repo}/${name}`,
...tabParams,
kind: TabKind.INSTALL_CHART,
},
false,
);
installChartStore.setData(tab.id, {
name,
repo,
version,
namespace: "default",
releaseName: "",
description: "",
});
return tab;
};
},
});
export default createInstallChartTabInjectable;

View File

@ -0,0 +1,13 @@
/**
* 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 getRandomIdInjectable from "../../../../common/utils/get-random-id.injectable";
const getRandomInstallChartTabIdInjectable = getInjectable({
id: "get-random-install-chart-tab-id",
instantiate: (di) => di.inject(getRandomIdInjectable),
});
export default getRandomInstallChartTabIdInjectable;

View File

@ -0,0 +1,281 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import installChartTabStoreInjectable from "./store.injectable";
import { waitUntilDefined } from "../../../../common/utils";
import type { CallForHelmChartValues } from "./chart-data/call-for-helm-chart-values.injectable";
import callForHelmChartValuesInjectable from "./chart-data/call-for-helm-chart-values.injectable";
import type { IChartInstallData, InstallChartTabStore } from "./store";
import type { HelmChart } from "../../../../common/k8s-api/endpoints/helm-charts.api";
import React from "react";
import {
action,
computed,
observable,
runInAction,
} from "mobx";
import assert from "assert";
import type { CallForCreateHelmRelease } from "../../+helm-releases/create-release/call-for-create-helm-release.injectable";
import callForCreateHelmReleaseInjectable from "../../+helm-releases/create-release/call-for-create-helm-release.injectable";
import type { HelmReleaseUpdateDetails } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import dockStoreInjectable from "../dock/store.injectable";
import type { NavigateToHelmReleases } from "../../../../common/front-end-routing/routes/cluster/helm/releases/navigate-to-helm-releases.injectable";
import navigateToHelmReleasesInjectable from "../../../../common/front-end-routing/routes/cluster/helm/releases/navigate-to-helm-releases.injectable";
import type { SingleValue } from "react-select";
import type { CallForHelmChartVersions } from "../../+helm-charts/details/versions/call-for-helm-chart-versions.injectable";
import callForHelmChartVersionsInjectable from "../../+helm-charts/details/versions/call-for-helm-chart-versions.injectable";
const installChartModelInjectable = getInjectable({
id: "install-chart-model",
instantiate: async (di, tabId: string) => {
const store = di.inject(installChartTabStoreInjectable);
const callForHelmChartValues = di.inject(callForHelmChartValuesInjectable);
const callForHelmChartVersions = di.inject(callForHelmChartVersionsInjectable);
const callForCreateHelmRelease = di.inject(callForCreateHelmReleaseInjectable);
const dockStore = di.inject(dockStoreInjectable);
const navigateToHelmReleases = di.inject(navigateToHelmReleasesInjectable);
const closeTab = () => dockStore.closeTab(tabId);
const callChartByTab = async () => await waitUntilDefined(() => store.getData(tabId));
const model = new InstallChartModel({
tabId,
callChartByTab,
callForCreateHelmRelease,
closeTab,
navigateToHelmReleases,
callForHelmChartValues,
callForHelmChartVersions,
store,
});
await model.load();
return model;
},
lifecycle: lifecycleEnum.keyedSingleton({
getInstanceKey: (di, tabId: string) => tabId,
}),
});
export default installChartModelInjectable;
interface Dependencies {
tabId: string;
closeTab: () => void;
navigateToHelmReleases: NavigateToHelmReleases;
callChartByTab: (tabId: string) => Promise<IChartInstallData>;
callForCreateHelmRelease: CallForCreateHelmRelease;
callForHelmChartValues: CallForHelmChartValues;
callForHelmChartVersions: CallForHelmChartVersions;
store: InstallChartTabStore;
}
export class InstallChartModel {
readonly namespace = {
value: computed(() => this.chart?.namespace || "default"),
onChange: action(
(option: SingleValue<{ label: string; value: string }>) => {
if (option) {
const namespace = option.value;
this.save({ namespace });
}
},
),
};
readonly customName = {
value: computed(() => this.chart?.releaseName || ""),
onChange: action((customName: string) => {
this.save({ releaseName: customName });
}),
};
private readonly versions = observable.array<HelmChart>([]);
readonly installed = observable.box<HelmReleaseUpdateDetails | undefined>();
private save = (data: Partial<IChartInstallData>) => {
assert(this.chart);
const chart = { ...this.chart, ...data };
this.dependencies.store.setData(this.dependencies.tabId, chart);
};
readonly version = {
value: computed(() => this.chart?.version),
onChange: async (version: string | undefined) => {
assert(this.chart);
if (!version) {
return;
}
this.save({ version });
runInAction(() => {
this.configuration.isLoading.set(true);
});
const configuration = await this.dependencies.callForHelmChartValues(
this.chart.repo,
this.chart.name,
version,
);
runInAction(() => {
this.configuration.onChange(configuration);
this.configuration.isLoading.set(false);
});
},
options: computed(() =>
this.versions.map((chart) => ({
label: chart.version,
value: chart.version,
})),
),
};
readonly configuration = {
value: computed(() => this.chart?.values || ""),
isLoading: observable.box(false),
onChange: action((configuration: string) => {
this.errorInConfiguration.value.set(undefined);
this.save({ values: configuration });
}),
};
readonly errorInConfiguration = {
value: observable.box<string | undefined>(),
onChange: action((error: unknown) => {
this.errorInConfiguration.value.set(error as string);
}),
};
readonly executionOutput = {
isShown: observable.box(false),
show: action(() => {
this.executionOutput.isShown.set(true);
}),
close: action(() => {
this.executionOutput.isShown.set(false);
}),
};
constructor(private readonly dependencies: Dependencies) {}
@computed
private get chart() {
const chart = this.dependencies.store.getData(this.dependencies.tabId);
assert(chart);
return chart;
}
load = async () => {
const tabId = this.dependencies.tabId;
await this.dependencies.callChartByTab(tabId);
const [defaultConfiguration, versions] = await Promise.all([
this.dependencies.callForHelmChartValues(
this.chart.repo,
this.chart.name,
this.chart.version,
),
this.dependencies.callForHelmChartVersions(
this.chart.repo,
this.chart.name,
),
]);
runInAction(() => {
// TODO: Make "default" not hard-coded
const namespace = this.chart.namespace || "default";
this.versions.replace(versions);
this.save({
version: this.chart.version,
namespace,
values: this.chart.values || defaultConfiguration,
releaseName: this.chart.releaseName,
});
});
};
@computed
get isValid() {
return !this.configuration.isLoading.get();
}
get chartName() {
return `${this.repository}/${this.name}`;
}
private get name() {
assert(this.chart);
return this.chart.name;
}
private get repository() {
assert(this.chart);
return this.chart.repo;
}
install = async () => {
const installed = await this.dependencies.callForCreateHelmRelease({
name: this.customName.value.get() || undefined,
chart: this.name,
repo: this.repository,
namespace: this.namespace.value.get() || "",
version: this.version.value.get() || "",
values: this.configuration.value.get() || "",
});
runInAction(() => {
this.installed.set(installed);
});
return (
<p>
{"Chart Release "}
<b>{installed.release.name}</b>
{" successfully created."}
</p>
);
};
navigateToInstalledRelease = () => {
const installed = this.installed.get();
assert(installed);
const release = installed.release;
this.dependencies.navigateToHelmReleases({
name: release.name,
namespace: release.namespace,
});
this.dependencies.closeTab();
};
}

View File

@ -3,13 +3,10 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { action, makeObservable } from "mobx";
import type { TabId } from "../dock/store";
import { makeObservable } from "mobx";
import type { DockTabStoreDependencies } from "../dock-tab-store/dock-tab.store";
import { DockTabStore } from "../dock-tab-store/dock-tab.store";
import { getChartDetails, getChartValues } from "../../../../common/k8s-api/endpoints/helm-charts.api";
import type { HelmReleaseUpdateDetails } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import { waitUntilDefined } from "../../../../common/utils/wait";
export interface IChartInstallData {
name: string;
@ -40,42 +37,4 @@ export class InstallChartTabStore extends DockTabStore<IChartInstallData> {
get details() {
return this.dependencies.detailsStore;
}
@action
async loadData(tabId: string) {
const promises = [];
const data = await waitUntilDefined(() => this.getData(tabId));
if (!this.getData(tabId)?.values) {
promises.push(this.loadValues(tabId));
}
if (!this.versions.getData(tabId)) {
promises.push(this.loadVersions(tabId, data));
}
await Promise.all(promises);
}
@action
private async loadVersions(tabId: TabId, { repo, name, version }: IChartInstallData) {
this.versions.clearData(tabId); // reset
const charts = await getChartDetails(repo, name, { version });
const versions = charts.versions.map(chartVersion => chartVersion.version);
this.versions.setData(tabId, versions);
}
@action
async loadValues(tabId: TabId, attempt = 0): Promise<void> {
const data = await waitUntilDefined(() => this.getData(tabId));
const { repo, name, version } = data;
const values = await getChartValues(repo, name, version);
if (values) {
this.setData(tabId, { ...data, values });
} else if (attempt < 4) {
return this.loadValues(tabId, attempt + 1);
}
}
}

View File

@ -5,154 +5,44 @@
import "./install-chart.scss";
import React, { Component } from "react";
import { action, makeObservable, observable } from "mobx";
import React from "react";
import { observer } from "mobx-react";
import type { DockStore, DockTab } from "../dock/store";
import type { DockTab } from "../dock/store";
import { InfoPanel } from "../info-panel";
import { Badge } from "../../badge";
import { NamespaceSelect } from "../../+namespaces/namespace-select";
import { prevDefault } from "../../../utils";
import type { IChartInstallData, InstallChartTabStore } from "./store";
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";
import type { HelmReleaseCreatePayload, HelmReleaseUpdateDetails } from "../../../../common/k8s-api/endpoints/helm-releases.api";
import { withInjectables } from "@ogre-tools/injectable-react";
import installChartTabStoreInjectable from "./store.injectable";
import dockStoreInjectable from "../dock/store.injectable";
import createReleaseInjectable from "../../+helm-releases/create-release/create-release.injectable";
import { Notifications } from "../../notifications";
import type { NavigateToHelmReleases } from "../../../../common/front-end-routing/routes/cluster/helm/releases/navigate-to-helm-releases.injectable";
import navigateToHelmReleasesInjectable from "../../../../common/front-end-routing/routes/cluster/helm/releases/navigate-to-helm-releases.injectable";
import assert from "assert";
import type { SingleValue } from "react-select";
import type { InstallChartModel } from "./install-chart-model.injectable";
import installChartModelInjectable from "./install-chart-model.injectable";
import { Spinner } from "../../spinner";
export interface InstallCharProps {
tab: DockTab;
}
interface Dependencies {
createRelease: (payload: HelmReleaseCreatePayload) => Promise<HelmReleaseUpdateDetails>;
installChartStore: InstallChartTabStore;
dockStore: DockStore;
navigateToHelmReleases: NavigateToHelmReleases;
model: InstallChartModel;
}
@observer
class NonInjectedInstallChart extends Component<InstallCharProps & Dependencies> {
@observable error = "";
@observable showNotes = false;
const NonInjectedInstallChart = observer(
({ model: model, tab: { id: tabId }}: InstallCharProps & Dependencies) => {
const installed = model.installed.get();
constructor(props: InstallCharProps & Dependencies) {
super(props);
makeObservable(this);
}
componentDidMount(): void {
this.props.installChartStore.loadData(this.tabId)
.catch(err => Notifications.error(String(err)));
}
get chartData() {
return this.props.installChartStore.getData(this.tabId);
}
get tabId() {
return this.props.tab.id;
}
get versions() {
return this.props.installChartStore.versions.getData(this.tabId);
}
get releaseDetails() {
return this.props.installChartStore.details.getData(this.tabId);
}
viewRelease = ({ release }: HelmReleaseUpdateDetails) => {
this.props.navigateToHelmReleases({
name: release.name,
namespace: release.namespace,
});
this.props.dockStore.closeTab(this.tabId);
};
save(data: Partial<IChartInstallData>) {
assert(this.chartData, "Cannot update data before data exists");
this.props.installChartStore.setData(this.tabId, { ...this.chartData, ...data });
}
onVersionChange = (option: SingleValue<SelectOption<string>>) => {
if (option) {
this.save({ ...option, values: "" });
this.props.installChartStore.loadValues(this.tabId);
}
};
onChange = action((values: string) => {
this.error = "";
this.save({ values });
});
onError = action((error: Error | string) => {
this.error = error.toString();
});
onNamespaceChange = (option: SingleValue<SelectOption<string>>) => {
if (option) {
this.save({ namespace: option.value });
}
};
onReleaseNameChange = (name: string) => {
this.save({ releaseName: name });
};
install = async ({ repo, name, version, namespace, values = "", releaseName }: IChartInstallData) => {
const details = await this.props.createRelease({
name: releaseName || undefined,
chart: name,
repo,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
namespace: namespace!,
version,
values,
});
this.props.installChartStore.details.setData(this.tabId, details);
return (
<p>
{"Chart Release "}
<b>{details.release.name}</b>
{" successfully created."}
</p>
);
};
render() {
const { tabId, chartData, versions, install, releaseDetails } = this;
if (chartData?.values === undefined || !versions) {
return <Spinner center />;
}
if (releaseDetails) {
if (installed) {
return (
<div className="InstallChartDone flex column gaps align-center justify-center">
<p>
<Icon
material="check"
big
sticker
/>
sticker />
</p>
<p>Installation complete!</p>
<div className="flex gaps align-center">
@ -160,30 +50,34 @@ class NonInjectedInstallChart extends Component<InstallCharProps & Dependencies>
autoFocus
primary
label="View Helm Release"
onClick={prevDefault(() => this.viewRelease(releaseDetails))}
onClick={prevDefault(model.navigateToInstalledRelease)}
data-testid={`show-release-${installed.release.name}-for-${tabId}`}
/>
<Button
plain
active
label="Show Notes"
onClick={() => this.showNotes = true}
onClick={model.executionOutput.show}
data-testid={`show-execution-output-for-${installed.release.name}-in-${tabId}`}
/>
</div>
<LogsDialog
title="Helm Chart Install"
isOpen={this.showNotes}
close={() => this.showNotes = false}
logs={releaseDetails.log}
isOpen={model.executionOutput.isShown.get()}
close={model.executionOutput.close}
logs={installed.log}
/>
</div>
);
}
const { repo, name, version, namespace, releaseName } = chartData;
const versionOptions = versions.map(version => ({
value: version,
label: version,
}));
const {
configuration,
version,
namespace,
customName,
errorInConfiguration,
} = model;
return (
<div className="InstallChart flex column">
@ -192,60 +86,77 @@ class NonInjectedInstallChart extends Component<InstallCharProps & Dependencies>
controls={(
<div className="install-controls flex gaps align-center">
<span>Chart</span>
<Badge label={`${repo}/${name}`} title="Repo/Name" />
<Badge label={model.chartName} title="Repo/Name" />
<span>Version</span>
<Select
className="chart-version"
value={version}
options={versionOptions}
onChange={this.onVersionChange}
value={version.value.get()}
options={version.options.get()}
onChange={(changed) => version.onChange(changed?.value)}
menuPlacement="top"
themeName="outlined"
id={`install-chart-version-select-for-${tabId}`}
/>
<span>Namespace</span>
<NamespaceSelect
showIcons={false}
menuPlacement="top"
themeName="outlined"
value={namespace}
onChange={this.onNamespaceChange}
value={namespace.value.get()}
onChange={namespace.onChange}
id={`install-chart-namespace-select-for-${tabId}`}
/>
<Input
placeholder="Name (optional)"
title="Release name"
maxLength={50}
value={releaseName}
onChange={this.onReleaseNameChange}
value={customName.value.get()}
onChange={customName.onChange}
data-testid={`install-chart-custom-name-input-for-${tabId}`}
/>
</div>
)}
error={this.error}
submit={() => install(chartData)}
disableSubmit={!chartData.namespace}
error={errorInConfiguration.value.get()}
submit={model.install}
disableSubmit={!model.isValid} // !namespace
submitLabel="Install"
submittingMessage="Installing..."
showSubmitClose={false}
cancelTestId={`cancel-install-chart-from-tab-for-${tabId}`}
submitTestId={`install-chart-from-tab-for-${tabId}`}
submittingTestId={`installing-chart-from-tab-${tabId}`}
/>
{configuration.isLoading.get() && (
<Spinner center data-testid="install-chart-configuration-spinner" />
)}
<EditorPanel
tabId={tabId}
value={chartData.values}
onChange={this.onChange}
onError={this.onError}
value={configuration.value.get()}
onChange={configuration.onChange}
onError={errorInConfiguration.onChange}
hidden={configuration.isLoading.get()}
/>
</div>
);
}
}
},
);
export const InstallChart = withInjectables<Dependencies, InstallCharProps>(
NonInjectedInstallChart,
{
getProps: (di, props) => ({
createRelease: di.inject(createReleaseInjectable),
installChartStore: di.inject(installChartTabStoreInjectable),
dockStore: di.inject(dockStoreInjectable),
navigateToHelmReleases: di.inject(navigateToHelmReleasesInjectable),
getPlaceholder: () => (
<Spinner
center
data-testid="install-chart-tab-spinner"
/>
),
getProps: async (di, props) => ({
model: await di.inject(installChartModelInjectable, props.tab.id),
...props,
}),
},

View File

@ -19,15 +19,19 @@ export interface TableRowProps<Item> extends React.DOMAttributes<HTMLDivElement>
sortItem?: Item; // data for sorting callback in <Table sortable={}/>
searchItem?: Item; // data for searching filters in <Table searchable={}/>
disabled?: boolean;
testId?: string;
}
export class TableRow<Item> extends React.Component<TableRowProps<Item>> {
render() {
const { className, nowrap, selected, disabled, children, sortItem, searchItem, ...rowProps } = this.props;
const { className, nowrap, selected, disabled, children, sortItem, searchItem, testId, ...rowProps } = this.props;
const classNames = cssNames("TableRow", className, { selected, nowrap, disabled });
return (
<div className={classNames} {...rowProps}>
<div
className={classNames}
data-testid={testId}
{...rowProps}>
{children}
</div>
);