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:
Sebastian Malton committed 2023-04-06 09:05:27 -04:00
1 parent 5db8fc1342
commit 82bf67cc9e
70 files changed
+7110 -2945

No files matched your search

@@ -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">
@@ -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>;
@@ -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;