mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
chore: Simplify extension dependency injection
- Has better typing
- Removes use of unnusual unique symbol
- Fix welcome banner tests
- Update associated snapshots
- Start converting custom column tests to use ApplicationBuilder
- Remove old and unnused RecursiveTreeView
- Introduce new TreeView for use in CatalogMenu to fix tests
Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
1 parent
5db8fc1342
commit
82bf67cc9e
70 files changed
+7110
-2945
No files matched your search
+2
-1
@@ -13,6 +13,7 @@ import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
|
||||
import type { CatalogEntityStore } from "../catalog-entity-store.injectable";
|
||||
import catalogEntityStoreInjectable from "../catalog-entity-store.injectable";
|
||||
import { noop } from "@k8slens/utilities";
|
||||
import type { CatalogEntityRegistry } from "../../../api/catalog/entity/registry";
|
||||
|
||||
class TestEntityOne extends CatalogEntity {
|
||||
public static readonly apiVersion: string = "entity.k8slens.dev/v1alpha1";
|
||||
@@ -152,7 +153,7 @@ describe("CatalogEntityStore", () => {
|
||||
getItemsForCategory: <T extends CatalogEntity>(category: CatalogCategory): T[] => {
|
||||
return entityItems.filter(item => category.spec.versions.some(version => item instanceof version.entityClass)) as T[];
|
||||
},
|
||||
} as any));
|
||||
} as CatalogEntityRegistry));
|
||||
|
||||
store = di.inject(catalogEntityStoreInjectable);
|
||||
});
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import type { DiContainer } from "@ogre-tools/injectable";
|
||||
import type { CatalogCategorySpec } from "../../../../common/catalog";
|
||||
import { LensRendererExtension } from "../../../../extensions/lens-renderer-extension";
|
||||
import { CatalogCategory } from "../../../api/catalog-entity";
|
||||
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
|
||||
import type { AdditionalCategoryColumnRegistration, CategoryColumnRegistration } from "../custom-category-columns";
|
||||
import type { CategoryColumns, GetCategoryColumnsParams } from "../columns/get.injectable";
|
||||
import getCategoryColumnsInjectable from "../columns/get.injectable";
|
||||
import extensionInjectable from "../../../../extensions/extension-loader/extension/extension.injectable";
|
||||
import currentlyInClusterFrameInjectable from "../../../routes/currently-in-cluster-frame.injectable";
|
||||
|
||||
class TestCategory extends CatalogCategory {
|
||||
apiVersion = "catalog.k8slens.dev/v1alpha1";
|
||||
kind = "CatalogCategory";
|
||||
metadata = {
|
||||
name: "Test",
|
||||
icon: "question_mark",
|
||||
};
|
||||
spec: CatalogCategorySpec = {
|
||||
group: "foo.bar.bat",
|
||||
names: {
|
||||
kind: "Test",
|
||||
},
|
||||
versions: [],
|
||||
};
|
||||
|
||||
constructor(columns?: CategoryColumnRegistration[]) {
|
||||
super();
|
||||
this.spec = {
|
||||
displayColumns: columns,
|
||||
...this.spec,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe("Custom Category Columns", () => {
|
||||
let di: DiContainer;
|
||||
let getCategoryColumns: (params: GetCategoryColumnsParams) => CategoryColumns;
|
||||
|
||||
beforeEach(() => {
|
||||
di = getDiForUnitTesting();
|
||||
|
||||
di.override(currentlyInClusterFrameInjectable, () => false);
|
||||
|
||||
getCategoryColumns = di.inject(getCategoryColumnsInjectable);
|
||||
});
|
||||
|
||||
describe("without extensions", () => {
|
||||
it("should contain a kind column if activeCategory is falsy", () => {
|
||||
expect(getCategoryColumns({ activeCategory: null }).renderTableHeader.find(elem => elem?.title === "Kind")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not contain a kind column if activeCategory is truthy", () => {
|
||||
expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem?.title === "Kind")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should include the default columns if the provided category doesn't provide any", () => {
|
||||
expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem?.title === "Source")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not include the default columns if the provided category provides any", () => {
|
||||
expect(getCategoryColumns({ activeCategory: new TestCategory([]) }).renderTableHeader.find(elem => elem?.title === "Source")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("should include the displayColumns from the provided category", () => {
|
||||
const columns: CategoryColumnRegistration[] = [
|
||||
{
|
||||
id: "foo",
|
||||
renderCell: () => null,
|
||||
titleProps: {
|
||||
title: "Foo",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
expect(getCategoryColumns({ activeCategory: new TestCategory(columns) }).renderTableHeader.find(elem => elem?.title === "Foo")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("with extensions", () => {
|
||||
beforeEach(() => {
|
||||
const ext = di.inject(extensionInjectable, new (class extends LensRendererExtension {
|
||||
additionalCategoryColumns = [
|
||||
{
|
||||
group: "foo.bar.bat",
|
||||
id: "high",
|
||||
kind: "Test",
|
||||
renderCell: () => "",
|
||||
titleProps: {
|
||||
title: "High",
|
||||
},
|
||||
} as AdditionalCategoryColumnRegistration,
|
||||
{
|
||||
group: "foo.bar",
|
||||
id: "high",
|
||||
kind: "Test",
|
||||
renderCell: () => "",
|
||||
titleProps: {
|
||||
title: "High2",
|
||||
},
|
||||
} as AdditionalCategoryColumnRegistration,
|
||||
];
|
||||
})({
|
||||
absolutePath: "/some-absolute-path",
|
||||
id: "some-id",
|
||||
isBundled: false,
|
||||
isCompatible: true,
|
||||
isEnabled: true,
|
||||
manifest: {
|
||||
engines: {
|
||||
lens: "",
|
||||
},
|
||||
name: "some-extension-name",
|
||||
version: "1.0.0",
|
||||
},
|
||||
manifestPath: "/some-manifest-path",
|
||||
}));
|
||||
|
||||
ext.register();
|
||||
});
|
||||
|
||||
it("should include columns from extensions that match", () => {
|
||||
expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem?.title === "High")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should not include columns from extensions that don't match", () => {
|
||||
expect(getCategoryColumns({ activeCategory: new TestCategory() }).renderTableHeader.find(elem => elem?.title === "High2")).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,7 @@ const catalogEntityStoreInjectable = getInjectable({
|
||||
const catalogCategoryRegistry = di.inject(catalogCategoryRegistryInjectable);
|
||||
const selectedCatalogEntityParam = di.inject(selectedCatalogEntityParamInjectable);
|
||||
|
||||
const activeCategory = observable.box<CatalogCategory | undefined>(undefined);
|
||||
const activeCategory = observable.box<CatalogCategory>();
|
||||
const entities = computed(() => {
|
||||
const category = activeCategory.get();
|
||||
|
||||
|
||||
@@ -7,25 +7,24 @@ import treeStyles from "./catalog-tree.module.scss";
|
||||
import styles from "./catalog-menu.module.scss";
|
||||
|
||||
import React from "react";
|
||||
import type { TreeItemProps } from "@material-ui/lab";
|
||||
import { TreeItem, TreeView } from "@material-ui/lab";
|
||||
import { Icon } from "../icon";
|
||||
import { StylesProvider } from "@material-ui/core";
|
||||
import { cssNames } from "@k8slens/utilities";
|
||||
import type { CatalogCategory } from "../../api/catalog-entity";
|
||||
import { observer } from "mobx-react";
|
||||
import { CatalogCategoryLabel } from "./catalog-category-label";
|
||||
import type { IComputedValue } from "mobx";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import filteredCategoriesInjectable from "../../../common/catalog/filtered-categories.injectable";
|
||||
import { TreeGroup, TreeItem, TreeView } from "../tree-view/tree-view";
|
||||
import { browseCatalogTab } from "./catalog-browse-tab";
|
||||
import { HorizontalLine } from "../horizontal-line/horizontal-line";
|
||||
|
||||
export interface CatalogMenuProps {
|
||||
activeTab: string | undefined;
|
||||
onItemClick: (id: string) => void;
|
||||
}
|
||||
|
||||
function getCategoryIcon(category: CatalogCategory) {
|
||||
const { icon } = category.metadata ?? {};
|
||||
function CategoryIcon(props: { category: CatalogCategory }) {
|
||||
const { icon } = props.category.metadata ?? {};
|
||||
|
||||
if (typeof icon === "string") {
|
||||
return Icon.isSvg(icon)
|
||||
@@ -36,12 +35,6 @@ function getCategoryIcon(category: CatalogCategory) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function Item(props: TreeItemProps) {
|
||||
return (
|
||||
<TreeItem classes={treeStyles} {...props}/>
|
||||
);
|
||||
}
|
||||
|
||||
interface Dependencies {
|
||||
filteredCategories: IComputedValue<CatalogCategory[]>;
|
||||
}
|
||||
@@ -51,42 +44,36 @@ const NonInjectedCatalogMenu = observer(({
|
||||
filteredCategories,
|
||||
onItemClick,
|
||||
}: CatalogMenuProps & Dependencies) => (
|
||||
// Overwrite Material UI styles with injectFirst https://material-ui.com/guides/interoperability/#controlling-priority-4
|
||||
<StylesProvider injectFirst>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className={styles.catalog}>Catalog</div>
|
||||
<TreeView
|
||||
defaultExpanded={["catalog"]}
|
||||
defaultCollapseIcon={<Icon material="expand_more" />}
|
||||
defaultExpandIcon={<Icon material="chevron_right" />}
|
||||
selected={activeTab || "browse"}
|
||||
<div className="flex flex-col w-full">
|
||||
<div className={styles.catalog}>Catalog</div>
|
||||
<TreeView>
|
||||
<TreeItem
|
||||
classes={treeStyles}
|
||||
label="Browse"
|
||||
data-testid="*-tab"
|
||||
onClick={() => onItemClick("*")}
|
||||
selected={activeTab === browseCatalogTab}
|
||||
/>
|
||||
<HorizontalLine size="xxs" />
|
||||
<TreeGroup
|
||||
classes={treeStyles}
|
||||
label={<div className={styles.parent}>Categories</div>}
|
||||
>
|
||||
<Item
|
||||
nodeId="browse"
|
||||
label="Browse"
|
||||
data-testid="*-tab"
|
||||
onClick={() => onItemClick("*")} />
|
||||
<Item
|
||||
nodeId="catalog"
|
||||
label={<div className={styles.parent}>Categories</div>}
|
||||
className={cssNames(styles.bordered)}
|
||||
>
|
||||
{
|
||||
filteredCategories.get()
|
||||
.map(category => (
|
||||
<Item
|
||||
icon={getCategoryIcon(category)}
|
||||
key={category.getId()}
|
||||
nodeId={category.getId()}
|
||||
label={<CatalogCategoryLabel category={category} />}
|
||||
data-testid={`${category.getId()}-tab`}
|
||||
onClick={() => onItemClick(category.getId())} />
|
||||
))
|
||||
}
|
||||
</Item>
|
||||
</TreeView>
|
||||
</div>
|
||||
</StylesProvider>
|
||||
{filteredCategories.get()
|
||||
.map(category => (
|
||||
<TreeItem
|
||||
classes={treeStyles}
|
||||
key={category.getId()}
|
||||
icon={<CategoryIcon category={category} />}
|
||||
label={<CatalogCategoryLabel category={category} />}
|
||||
selected={activeTab === category.getId()}
|
||||
data-testid={`${category.getId()}-tab`}
|
||||
onClick={() => onItemClick(category.getId())}
|
||||
/>
|
||||
))}
|
||||
</TreeGroup>
|
||||
</TreeView>
|
||||
</div>
|
||||
));
|
||||
|
||||
export const CatalogMenu = withInjectables<Dependencies, CatalogMenuProps>(NonInjectedCatalogMenu, {
|
||||
|
||||
@@ -23,39 +23,8 @@
|
||||
.content {
|
||||
min-height: 26px;
|
||||
line-height: 1.3;
|
||||
padding: 2px var(--padding) 2px 0;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--sidebarItemHoverBackground);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&:active {
|
||||
color: white;
|
||||
background-color: var(--blue);
|
||||
}
|
||||
}
|
||||
|
||||
.group {
|
||||
margin-left: 0px;
|
||||
|
||||
.iconContainer {
|
||||
margin-left: 28px;
|
||||
margin-top: 2px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
& > *:first-child {
|
||||
background-color: var(--blue);
|
||||
color: white;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.iconContainer {
|
||||
width: 21px;
|
||||
margin-left: 5px;
|
||||
margin-right: 0;
|
||||
}
|
||||
@@ -135,16 +135,19 @@ class NonInjectedCatalog extends React.Component<Dependencies> {
|
||||
}
|
||||
}, { fireImmediately: true }),
|
||||
// If active category is filtered out, automatically switch to the first category
|
||||
reaction(() => catalogCategoryRegistry.filteredItems, () => {
|
||||
if (!catalogCategoryRegistry.filteredItems.find(item => item.getId() === catalogEntityStore.activeCategory.get()?.getId())) {
|
||||
const item = catalogCategoryRegistry.filteredItems[0];
|
||||
reaction(() => [...catalogCategoryRegistry.filteredItems], (categories) => {
|
||||
const currentCategory = catalogEntityStore.activeCategory.get();
|
||||
const someCategory = categories[0];
|
||||
|
||||
runInAction(() => {
|
||||
if (item) {
|
||||
this.activeTab = item.getId();
|
||||
this.props.catalogEntityStore.activeCategory.set(item);
|
||||
}
|
||||
});
|
||||
if (this.routeActiveTab === browseCatalogTab || !someCategory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentCategoryShouldBeShown = Boolean(categories.find(item => item.getId() === someCategory.getId()));
|
||||
|
||||
if (!currentCategory || !currentCategoryShouldBeShown) {
|
||||
this.activeTab = someCategory.getId();
|
||||
this.props.catalogEntityStore.activeCategory.set(someCategory);
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -17,6 +17,7 @@ const defaultBrowseAllColumns: RegisteredAdditionalCategoryColumn[] = [
|
||||
id: "kind",
|
||||
sortBy: "kind",
|
||||
title: "Kind",
|
||||
"data-testid": "catalog-kind-column",
|
||||
},
|
||||
sortCallback: entity => entity.kind,
|
||||
},
|
||||
|
||||
@@ -25,6 +25,7 @@ const defaultCategoryColumnsInjectable = getInjectable({
|
||||
className: styles.sourceCell,
|
||||
id: "source",
|
||||
sortBy: "source",
|
||||
"data-testid": "catalog-source-column",
|
||||
},
|
||||
sortCallback: entity => entity.getSource(),
|
||||
searchFilter: entity => `source=${entity.getSource()}`,
|
||||
@@ -37,6 +38,7 @@ const defaultCategoryColumnsInjectable = getInjectable({
|
||||
id: "labels",
|
||||
title: "Labels",
|
||||
className: `${styles.labelsCell} scrollable`,
|
||||
"data-testid": "catalog-labels-column",
|
||||
},
|
||||
searchFilter: entity => KubeObject.stringifyLabels(entity.metadata.labels),
|
||||
},
|
||||
@@ -53,6 +55,7 @@ const defaultCategoryColumnsInjectable = getInjectable({
|
||||
className: styles.statusCell,
|
||||
id: "status",
|
||||
sortBy: "status",
|
||||
"data-testid": "catalog-status-column",
|
||||
},
|
||||
searchFilter: entity => entity.status.phase,
|
||||
sortCallback: entity => entity.status.phase,
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { TableCellProps } from "../table";
|
||||
export interface TitleCellProps {
|
||||
className?: string;
|
||||
title: string;
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
export interface CategoryColumnRegistration {
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { screen } from "@testing-library/react";
|
||||
import "@testing-library/jest-dom/extend-expect";
|
||||
import { defaultWidth, Welcome } from "../welcome";
|
||||
import { computed } from "mobx";
|
||||
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
|
||||
import type { DiRender } from "../../test-utils/renderFor";
|
||||
import { renderFor } from "../../test-utils/renderFor";
|
||||
import type { DiContainer } from "@ogre-tools/injectable";
|
||||
import rendererExtensionsInjectable from "../../../../extensions/renderer-extensions.injectable";
|
||||
import { LensRendererExtension } from "../../../../extensions/lens-renderer-extension";
|
||||
import type { WelcomeBannerRegistration } from "../welcome-banner-items/welcome-banner-registration";
|
||||
import currentlyInClusterFrameInjectable from "../../../routes/currently-in-cluster-frame.injectable";
|
||||
|
||||
describe("<Welcome/>", () => {
|
||||
let render: DiRender;
|
||||
let di: DiContainer;
|
||||
let welcomeBannersStub: WelcomeBannerRegistration[];
|
||||
|
||||
beforeEach(() => {
|
||||
di = getDiForUnitTesting();
|
||||
|
||||
di.override(currentlyInClusterFrameInjectable, () => false);
|
||||
|
||||
render = renderFor(di);
|
||||
welcomeBannersStub = [];
|
||||
|
||||
di.override(rendererExtensionsInjectable, () =>
|
||||
computed(() => [
|
||||
new TestExtension({
|
||||
id: "some-id",
|
||||
welcomeBanners: welcomeBannersStub,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders <Banner /> registered in WelcomeBannerRegistry and hide logo", async () => {
|
||||
const testId = "testId";
|
||||
|
||||
welcomeBannersStub.push({
|
||||
Banner: () => <div data-testid={testId} />,
|
||||
});
|
||||
|
||||
const { container } = render(<Welcome />);
|
||||
|
||||
expect(screen.queryByTestId(testId)).toBeInTheDocument();
|
||||
expect(container.getElementsByClassName("logo").length).toBe(0);
|
||||
});
|
||||
|
||||
it("calculates max width from WelcomeBanner.width registered in WelcomeBannerRegistry", async () => {
|
||||
welcomeBannersStub.push({
|
||||
width: 100,
|
||||
Banner: () => <div />,
|
||||
});
|
||||
|
||||
welcomeBannersStub.push({
|
||||
width: 800,
|
||||
Banner: () => <div />,
|
||||
});
|
||||
|
||||
render(<Welcome />);
|
||||
|
||||
expect(screen.queryByTestId("welcome-banner-container")).toHaveStyle({
|
||||
// should take the max width of the banners (if > defaultWidth)
|
||||
width: `800px`,
|
||||
});
|
||||
expect(screen.queryByTestId("welcome-text-container")).toHaveStyle({
|
||||
width: `${defaultWidth}px`,
|
||||
});
|
||||
expect(screen.queryByTestId("welcome-menu-container")).toHaveStyle({
|
||||
width: `${defaultWidth}px`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class TestExtension extends LensRendererExtension {
|
||||
constructor({
|
||||
id,
|
||||
welcomeBanners,
|
||||
}: {
|
||||
id: string;
|
||||
welcomeBanners: WelcomeBannerRegistration[];
|
||||
}) {
|
||||
super({
|
||||
id,
|
||||
absolutePath: "irrelevant",
|
||||
isBundled: false,
|
||||
isCompatible: false,
|
||||
isEnabled: false,
|
||||
manifest: { name: id, version: "some-version", engines: { lens: "^5.5.0" }},
|
||||
manifestPath: "irrelevant",
|
||||
});
|
||||
|
||||
this.welcomeBanners = welcomeBanners;
|
||||
}
|
||||
}
|
||||
@@ -71,7 +71,11 @@ const NonInjectedWelcome = observer(({
|
||||
))}
|
||||
</Carousel>
|
||||
) : (
|
||||
<Icon svg="logo-lens" className="logo" />
|
||||
<Icon
|
||||
svg="logo-lens"
|
||||
className="logo"
|
||||
data-testid="no-welcome-banners-icon"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center">
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { getInjectionToken } from "@ogre-tools/injectable";
|
||||
import type { IComputedValue } from "mobx";
|
||||
import type React from "react";
|
||||
|
||||
interface WorkloadOverviewDetail {
|
||||
export interface WorkloadOverviewDetail {
|
||||
orderNumber: number;
|
||||
Component: React.ElementType<{}>;
|
||||
enabled: IComputedValue<boolean>;
|
||||
|
||||
+15
-29
@@ -16,43 +16,29 @@ const workloadOverviewDetailRegistratorInjectable = getInjectable({
|
||||
instantiate: (di) => {
|
||||
const getRandomId = di.inject(getRandomIdInjectable);
|
||||
|
||||
const getExtensionShouldBeEnabledForClusterFrame = (
|
||||
extension: LensRendererExtension,
|
||||
) =>
|
||||
di.inject(extensionShouldBeEnabledForClusterFrameInjectable, extension);
|
||||
|
||||
return (ext) => {
|
||||
const extension = ext as LensRendererExtension;
|
||||
const extensionShouldBeEnabledForClusterFrame = di.inject(extensionShouldBeEnabledForClusterFrameInjectable, extension);
|
||||
|
||||
const extensionShouldBeEnabledForClusterFrame =
|
||||
getExtensionShouldBeEnabledForClusterFrame(extension);
|
||||
return extension.kubeWorkloadsOverviewItems.map((registration) => getInjectable({
|
||||
id: `workload-overview-detail-from-${extension.sanitizedExtensionId}-${getRandomId()}`,
|
||||
|
||||
return extension.kubeWorkloadsOverviewItems.map((registration) => {
|
||||
const id = `workload-overview-detail-from-${
|
||||
extension.sanitizedExtensionId
|
||||
}-${getRandomId()}`;
|
||||
instantiate: () => ({
|
||||
Component: registration.components.Details,
|
||||
|
||||
return getInjectable({
|
||||
id,
|
||||
enabled: computed(() => {
|
||||
if (!extensionShouldBeEnabledForClusterFrame.value.get()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
instantiate: () => ({
|
||||
Component: registration.components.Details,
|
||||
|
||||
enabled: computed(() => {
|
||||
if (!extensionShouldBeEnabledForClusterFrame.value.get()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return registration.visible ? registration.visible.get() : true;
|
||||
}),
|
||||
|
||||
orderNumber:
|
||||
0.5 + (registration.priority ? 100 - registration.priority : 50),
|
||||
return registration.visible ? registration.visible.get() : true;
|
||||
}),
|
||||
|
||||
injectionToken: workloadOverviewDetailInjectionToken,
|
||||
});
|
||||
});
|
||||
orderNumber: 0.5 + (registration.priority ? 100 - registration.priority : 50),
|
||||
}),
|
||||
|
||||
injectionToken: workloadOverviewDetailInjectionToken,
|
||||
}));
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import { HotbarMenu } from "../hotbar/hotbar-menu";
|
||||
import { DeleteClusterDialog } from "../delete-cluster-dialog";
|
||||
import { withInjectables } from "@ogre-tools/injectable-react";
|
||||
import { TopBar } from "../layout/top-bar/top-bar";
|
||||
import catalogPreviousActiveTabStorageInjectable from "../+catalog/catalog-previous-active-tab-storage/catalog-previous-active-tab-storage.injectable";
|
||||
import type { IComputedValue } from "mobx";
|
||||
import currentRouteComponentInjectable from "../../routes/current-route-component.injectable";
|
||||
import welcomeRouteInjectable from "../../../common/front-end-routing/routes/welcome/welcome-route.injectable";
|
||||
@@ -21,10 +20,8 @@ import { buildURL } from "@k8slens/utilities";
|
||||
import type { WatchForGeneralEntityNavigation } from "../../api/helpers/watch-for-general-entity-navigation.injectable";
|
||||
import watchForGeneralEntityNavigationInjectable from "../../api/helpers/watch-for-general-entity-navigation.injectable";
|
||||
import currentPathInjectable from "../../routes/current-path.injectable";
|
||||
import type { StorageLayer } from "../../utils/storage-helper";
|
||||
|
||||
interface Dependencies {
|
||||
catalogPreviousActiveTabStorage: StorageLayer<string | null>;
|
||||
currentRouteComponent: IComputedValue<React.ElementType | undefined>;
|
||||
welcomeUrl: string;
|
||||
watchForGeneralEntityNavigation: WatchForGeneralEntityNavigation;
|
||||
@@ -84,7 +81,6 @@ class NonInjectedClusterManager extends React.Component<Dependencies> {
|
||||
|
||||
export const ClusterManager = withInjectables<Dependencies>(NonInjectedClusterManager, {
|
||||
getProps: (di) => ({
|
||||
catalogPreviousActiveTabStorage: di.inject(catalogPreviousActiveTabStorageInjectable),
|
||||
currentRouteComponent: di.inject(currentRouteComponentInjectable),
|
||||
welcomeUrl: buildURL(di.inject(welcomeRouteInjectable).path),
|
||||
watchForGeneralEntityNavigation: di.inject(watchForGeneralEntityNavigationInjectable),
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
|
||||
$baseline: 8px;
|
||||
|
||||
@include horizontalLineSize('xxs', 0.5 * $baseline);
|
||||
@include horizontalLineSize('xs', 1 * $baseline);
|
||||
@include horizontalLineSize('sm', 2 * $baseline);
|
||||
@include horizontalLineSize('md', 3 * $baseline);
|
||||
@include horizontalLineSize('lg', 4 * $baseline);
|
||||
@include horizontalLineSize('xl', 5 * $baseline);
|
||||
@@ -7,7 +7,7 @@ import styles from "./horizontal-line.module.scss";
|
||||
import { cssNames } from "@k8slens/utilities";
|
||||
|
||||
interface HorizontalLineProps {
|
||||
size?: "sm" | "md" | "xl";
|
||||
size?: "xxs" | "xs" | "sm" | "md" | "lg" | "xl";
|
||||
}
|
||||
|
||||
export const HorizontalLine = ({ size = "xl" }: HorizontalLineProps = { size: "xl" }) => {
|
||||
|
||||
@@ -25,6 +25,8 @@ import createEditResourceTabInjectable from "../dock/edit-resource/edit-resource
|
||||
import hideDetailsInjectable from "../kube-detail-params/hide-details.injectable";
|
||||
import { kubeObjectMenuItemInjectionToken } from "./kube-object-menu-item-injection-token";
|
||||
import activeEntityInternalClusterInjectable from "../../api/catalog/entity/get-active-cluster-entity.injectable";
|
||||
import directoryForTempInjectable from "../../../common/app-paths/directory-for-temp/directory-for-temp.injectable";
|
||||
import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
|
||||
|
||||
// TODO: make `animated={false}` not required to make tests deterministic
|
||||
describe("kube-object-menu", () => {
|
||||
@@ -34,6 +36,9 @@ describe("kube-object-menu", () => {
|
||||
beforeEach(() => {
|
||||
di = getDiForUnitTesting();
|
||||
|
||||
di.override(directoryForUserDataInjectable, () => "/some-directory-for-user-data");
|
||||
di.override(directoryForTempInjectable, () => "/some-directory-for-temp");
|
||||
|
||||
runInAction(() => {
|
||||
di.register(
|
||||
someMenuItemInjectable,
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom/extend-expect";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import { ScrollSpy } from "../scroll-spy";
|
||||
import { RecursiveTreeView } from "../../tree-view";
|
||||
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
|
||||
import { type DiRender, renderFor } from "../../test-utils/renderFor";
|
||||
|
||||
const observe = jest.fn();
|
||||
|
||||
Object.defineProperty(window, "IntersectionObserver", {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation(() => ({
|
||||
observe,
|
||||
unobserve: jest.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
describe("<ScrollSpy/>", () => {
|
||||
let render: DiRender;
|
||||
|
||||
beforeEach(() => {
|
||||
const di = getDiForUnitTesting();
|
||||
|
||||
render = renderFor(di);
|
||||
});
|
||||
|
||||
it("renders w/o errors", () => {
|
||||
const { container } = render((
|
||||
<ScrollSpy
|
||||
render={() => (
|
||||
<div>
|
||||
<section id="application">
|
||||
<h1>Application</h1>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(container).toBeInstanceOf(HTMLElement);
|
||||
});
|
||||
|
||||
it("calls intersection observer", () => {
|
||||
render((
|
||||
<ScrollSpy
|
||||
render={() => (
|
||||
<div>
|
||||
<section id="application">
|
||||
<h1>Application</h1>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(observe).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders dataTree component", async () => {
|
||||
render((
|
||||
<ScrollSpy
|
||||
render={dataTree => (
|
||||
<div>
|
||||
<nav>
|
||||
<RecursiveTreeView data={dataTree}/>
|
||||
</nav>
|
||||
<section id="application">
|
||||
<h1>Application</h1>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(await screen.findByTestId("TreeView")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("throws if no sections founded", () => {
|
||||
// Prevent writing to stderr during this render.
|
||||
const err = console.error;
|
||||
|
||||
console.error = jest.fn();
|
||||
|
||||
expect(() => render((
|
||||
<ScrollSpy
|
||||
render={() => (
|
||||
<div>
|
||||
Content
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
))).toThrow();
|
||||
|
||||
// Restore writing to stderr.
|
||||
console.error = err;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("<TreeView/> dataTree inside <ScrollSpy/>", () => {
|
||||
let render: DiRender;
|
||||
|
||||
beforeEach(() => {
|
||||
const di = getDiForUnitTesting();
|
||||
|
||||
render = renderFor(di);
|
||||
});
|
||||
|
||||
it("contains links to all sections", async () => {
|
||||
render((
|
||||
<ScrollSpy
|
||||
render={dataTree => (
|
||||
<div>
|
||||
<nav>
|
||||
<RecursiveTreeView data={dataTree}/>
|
||||
</nav>
|
||||
<section id="application">
|
||||
<h1>Application</h1>
|
||||
<section id="appearance">
|
||||
<h2>Appearance</h2>
|
||||
</section>
|
||||
<section id="theme">
|
||||
<h2>Theme</h2>
|
||||
<div>description</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(await screen.findByTitle("Application")).toBeInTheDocument();
|
||||
expect(await screen.findByTitle("Appearance")).toBeInTheDocument();
|
||||
expect(await screen.findByTitle("Theme")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("not showing links to sections without id", async () => {
|
||||
const { queryByTitle } = render((
|
||||
<ScrollSpy
|
||||
render={dataTree => (
|
||||
<div>
|
||||
<nav>
|
||||
<RecursiveTreeView data={dataTree}/>
|
||||
</nav>
|
||||
<section id="application">
|
||||
<h1>Application</h1>
|
||||
<section>
|
||||
<h2>Kubectl</h2>
|
||||
</section>
|
||||
<section id="appearance">
|
||||
<h2>Appearance</h2>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(await screen.findByTitle("Application")).toBeInTheDocument();
|
||||
expect(await screen.findByTitle("Appearance")).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(queryByTitle("Kubectl")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("expands parent sections", async () => {
|
||||
render((
|
||||
<ScrollSpy
|
||||
render={dataTree => (
|
||||
<div>
|
||||
<nav>
|
||||
<RecursiveTreeView data={dataTree}/>
|
||||
</nav>
|
||||
<section id="application">
|
||||
<h1>Application</h1>
|
||||
<section id="appearance">
|
||||
<h2>Appearance</h2>
|
||||
</section>
|
||||
<section id="theme">
|
||||
<h2>Theme</h2>
|
||||
<div>description</div>
|
||||
</section>
|
||||
</section>
|
||||
<section id="Kubernetes">
|
||||
<h1>Kubernetes</h1>
|
||||
<section id="kubectl">
|
||||
<h2>Kubectl</h2>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(await screen.findByTitle("Application")).toHaveAttribute("aria-expanded");
|
||||
expect(await screen.findByTitle("Kubernetes")).toHaveAttribute("aria-expanded");
|
||||
});
|
||||
|
||||
it("skips sections without headings", async () => {
|
||||
render((
|
||||
<ScrollSpy
|
||||
render={dataTree => (
|
||||
<div>
|
||||
<nav>
|
||||
<RecursiveTreeView data={dataTree}/>
|
||||
</nav>
|
||||
<section id="application">
|
||||
<h1>Application</h1>
|
||||
<section id="appearance">
|
||||
<p>Appearance</p>
|
||||
</section>
|
||||
<section id="theme">
|
||||
<h2>Theme</h2>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
));
|
||||
|
||||
expect(await screen.findByTitle("Application")).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTitle("appearance")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTitle("Appearance")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,14 @@
|
||||
import { observer } from "mobx-react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { useMutationObserver } from "../../hooks";
|
||||
import type { NavigationTree } from "../tree-view";
|
||||
|
||||
export interface NavigationTree {
|
||||
id: string;
|
||||
parentId?: string;
|
||||
name: string;
|
||||
selected?: boolean;
|
||||
children?: NavigationTree[];
|
||||
}
|
||||
|
||||
export interface ScrollSpyProps extends React.DOMAttributes<HTMLElement> {
|
||||
render: (data: NavigationTree[]) => JSX.Element;
|
||||
|
||||
@@ -71,6 +71,11 @@ export interface TableCellProps extends React.DOMAttributes<HTMLDivElement> {
|
||||
* indicator, might come from parent <TableHead>, don't use this prop outside (!)
|
||||
*/
|
||||
_nowrap?: boolean;
|
||||
|
||||
/**
|
||||
* For passing in the testid
|
||||
*/
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
export class TableCell extends React.Component<TableCellProps> {
|
||||
|
||||
@@ -55,7 +55,7 @@ export interface TabProps<D> extends DOMAttributes<HTMLElement> {
|
||||
className?: string;
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
icon?: React.ReactNode | string; // material-ui name or custom icon
|
||||
icon?: React.ReactNode | string; // material-io name or custom icon
|
||||
label?: React.ReactNode;
|
||||
value: D;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ import { applicationWindowInjectionToken } from "../../../main/start-main-applic
|
||||
import closeAllWindowsInjectable from "../../../main/start-main-application/lens-window/hide-all-windows/close-all-windows.injectable";
|
||||
import type { LensWindow } from "../../../main/start-main-application/lens-window/application-window/create-lens-window.injectable";
|
||||
import type { FakeExtensionOptions } from "./get-extension-fake";
|
||||
import { getMainExtensionFakeWith, getRendererExtensionFakeWith } from "./get-extension-fake";
|
||||
import { getExtensionFakeForMain, getExtensionFakeForRenderer } from "./get-extension-fake";
|
||||
import namespaceApiInjectable from "../../../common/k8s-api/endpoints/namespace.api.injectable";
|
||||
import { Namespace } from "../../../common/k8s-api/endpoints";
|
||||
import { getOverrideFsWithFakes } from "../../../test-utils/override-fs-with-fakes";
|
||||
@@ -594,13 +594,13 @@ export const getApplicationBuilder = () => {
|
||||
enable: (...extensions) => {
|
||||
builder.afterWindowStart(action(({ windowDi }) => {
|
||||
extensions
|
||||
.map(getRendererExtensionFakeWith(windowDi))
|
||||
.map(getExtensionFakeForRenderer)
|
||||
.forEach(enableExtensionFor(windowDi, rendererExtensionsStateInjectable));
|
||||
}));
|
||||
|
||||
builder.afterApplicationStart(action(({ mainDi }) => {
|
||||
extensions
|
||||
.map(getMainExtensionFakeWith(mainDi))
|
||||
.map(getExtensionFakeForMain)
|
||||
.forEach(enableExtensionFor(mainDi, mainExtensionsStateInjectable));
|
||||
}));
|
||||
},
|
||||
|
||||
@@ -2,20 +2,8 @@
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
import type { Writable } from "type-fest";
|
||||
import { lensExtensionDependencies } from "../../../extensions/lens-extension";
|
||||
import { LensMainExtension } from "../../../extensions/lens-main-extension";
|
||||
import navigateForExtensionInjectable from "../../../main/start-main-application/lens-window/navigate-for-extension.injectable";
|
||||
import { LensRendererExtension } from "../../../extensions/lens-renderer-extension";
|
||||
import catalogCategoryRegistryInjectable from "../../../common/catalog/category-registry.injectable";
|
||||
import getExtensionPageParametersInjectable from "../../routes/get-extension-page-parameters.injectable";
|
||||
import navigateToRouteInjectable from "../../routes/navigate-to-route.injectable";
|
||||
import routesInjectable from "../../routes/routes.injectable";
|
||||
import catalogEntityRegistryForMainInjectable from "../../../main/catalog/entity-registry.injectable";
|
||||
import catalogEntityRegistryForRendererInjectable from "../../api/catalog/entity/registry.injectable";
|
||||
import type { DiContainer } from "@ogre-tools/injectable";
|
||||
import loggerInjectable from "../../../common/logger.injectable";
|
||||
import ensureHashedDirectoryForExtensionInjectable from "../../../extensions/extension-loader/file-system-provisioner-store/ensure-hashed-directory-for-extension.injectable";
|
||||
|
||||
export class TestExtensionMain extends LensMainExtension {}
|
||||
export class TestExtensionRenderer extends LensRendererExtension {}
|
||||
@@ -27,63 +15,44 @@ export interface FakeExtensionOptions {
|
||||
mainOptions?: Partial<LensMainExtension>;
|
||||
}
|
||||
|
||||
export const getMainExtensionFakeWith = (di: DiContainer) => ({ id, name, mainOptions = {}}: FakeExtensionOptions) => {
|
||||
const instance = new TestExtensionMain({
|
||||
id,
|
||||
absolutePath: "irrelevant",
|
||||
isBundled: false,
|
||||
isCompatible: false,
|
||||
isEnabled: false,
|
||||
manifest: {
|
||||
name,
|
||||
version: "1.0.0",
|
||||
engines: {
|
||||
lens: "^5.5.0",
|
||||
export const getExtensionFakeForMain = ({ id, name, mainOptions = {}}: FakeExtensionOptions) => (
|
||||
Object.assign(
|
||||
new TestExtensionMain({
|
||||
id,
|
||||
absolutePath: "irrelevant",
|
||||
isBundled: false,
|
||||
isCompatible: false,
|
||||
isEnabled: false,
|
||||
manifest: {
|
||||
name,
|
||||
version: "1.0.0",
|
||||
engines: {
|
||||
lens: "^5.5.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
manifestPath: "irrelevant",
|
||||
});
|
||||
manifestPath: "irrelevant",
|
||||
}),
|
||||
mainOptions,
|
||||
)
|
||||
);
|
||||
|
||||
Object.assign(instance, mainOptions);
|
||||
|
||||
(instance as Writable<LensMainExtension>)[lensExtensionDependencies] = {
|
||||
ensureHashedDirectoryForExtension: di.inject(ensureHashedDirectoryForExtensionInjectable),
|
||||
entityRegistry: di.inject(catalogEntityRegistryForMainInjectable),
|
||||
navigate: di.inject(navigateForExtensionInjectable),
|
||||
logger: di.inject(loggerInjectable),
|
||||
};
|
||||
|
||||
return instance;
|
||||
};
|
||||
|
||||
export const getRendererExtensionFakeWith = (di: DiContainer) => ({ id, name, rendererOptions = {}}: FakeExtensionOptions) => {
|
||||
const instance = new TestExtensionRenderer({
|
||||
id,
|
||||
absolutePath: "irrelevant",
|
||||
isBundled: false,
|
||||
isCompatible: false,
|
||||
isEnabled: false,
|
||||
manifest: {
|
||||
name,
|
||||
version: "1.0.0",
|
||||
engines: {
|
||||
lens: "^5.5.0",
|
||||
export const getExtensionFakeForRenderer = ({ id, name, rendererOptions = {}}: FakeExtensionOptions) => (
|
||||
Object.assign(
|
||||
new TestExtensionRenderer({
|
||||
id,
|
||||
absolutePath: "irrelevant",
|
||||
isBundled: false,
|
||||
isCompatible: false,
|
||||
isEnabled: false,
|
||||
manifest: {
|
||||
name,
|
||||
version: "1.0.0",
|
||||
engines: {
|
||||
lens: "^5.5.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
manifestPath: "irrelevant",
|
||||
});
|
||||
|
||||
Object.assign(instance, rendererOptions);
|
||||
|
||||
(instance as Writable<LensRendererExtension>)[lensExtensionDependencies] = {
|
||||
categoryRegistry: di.inject(catalogCategoryRegistryInjectable),
|
||||
entityRegistry: di.inject(catalogEntityRegistryForRendererInjectable),
|
||||
ensureHashedDirectoryForExtension: di.inject(ensureHashedDirectoryForExtensionInjectable),
|
||||
getExtensionPageParameters: di.inject(getExtensionPageParametersInjectable),
|
||||
navigateToRoute: di.inject(navigateToRouteInjectable),
|
||||
routes: di.inject(routesInjectable),
|
||||
logger: di.inject(loggerInjectable),
|
||||
};
|
||||
|
||||
return instance;
|
||||
};
|
||||
manifestPath: "irrelevant",
|
||||
}),
|
||||
rendererOptions,
|
||||
)
|
||||
);
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
export * from "./tree-view";
|
||||
@@ -0,0 +1,63 @@
|
||||
.treeItem {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding: 2px var(--padding) 2px 0;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--sidebarItemHoverBackground);
|
||||
}
|
||||
|
||||
&.selected:hover {
|
||||
background-color: var(--blue);
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
background-color: var(--blue);
|
||||
color: white;
|
||||
border-radius: 2px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.treeGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.contents {
|
||||
padding-left: 25px;
|
||||
transition: all 300ms ease;
|
||||
overflow: hidden;
|
||||
|
||||
&.expanded {
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
&:not(.expanded) {
|
||||
max-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.selected {
|
||||
color: white;
|
||||
background-color: var(--blue);
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.iconContainer {
|
||||
align-self: flex-start;
|
||||
width: 21px;
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.treeView {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
.TreeView {
|
||||
.MuiTypography-body1 {
|
||||
font-size: var(--font-size);
|
||||
color: var(--textColorAccent);
|
||||
}
|
||||
|
||||
.MuiTreeItem-root {
|
||||
> .MuiTreeItem-content .MuiTreeItem-label {
|
||||
border-radius: 4px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
> .MuiTreeItem-content .MuiTreeItem-label {
|
||||
border-color: var(--blue);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
// Make inner component selected state invisible
|
||||
&.Mui-selected, &.Mui-selected:focus {
|
||||
> .MuiTreeItem-content .MuiTreeItem-label {
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,103 +3,153 @@
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import "./tree-view.scss";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { Icon } from "../icon";
|
||||
import TreeView from "@material-ui/lab/TreeView";
|
||||
import TreeItem from "@material-ui/lab/TreeItem";
|
||||
import styles from "./tree-view.module.scss";
|
||||
import type { MouseEventHandler } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { cssNames } from "@k8slens/utilities";
|
||||
import { Icon } from "../icon";
|
||||
|
||||
import _ from "lodash";
|
||||
import getDeepDash from "deepdash";
|
||||
export interface TreeViewClasses {
|
||||
root?: string;
|
||||
}
|
||||
|
||||
const deepDash = getDeepDash(_);
|
||||
export interface TreeViewProps {
|
||||
classes?: TreeViewClasses;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface NavigationTree {
|
||||
id: string;
|
||||
parentId?: string;
|
||||
name: string;
|
||||
export function TreeView(props: TreeViewProps) {
|
||||
const {
|
||||
children,
|
||||
classes = {},
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<ul
|
||||
className={cssNames(classes.root, styles.treeView)}
|
||||
role="tree"
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TreeItemClasses {
|
||||
root?: string;
|
||||
label?: string;
|
||||
selected?: string;
|
||||
hover?: string;
|
||||
iconContainer?: string;
|
||||
}
|
||||
|
||||
export interface TreeItemProps {
|
||||
classes?: TreeItemClasses;
|
||||
icon?: JSX.Element;
|
||||
label: JSX.Element | string;
|
||||
"data-testid"?: string;
|
||||
selected?: boolean;
|
||||
children?: NavigationTree[];
|
||||
onClick?: MouseEventHandler;
|
||||
}
|
||||
|
||||
export interface RecursiveTreeViewProps {
|
||||
data: NavigationTree[];
|
||||
}
|
||||
export function TreeItem(props: TreeItemProps) {
|
||||
const {
|
||||
label,
|
||||
"data-testid": dataTestId,
|
||||
classes = {},
|
||||
icon,
|
||||
onClick,
|
||||
selected = false,
|
||||
} = props;
|
||||
const [hovering, setHovering] = useState(false);
|
||||
const optionalCssNames: Partial<Record<string, any>> = {};
|
||||
|
||||
function scrollToItem(id: string) {
|
||||
document.getElementById(id)?.scrollIntoView();
|
||||
}
|
||||
if (classes.selected) {
|
||||
optionalCssNames[classes.selected] = selected;
|
||||
}
|
||||
|
||||
function getSelectedNode(data: NavigationTree[]) {
|
||||
return deepDash.findDeep(data, (value, key) => key === "selected" && value === true)?.parent;
|
||||
}
|
||||
|
||||
export function RecursiveTreeView({ data }: RecursiveTreeViewProps) {
|
||||
const [expanded, setExpanded] = React.useState<string[]>([]);
|
||||
const prevData = useRef<NavigationTree[]>(data);
|
||||
|
||||
const handleToggle = (event: React.ChangeEvent<{}>, nodeIds: string[]) => {
|
||||
setExpanded(nodeIds);
|
||||
};
|
||||
|
||||
const expandTopLevelNodes = () => {
|
||||
setExpanded(data.map(node => node.id));
|
||||
};
|
||||
|
||||
const expandParentNode = () => {
|
||||
const node = getSelectedNode(data) as any as NavigationTree;
|
||||
const id = node?.parentId;
|
||||
|
||||
if (id && !expanded.includes(id)) {
|
||||
setExpanded([...expanded, id]);
|
||||
}
|
||||
};
|
||||
|
||||
const onLabelClick = (event: React.MouseEvent, nodeId: string) => {
|
||||
event.preventDefault();
|
||||
scrollToItem(nodeId);
|
||||
};
|
||||
|
||||
const renderTree = (nodes: NavigationTree[]) => {
|
||||
return nodes.map(node => (
|
||||
<TreeItem
|
||||
key={node.id}
|
||||
nodeId={node.id}
|
||||
label={node.name}
|
||||
onLabelClick={(event) => onLabelClick(event, node.id)}
|
||||
className={cssNames({ selected: node.selected })}
|
||||
title={node.name}
|
||||
>
|
||||
{Array.isArray(node.children) ? node.children.map((node) => renderTree([node])) : null}
|
||||
</TreeItem>
|
||||
));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!prevData.current.length) {
|
||||
expandTopLevelNodes();
|
||||
} else {
|
||||
expandParentNode();
|
||||
}
|
||||
prevData.current = data;
|
||||
}, [data]);
|
||||
|
||||
if (!data.length) {
|
||||
return null;
|
||||
if (classes.hover) {
|
||||
optionalCssNames[classes.hover] = hovering;
|
||||
}
|
||||
|
||||
return (
|
||||
<TreeView
|
||||
data-testid="TreeView"
|
||||
className="TreeView"
|
||||
expanded={expanded}
|
||||
onNodeToggle={handleToggle}
|
||||
defaultCollapseIcon={<Icon material="expand_more"/>}
|
||||
defaultExpandIcon={<Icon material="chevron_right" />}
|
||||
<li
|
||||
className={cssNames(classes.root, optionalCssNames, styles.treeItem, {
|
||||
[styles.selected]: selected,
|
||||
})}
|
||||
role="treeitem"
|
||||
data-testid={dataTestId}
|
||||
onClick={onClick}
|
||||
onMouseOver={() => setHovering(true)}
|
||||
onMouseLeave={() => setHovering(false)}
|
||||
>
|
||||
{renderTree(data)}
|
||||
</TreeView>
|
||||
<div className={cssNames(classes.iconContainer, styles.iconContainer)}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className={classes.label}>
|
||||
{label}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export interface TreeGroupClasses {
|
||||
root?: string;
|
||||
group?: string;
|
||||
iconContainer?: string;
|
||||
label?: string;
|
||||
contents?: string;
|
||||
}
|
||||
|
||||
export interface TreeGroupProps {
|
||||
classes?: TreeGroupClasses;
|
||||
children?: JSX.Element[] | JSX.Element;
|
||||
defaultExpanded?: boolean;
|
||||
label: JSX.Element | string;
|
||||
"data-testid"?: string;
|
||||
collapseIcon?: JSX.Element;
|
||||
expandIcon?: JSX.Element;
|
||||
}
|
||||
|
||||
export function TreeGroup(props: TreeGroupProps) {
|
||||
const {
|
||||
label,
|
||||
"data-testid": dataTestId,
|
||||
children,
|
||||
classes = {},
|
||||
collapseIcon,
|
||||
defaultExpanded = true,
|
||||
expandIcon,
|
||||
} = props;
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
|
||||
return (
|
||||
<li
|
||||
className={cssNames(classes.root, styles.treeGroup)}
|
||||
role="group"
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
<div
|
||||
className={cssNames(classes.group, styles.group)}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<div className={cssNames(classes.iconContainer, styles.iconContainer)}>
|
||||
{
|
||||
expanded
|
||||
? collapseIcon ?? <Icon material="expand_more" />
|
||||
: expandIcon ?? <Icon material="chevron_right" />
|
||||
}
|
||||
</div>
|
||||
<div className={classes.label}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
<ul
|
||||
className={cssNames(classes.contents, styles.contents, {
|
||||
[styles.expanded]: expanded,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* 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 { Writable } from "type-fest";
|
||||
import catalogCategoryRegistryInjectable from "../../common/catalog/category-registry.injectable";
|
||||
import loggerInjectable from "../../common/logger.injectable";
|
||||
import { createExtensionInstanceInjectionToken } from "../../extensions/extension-loader/create-extension-instance.token";
|
||||
import ensureHashedDirectoryForExtensionInjectable from "../../extensions/extension-loader/file-system-provisioner-store/ensure-hashed-directory-for-extension.injectable";
|
||||
import { lensExtensionDependencies } from "../../extensions/lens-extension";
|
||||
import type { LensRendererExtensionDependencies } from "../../extensions/lens-extension-set-dependencies";
|
||||
import type { LensRendererExtension } from "../../extensions/lens-renderer-extension";
|
||||
import catalogEntityRegistryInjectable from "../api/catalog/entity/registry.injectable";
|
||||
import getExtensionPageParametersInjectable from "../routes/get-extension-page-parameters.injectable";
|
||||
import navigateToRouteInjectable from "../routes/navigate-to-route.injectable";
|
||||
import routesInjectable from "../routes/routes.injectable";
|
||||
|
||||
const createExtensionInstanceInjectable = getInjectable({
|
||||
id: "create-extension-instance",
|
||||
instantiate: (di) => {
|
||||
const deps: LensRendererExtensionDependencies = {
|
||||
categoryRegistry: di.inject(catalogCategoryRegistryInjectable),
|
||||
entityRegistry: di.inject(catalogEntityRegistryInjectable),
|
||||
ensureHashedDirectoryForExtension: di.inject(ensureHashedDirectoryForExtensionInjectable),
|
||||
getExtensionPageParameters: di.inject(getExtensionPageParametersInjectable),
|
||||
navigateToRoute: di.inject(navigateToRouteInjectable),
|
||||
routes: di.inject(routesInjectable),
|
||||
logger: di.inject(loggerInjectable),
|
||||
};
|
||||
|
||||
return (ExtensionClass, extension) => {
|
||||
const instance = new ExtensionClass(extension as any) as LensRendererExtension;
|
||||
|
||||
(instance as Writable<LensRendererExtension>)[lensExtensionDependencies] = deps;
|
||||
|
||||
return instance;
|
||||
};
|
||||
},
|
||||
injectionToken: createExtensionInstanceInjectionToken,
|
||||
});
|
||||
|
||||
export default createExtensionInstanceInjectable;
|
||||
Reference in new issue
Block a user