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

Replace EntityDetailRegistry with an injectable solution

- Add some behavioural tests

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2022-11-17 15:17:51 -05:00
parent 5c69b273b1
commit 73eaf5a22c
26 changed files with 7120 additions and 302 deletions

View File

@ -11,5 +11,5 @@ import type {
} from "react-beautiful-dnd";
export const DragDropContext = ({ children }: DragDropContextProps) => <>{ children }</>;
export const Draggable = ({ children }: DraggableProps) => <>{ children }</>;
export const Droppable = ({ children }: DroppableProps) => <>{ children }</>;
export const Draggable = ({ children }: DraggableProps) => <>{ children({} as any, {} as any, {} as any) }</>;
export const Droppable = ({ children }: DroppableProps) => <>{ children({} as any, {} as any) }</>;

View File

@ -3,11 +3,11 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api";
import { Environments, getEnvironmentSpecificLegacyGlobalDiForExtensionApi } from "../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api";
import type { CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog";
import { CatalogCategory, CatalogEntity, categoryVersion } from "../catalog/catalog-entity";
import productNameInjectable from "../vars/product-name.injectable";
import { WeblinkStore } from "../weblink-store";
import weblinkStoreInjectable from "../weblink-store.injectable";
export type WebLinkStatusPhase = "available" | "unavailable";
@ -31,14 +31,15 @@ export class WebLink extends CatalogEntity<CatalogEntityMetadata, WebLinkStatus,
}
onContextMenuOpen(context: CatalogEntityContextMenuContext) {
const di = getLegacyGlobalDiForExtensionApi();
// NOTE: this is safe because `onContextMenuOpen` is only supposed to be called in the renderer
const di = getEnvironmentSpecificLegacyGlobalDiForExtensionApi(Environments.renderer);
const productName = di.inject(productNameInjectable);
if (this.metadata.source === "local") {
context.menuItems.push({
title: "Delete",
icon: "delete",
onClick: async () => WeblinkStore.getInstance().removeById(this.getId()),
onClick: async () => di.inject(weblinkStoreInjectable).removeById(this.getId()),
confirm: {
message: `Remove Web Link "${this.getName()}" from ${productName}?`,
},

View File

@ -12,8 +12,6 @@ import type { Disposer } from "../../common/utils";
import { isDefined, toJS } from "../../common/utils";
import type { InstalledExtension } from "../extension-discovery/extension-discovery";
import type { LensExtension, LensExtensionConstructor, LensExtensionId } from "../lens-extension";
import type { LensRendererExtension } from "../lens-renderer-extension";
import * as registries from "../registries";
import type { LensExtensionState } from "../extensions-store/extensions-store";
import { extensionLoaderFromMainChannel, extensionLoaderFromRendererChannel } from "../../common/ipc/extension-handling";
import { requestExtensionLoaderInitialState } from "../../renderer/ipc";
@ -252,28 +250,13 @@ export class ExtensionLoader {
}
loadOnMain() {
this.autoInitExtensions(() => Promise.resolve([]));
this.autoInitExtensions(async () => []);
}
loadOnClusterManagerRenderer = () => {
this.dependencies.logger.debug(`${logModule}: load on main renderer (cluster manager)`);
return this.autoInitExtensions(async (ext) => {
const extension = ext as LensRendererExtension;
const removeItems = [
registries.CatalogEntityDetailRegistry.getInstance().add(extension.catalogEntityDetailItems),
];
this.onRemoveExtensionId.addListener((removedExtensionId) => {
if (removedExtensionId === extension.id) {
removeItems.forEach(remove => {
remove();
});
}
});
return removeItems;
});
return this.autoInitExtensions(async () => []);
};
loadOnClusterRenderer = () => {

View File

@ -1,38 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type React from "react";
import type { Disposer } from "../../common/utils";
import type { CatalogEntity } from "../common-api/catalog";
import { BaseRegistry } from "./base-registry";
export interface CatalogEntityDetailsProps<T extends CatalogEntity> {
entity: T;
}
export interface CatalogEntityDetailComponents<T extends CatalogEntity> {
Details: React.ComponentType<CatalogEntityDetailsProps<T>>;
}
export interface CatalogEntityDetailRegistration<T extends CatalogEntity> {
kind: string;
apiVersions: string[];
components: CatalogEntityDetailComponents<T>;
priority?: number;
}
export class CatalogEntityDetailRegistry extends BaseRegistry<CatalogEntityDetailRegistration<CatalogEntity>> {
add<T extends CatalogEntity>(items: CatalogEntityDetailRegistration<T> | CatalogEntityDetailRegistration<T>[]): Disposer {
return super.add(items as never);
}
getItemsForKind(kind: string, apiVersion: string) {
const items = this.getItems().filter((item) => {
return item.kind === kind && item.apiVersions.includes(apiVersion);
});
return items.sort((a, b) => (b.priority ?? 50) - (a.priority ?? 50));
}
}

View File

@ -7,5 +7,4 @@
export * from "./page-registry";
export * from "./page-menu-registry";
export * from "./catalog-entity-detail-registry";
export * from "./protocol-handler";

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,223 @@
/**
* 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 { RenderResult } from "@testing-library/react";
import { KubernetesCluster, WebLink } from "../../common/catalog-entities";
import getClusterByIdInjectable from "../../common/cluster-store/get-by-id.injectable";
import type { Cluster } from "../../common/cluster/cluster";
import navigateToCatalogInjectable from "../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable";
import catalogEntityRegistryInjectable from "../../renderer/api/catalog/entity/registry.injectable";
import { type ApplicationBuilder, getApplicationBuilder } from "../../renderer/components/test-utils/get-application-builder";
import createClusterInjectable from "../../renderer/create-cluster/create-cluster.injectable";
describe("opening catalog entity details panel", () => {
let builder: ApplicationBuilder;
let rendered: RenderResult;
let windowDi: DiContainer;
let clusterEntity: KubernetesCluster;
let localClusterEntity: KubernetesCluster;
let otherEntity: WebLink;
let cluster: Cluster;
beforeEach(async () => {
builder = getApplicationBuilder();
builder.afterWindowStart((windowDi) => {
const createCluster = windowDi.inject(createClusterInjectable);
clusterEntity = new KubernetesCluster({
metadata: {
labels: {},
name: "some-kubernetes-cluster",
uid: "some-entity-id",
},
spec: {
kubeconfigContext: "some-context",
kubeconfigPath: "/some/path/to/kubeconfig",
},
status: {
phase: "connecting",
},
});
localClusterEntity = new KubernetesCluster({
metadata: {
labels: {},
name: "some-local-kubernetes-cluster",
uid: "some-entity-id-2",
source: "local",
},
spec: {
kubeconfigContext: "some-context",
kubeconfigPath: "/some/path/to/local/kubeconfig",
},
status: {
phase: "connecting",
},
});
otherEntity = new WebLink({
metadata: {
labels: {},
name: "some-weblink",
uid: "some-weblink-id",
},
spec: {
url: "https://my-websome.com",
},
status: {
phase: "available",
},
});
cluster = createCluster({
contextName: clusterEntity.spec.kubeconfigContext,
id: clusterEntity.getId(),
kubeConfigPath: clusterEntity.spec.kubeconfigPath,
}, {
clusterServerUrl: "https://localhost:9999",
});
// TODO: remove once ClusterStore can be used without overriding it
windowDi.override(getClusterByIdInjectable, () => (clusterId) => {
if (clusterId === cluster.id) {
return cluster;
}
return undefined;
});
// TODO: replace with proper entity source once syncing entities between main and windows is injectable
const catalogEntityRegistry = windowDi.inject(catalogEntityRegistryInjectable);
catalogEntityRegistry.updateItems([clusterEntity, otherEntity, localClusterEntity]);
});
rendered = await builder.render();
windowDi = builder.applicationWindow.only.di;
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("shouldn't show the details yet", () => {
expect(rendered.queryByTestId("catalog-entity-details-drawer")).not.toBeInTheDocument();
});
describe("when navigated to the catalog", () => {
beforeEach(async () => {
const navigateToCatalog = windowDi.inject(navigateToCatalogInjectable);
navigateToCatalog();
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("should show the 'Browse All' view", () => {
expect(rendered.queryByTestId("catalog-list-for-browse-all")).toBeInTheDocument();
});
it("shouldn't show the details yet", () => {
expect(rendered.queryByTestId("catalog-entity-details-drawer")).not.toBeInTheDocument();
});
describe("when opening the menu 'some-kubernetes-cluster'", () => {
beforeEach(() => {
rendered.getByTestId("icon-for-menu-actions-for-catalog-for-some-entity-id").click();
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("opens the menu", () => {
expect(rendered.queryByTestId("menu-actions-for-catalog-for-some-entity-id")).toBeInTheDocument();
});
it("shouldn't show the details yet", () => {
expect(rendered.queryByTestId("catalog-entity-details-drawer")).not.toBeInTheDocument();
});
describe("when clicking the 'View Details' menu item", () => {
beforeEach(() => {
rendered.getByTestId("open-details-menu-item-for-some-entity-id").click();
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
describe("when the panel opens", () => {
beforeEach(async () => {
await rendered.findAllByTestId("catalog-entity-details-drawer");
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("opens the detail panel for the correct item", () => {
expect(rendered.queryByTestId("catalog-entity-details-content-for-some-entity-id")).toBeInTheDocument();
});
it("shows the registered items", () => {
expect(rendered.queryByTestId("kubernetes-distro-for-some-entity-id")).toBeInTheDocument();
});
});
});
});
describe("when opening the menu 'some-weblink'", () => {
beforeEach(() => {
rendered.getByTestId("icon-for-menu-actions-for-catalog-for-some-weblink-id").click();
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("opens the menu", () => {
expect(rendered.queryByTestId("menu-actions-for-catalog-for-some-weblink-id")).toBeInTheDocument();
});
it("shouldn't show the details yet", () => {
expect(rendered.queryByTestId("catalog-entity-details-drawer")).not.toBeInTheDocument();
});
describe("when clicking the 'View Details' menu item", () => {
beforeEach(() => {
rendered.getByTestId("open-details-menu-item-for-some-weblink-id").click();
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
describe("when the panel opens", () => {
beforeEach(async () => {
await rendered.findAllByTestId("catalog-entity-details-drawer");
});
it("renders", () => {
expect(rendered.baseElement).toMatchSnapshot();
});
it("opens the detail panel for the correct item", () => {
expect(rendered.queryByTestId("catalog-entity-details-content-for-some-weblink-id")).toBeInTheDocument();
});
it("shows the registered items", () => {
expect(rendered.queryByTestId("weblink-url-for-some-weblink-id")).toBeInTheDocument();
});
it("should not show registered items for different kinds", () => {
expect(rendered.queryByTestId("kubernetes-distro-for-some-weblink-id")).not.toBeInTheDocument();
});
});
});
});
});
});

View File

@ -94,12 +94,6 @@ export async function bootstrap(di: DiContainer) {
await attachChromeDebugger();
rootElem.classList.toggle("is-mac", isMac);
logger.info(`${logPrefix} initializing Registries`);
initializers.initRegistries();
logger.info(`${logPrefix} initializing CatalogEntityDetailRegistry`);
initializers.initCatalogEntityDetailRegistry();
logger.info(`${logPrefix} initializing CatalogCategoryRegistryEntries`);
initializers.initCatalogCategoryRegistryEntries({
navigateToAddCluster: di.inject(navigateToAddClusterInjectable),

View File

@ -1,124 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import styles from "./catalog-entity-details.module.scss";
import React, { Component } from "react";
import { observer } from "mobx-react";
import { Drawer, DrawerItem } from "../drawer";
import type { CatalogCategory, CatalogEntity } from "../../../common/catalog";
import { Icon } from "../icon";
import { CatalogEntityDrawerMenu } from "./catalog-entity-drawer-menu";
import { CatalogEntityDetailRegistry } from "../../../extensions/registries";
import { isDevelopment } from "../../../common/vars";
import { cssNames } from "../../utils";
import { Avatar } from "../avatar";
import type { GetLabelBadges } from "./get-label-badges.injectable";
import { withInjectables } from "@ogre-tools/injectable-react";
import getLabelBadgesInjectable from "./get-label-badges.injectable";
export interface CatalogEntityDetailsProps<Entity extends CatalogEntity> {
entity: Entity;
hideDetails(): void;
onRun: () => void;
}
interface Dependencies {
getLabelBadges: GetLabelBadges;
}
@observer
class NonInjectedCatalogEntityDetails<Entity extends CatalogEntity> extends Component<CatalogEntityDetailsProps<Entity> & Dependencies> {
categoryIcon(category: CatalogCategory) {
if (Icon.isSvg(category.metadata.icon)) {
return <Icon svg={category.metadata.icon} smallest />;
} else {
return <Icon material={category.metadata.icon} smallest />;
}
}
renderContent(entity: Entity) {
const { onRun, hideDetails, getLabelBadges } = this.props;
const detailItems = CatalogEntityDetailRegistry.getInstance().getItemsForKind(entity.kind, entity.apiVersion);
const details = detailItems.map(({ components }, index) => <components.Details entity={entity} key={index} />);
const showDefaultDetails = detailItems.find((item) => item.priority ?? 50 > 999) === undefined;
return (
<>
{showDefaultDetails && (
<div className="flex">
<div className={styles.entityIcon}>
<Avatar
title={entity.getName()}
colorHash={`${entity.getName()}-${entity.getSource()}`}
size={128}
src={entity.spec.icon?.src}
data-testid="detail-panel-hot-bar-icon"
background={entity.spec.icon?.background}
onClick={onRun}
className={styles.avatar}
>
{entity.spec.icon?.material && <Icon material={entity.spec.icon?.material}/>}
</Avatar>
{entity.isEnabled() && (
<div className={styles.hint}>
Click to open
</div>
)}
</div>
<div className={cssNames("box grow", styles.metadata)}>
<DrawerItem name="Name">
{entity.getName()}
</DrawerItem>
<DrawerItem name="Kind">
{entity.kind}
</DrawerItem>
<DrawerItem name="Source">
{entity.getSource()}
</DrawerItem>
<DrawerItem name="Status">
{entity.status.phase}
</DrawerItem>
<DrawerItem name="Labels">
{getLabelBadges(entity, hideDetails)}
</DrawerItem>
{isDevelopment && (
<DrawerItem name="Id">
{entity.getId()}
</DrawerItem>
)}
</div>
</div>
)}
<div className="box grow">
{details}
</div>
</>
);
}
render() {
const { entity, hideDetails } = this.props;
return (
<Drawer
className={styles.entityDetails}
usePortal={true}
open={true}
title={`${entity.kind}: ${entity.getName()}`}
toolbar={<CatalogEntityDrawerMenu entity={entity} key={entity.getId()} />}
onClose={hideDetails}
>
{this.renderContent(entity)}
</Drawer>
);
}
}
export const CatalogEntityDetails = withInjectables<Dependencies, CatalogEntityDetailsProps<CatalogEntity>>(NonInjectedCatalogEntityDetails, {
getProps: (di, props) => ({
...props,
getLabelBadges: di.inject(getLabelBadgesInjectable),
}),
}) as <Entity extends CatalogEntity>(props: CatalogEntityDetailsProps<Entity>) => React.ReactElement;

View File

@ -10,7 +10,6 @@ import { Catalog } from "./catalog";
import type { CatalogEntityActionContext, CatalogEntityData } from "../../../common/catalog";
import { CatalogEntity } from "../../../common/catalog";
import type { CatalogEntityOnBeforeRun, CatalogEntityRegistry } from "../../api/catalog/entity/registry";
import { CatalogEntityDetailRegistry } from "../../../extensions/registries";
import type { CatalogEntityStore } from "./catalog-entity-store/catalog-entity.store";
import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import type { DiContainer } from "@ogre-tools/injectable";
@ -71,8 +70,6 @@ describe("<Catalog />", () => {
di.permitSideEffects(getConfigurationFileModelInjectable);
CatalogEntityDetailRegistry.createInstance();
render = renderFor(di);
onRun = jest.fn();
catalogEntityItem = createMockCatalogEntity(onRun);
@ -87,10 +84,6 @@ describe("<Catalog />", () => {
});
});
afterEach(() => {
CatalogEntityDetailRegistry.resetInstance();
});
describe("can use catalogEntityRegistry.addOnBeforeRun to add hooks for catalog entities", () => {
let onBeforeRunMock: AsyncFnMock<CatalogEntityOnBeforeRun>;

View File

@ -15,11 +15,11 @@ import { MenuItem, MenuActions } from "../menu";
import type { CatalogEntityContextMenu } from "../../api/catalog-entity";
import type { CatalogCategory, CatalogCategoryRegistry, CatalogEntity } from "../../../common/catalog";
import { CatalogAddButton } from "./catalog-add-button";
import { Notifications } from "../notifications";
import type { ShowNotification } from "../notifications";
import { MainLayout } from "../layout/main-layout";
import type { StorageLayer } from "../../utils";
import { prevDefault } from "../../utils";
import { CatalogEntityDetails } from "./catalog-entity-details";
import { CatalogEntityDetails } from "./entity-details/view";
import { CatalogMenu } from "./catalog-menu";
import { RenderDelay } from "../render-delay/render-delay";
import { Icon } from "../icon";
@ -48,6 +48,9 @@ import type { NormalizeCatalogEntityContextMenu } from "../../catalog/normalize-
import normalizeCatalogEntityContextMenuInjectable from "../../catalog/normalize-menu-item.injectable";
import type { EmitAppEvent } from "../../../common/app-event-bus/emit-event.injectable";
import emitAppEventInjectable from "../../../common/app-event-bus/emit-event.injectable";
import type { Logger } from "../../../common/logger";
import loggerInjectable from "../../../common/logger.injectable";
import showErrorNotificationInjectable from "../notifications/show-error-notification.injectable";
interface Dependencies {
catalogPreviousActiveTabStorage: StorageLayer<string | null>;
@ -65,12 +68,14 @@ interface Dependencies {
visitEntityContextMenu: VisitEntityContextMenu;
navigate: Navigate;
normalizeMenuItem: NormalizeCatalogEntityContextMenu;
showErrorNotification: ShowNotification;
logger: Logger;
}
@observer
class NonInjectedCatalog extends React.Component<Dependencies> {
private readonly menuItems = observable.array<CatalogEntityContextMenu>();
@observable activeTab?: string;
@observable activeTab: string | undefined = undefined;
constructor(props: Dependencies) {
super(props);
@ -79,7 +84,7 @@ class NonInjectedCatalog extends React.Component<Dependencies> {
@computed
get routeActiveTab(): string {
const { group, kind } = this.props.routeParameters;
const { routeParameters: { group, kind }, catalogPreviousActiveTabStorage } = this.props;
const dereferencedGroup = group.get();
const dereferencedKind = kind.get();
@ -88,13 +93,7 @@ class NonInjectedCatalog extends React.Component<Dependencies> {
return `${dereferencedGroup}/${dereferencedKind}`;
}
const previousTab = this.props.catalogPreviousActiveTabStorage.get();
if (previousTab) {
return previousTab;
}
return browseCatalogTab;
return catalogPreviousActiveTabStorage.get() || browseCatalogTab;
}
async componentDidMount() {
@ -102,6 +101,8 @@ class NonInjectedCatalog extends React.Component<Dependencies> {
catalogEntityStore,
catalogPreviousActiveTabStorage,
catalogCategoryRegistry,
logger,
showErrorNotification,
} = this.props;
disposeOnUnmount(this, [
@ -110,7 +111,11 @@ class NonInjectedCatalog extends React.Component<Dependencies> {
catalogPreviousActiveTabStorage.set(this.routeActiveTab);
try {
await when(() => (routeTab === browseCatalogTab || !!catalogCategoryRegistry.filteredItems.find(i => i.getId() === routeTab)), { timeout: 5_000 }); // we need to wait because extensions might take a while to load
if (routeTab !== browseCatalogTab) {
// we need to wait because extensions might take a while to load
await when(() => Boolean(catalogCategoryRegistry.filteredItems.find(i => i.getId() === routeTab)), { timeout: 5_000 });
}
const item = catalogCategoryRegistry.filteredItems.find(i => i.getId() === routeTab);
runInAction(() => {
@ -118,8 +123,8 @@ class NonInjectedCatalog extends React.Component<Dependencies> {
catalogEntityStore.activeCategory.set(item);
});
} catch (error) {
console.error(error);
Notifications.error((
logger.warn("Failed to find route tab", error);
showErrorNotification((
<p>
{"Unknown category: "}
{routeTab}
@ -198,9 +203,14 @@ class NonInjectedCatalog extends React.Component<Dependencies> {
};
return (
<MenuActions id={`menu-actions-for-catalog-for-${entity.getId()}`} onOpen={onOpen}>
<MenuActions
id={`menu-actions-for-catalog-for-${entity.getId()}`}
data-testid={`menu-actions-for-catalog-for-${entity.getId()}`}
onOpen={onOpen}
>
<MenuItem
key="open-details"
data-testid={`open-details-menu-item-for-${entity.getId()}`}
onClick={() => this.props.catalogEntityStore.selectedItemId.set(entity.getId())}
>
View Details
@ -253,7 +263,7 @@ class NonInjectedCatalog extends React.Component<Dependencies> {
renderViews = (activeCategory: CatalogCategory | undefined) => {
if (!activeCategory) {
return this.renderList(activeCategory);
return this.renderList(undefined);
}
const customViews = this.props.customCategoryViews.get()
@ -301,15 +311,12 @@ class NonInjectedCatalog extends React.Component<Dependencies> {
{...getCategoryColumns({ activeCategory })}
onDetails={this.onDetails}
renderItemMenu={this.renderItemMenu}
data-testid={`catalog-list-for-${activeCategory?.metadata.name ?? "browse-all"}`}
/>
);
}
render() {
if (!this.props.catalogEntityStore) {
return null;
}
const activeCategory = this.props.catalogEntityStore.activeCategory.get();
const selectedItem = this.props.catalogEntityStore.selectedItem.get();
@ -362,5 +369,7 @@ export const Catalog = withInjectables<Dependencies>(NonInjectedCatalog, {
visitEntityContextMenu: di.inject(visitEntityContextMenuInjectable),
navigate: di.inject(navigateInjectable),
normalizeMenuItem: di.inject(normalizeCatalogEntityContextMenuInjectable),
logger: di.inject(loggerInjectable),
showErrorNotification: di.inject(showErrorNotificationInjectable),
}),
});

View File

@ -0,0 +1,33 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { computedInjectManyInjectable } from "@ogre-tools/injectable-extension-for-mobx";
import { computed } from "mobx";
import { byOrderNumber } from "../../../../common/utils/composable-responsibilities/orderable/orderable";
import type { CatalogEntity } from "../../../api/catalog-entity";
import { catalogEntityDetailItemInjectionToken } from "./token";
const catalogEntityDetailItemsInjectable = getInjectable({
id: "catalog-entity-detail-items",
instantiate: (di, entity) => {
const computedInjectMany = di.inject(computedInjectManyInjectable);
const detailItems = computedInjectMany(catalogEntityDetailItemInjectionToken);
return computed(() => (
detailItems.get()
.filter(item => (
item.apiVersions.has(entity.apiVersion)
&& item.kind === entity.kind
))
.sort(byOrderNumber)
.map(item => item.components.Details)
));
},
lifecycle: lifecycleEnum.keyedSingleton({
getInstanceKey: (di, entity: CatalogEntity) => `${entity.apiVersion}/${entity.kind}`,
}),
});
export default catalogEntityDetailItemsInjectable;

View File

@ -0,0 +1,39 @@
/**
* 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 React from "react";
import { KubernetesCluster } from "../../../../../common/catalog-entities";
import { DrawerTitle, DrawerItem } from "../../../drawer";
import { catalogEntityDetailItemInjectionToken } from "../token";
const kubernetesClusterDetailsItemInjectable = getInjectable({
id: "kubernetes-cluster-details-item",
instantiate: () => ({
apiVersions: new Set([KubernetesCluster.apiVersion]),
kind: KubernetesCluster.kind,
orderNumber: 40,
components: {
Details: ({ entity }) => (
<>
<DrawerTitle>Kubernetes Information</DrawerTitle>
<div className="box grow EntityMetadata">
<DrawerItem
name="Distribution"
data-testid={`kubernetes-distro-for-${entity.getId()}`}
>
{entity.metadata.distro || "unknown"}
</DrawerItem>
<DrawerItem name="Kubelet Version">
{entity.metadata.kubeVersion || "unknown"}
</DrawerItem>
</div>
</>
),
},
}),
injectionToken: catalogEntityDetailItemInjectionToken,
});
export default kubernetesClusterDetailsItemInjectable;

View File

@ -0,0 +1,34 @@
/**
* 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 React from "react";
import { WebLink } from "../../../../../common/catalog-entities";
import { DrawerTitle, DrawerItem } from "../../../drawer";
import { catalogEntityDetailItemInjectionToken } from "../token";
const weblinkDetailsItemInjectable = getInjectable({
id: "weblink-details-item",
instantiate: () => ({
apiVersions: new Set([WebLink.apiVersion]),
kind: WebLink.kind,
components: {
Details: ({ entity }) => (
<>
<DrawerTitle>More Information</DrawerTitle>
<DrawerItem
name="URL"
data-testid={`weblink-url-for-${entity.getId()}`}
>
{entity.spec.url}
</DrawerItem>
</>
),
},
orderNumber: 40,
}),
injectionToken: catalogEntityDetailItemInjectionToken,
});
export default weblinkDetailsItemInjectable;

View File

@ -0,0 +1,39 @@
/**
* 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 { extensionRegistratorInjectionToken } from "../../../../extensions/extension-loader/extension-registrator-injection-token";
import type { LensRendererExtension } from "../../../../extensions/lens-renderer-extension";
import type { CatalogEntity } from "../../../api/catalog-entity";
import { getRandId } from "../../../utils";
import type { CatalogEntityDetailRegistration } from "./token";
import { catalogEntityDetailItemInjectionToken } from "./token";
const catalogEntityDetailItemsRegistratorInjectable = getInjectable({
id: "catalog-entity-detail-items-registrator",
instantiate: () => (ext) => {
const extension = ext as LensRendererExtension;
return extension.catalogEntityDetailItems.map(getRegistratorFor(extension));
},
injectionToken: extensionRegistratorInjectionToken,
});
export default catalogEntityDetailItemsRegistratorInjectable;
const getRegistratorFor = (extension: LensRendererExtension) => ({
apiVersions,
components,
kind,
priority,
}: CatalogEntityDetailRegistration<CatalogEntity>) => getInjectable({
id: `catalog-entity-detail-item-for-${extension.sanitizedExtensionId}-${getRandId({ sep: "-" })}`,
instantiate: () => ({
apiVersions: new Set(apiVersions),
components,
kind,
orderNumber: priority ?? 50,
}),
injectionToken: catalogEntityDetailItemInjectionToken,
});

View File

@ -0,0 +1,35 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectionToken } from "@ogre-tools/injectable";
import type { CatalogEntity } from "../../../api/catalog-entity";
export interface CatalogEntityDetailsProps<T extends CatalogEntity> {
entity: T;
}
export type CatalogEntityDetailsComponent<T extends CatalogEntity> = React.ComponentType<CatalogEntityDetailsProps<T>>;
export interface CatalogEntityDetailComponents<T extends CatalogEntity> {
Details: CatalogEntityDetailsComponent<T>;
}
export interface CatalogEntityDetailRegistration<T extends CatalogEntity> {
kind: string;
apiVersions: string[];
components: CatalogEntityDetailComponents<T>;
priority?: number;
}
export interface CatalogEntityDetailItem {
kind: string;
apiVersions: Set<string>;
components: CatalogEntityDetailComponents<CatalogEntity>;
orderNumber: number;
}
export const catalogEntityDetailItemInjectionToken = getInjectionToken<CatalogEntityDetailItem>({
id: "catalog-entity-detail-item-token",
});

View File

@ -0,0 +1,127 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import styles from "./view.module.scss";
import React, { Component } from "react";
import { observer } from "mobx-react";
import { Drawer, DrawerItem } from "../../drawer";
import type { CatalogCategory, CatalogEntity } from "../../../../common/catalog";
import { Icon } from "../../icon";
import { CatalogEntityDrawerMenu } from "../catalog-entity-drawer-menu";
import { cssNames } from "../../../utils";
import { Avatar } from "../../avatar";
import type { GetLabelBadges } from "../get-label-badges.injectable";
import { withInjectables } from "@ogre-tools/injectable-react";
import getLabelBadgesInjectable from "../get-label-badges.injectable";
import isDevelopmentInjectable from "../../../../common/vars/is-development.injectable";
import type { IComputedValue } from "mobx";
import type { CatalogEntityDetailsComponent } from "./token";
import catalogEntityDetailItemsInjectable from "./detail-items.injectable";
export interface CatalogEntityDetailsProps<Entity extends CatalogEntity> {
entity: Entity;
hideDetails(): void;
onRun: () => void;
}
interface Dependencies {
getLabelBadges: GetLabelBadges;
isDevelopment: boolean;
detailItems: IComputedValue<CatalogEntityDetailsComponent<CatalogEntity>[]>;
}
@observer
class NonInjectedCatalogEntityDetails<Entity extends CatalogEntity> extends Component<CatalogEntityDetailsProps<Entity> & Dependencies> {
categoryIcon(category: CatalogCategory) {
if (Icon.isSvg(category.metadata.icon)) {
return <Icon svg={category.metadata.icon} smallest />;
} else {
return <Icon material={category.metadata.icon} smallest />;
}
}
renderContent(entity: Entity) {
const { onRun, hideDetails, getLabelBadges, isDevelopment, detailItems } = this.props;
const details = detailItems.get().map((Details, index) => <Details entity={entity} key={index} />);
return (
<>
<div className="flex" data-testid={`catalog-entity-details-content-for-${entity.getId()}`}>
<div className={styles.entityIcon}>
<Avatar
title={entity.getName()}
colorHash={`${entity.getName()}-${entity.getSource()}`}
size={128}
src={entity.spec.icon?.src}
data-testid="detail-panel-hot-bar-icon"
background={entity.spec.icon?.background}
onClick={onRun}
className={styles.avatar}
>
{entity.spec.icon?.material && <Icon material={entity.spec.icon?.material}/>}
</Avatar>
{entity.isEnabled() && (
<div className={styles.hint}>
Click to open
</div>
)}
</div>
<div className={cssNames("box grow", styles.metadata)}>
<DrawerItem name="Name">
{entity.getName()}
</DrawerItem>
<DrawerItem name="Kind">
{entity.kind}
</DrawerItem>
<DrawerItem name="Source">
{entity.getSource()}
</DrawerItem>
<DrawerItem name="Status">
{entity.status.phase}
</DrawerItem>
<DrawerItem name="Labels">
{getLabelBadges(entity, hideDetails)}
</DrawerItem>
{isDevelopment && (
<DrawerItem name="Id">
{entity.getId()}
</DrawerItem>
)}
</div>
</div>
<div className="box grow">
{details}
</div>
</>
);
}
render() {
const { entity, hideDetails } = this.props;
return (
<Drawer
className={styles.entityDetails}
usePortal={true}
open={true}
title={`${entity.kind}: ${entity.getName()}`}
toolbar={<CatalogEntityDrawerMenu entity={entity} key={entity.getId()} />}
onClose={hideDetails}
data-testid="catalog-entity-details-drawer"
>
{this.renderContent(entity)}
</Drawer>
);
}
}
export const CatalogEntityDetails = withInjectables<Dependencies, CatalogEntityDetailsProps<CatalogEntity>>(NonInjectedCatalogEntityDetails, {
getProps: (di, props) => ({
...props,
getLabelBadges: di.inject(getLabelBadgesInjectable),
isDevelopment: di.inject(isDevelopmentInjectable),
detailItems: di.inject(catalogEntityDetailItemsInjectable, props.entity),
}),
}) as <Entity extends CatalogEntity>(props: CatalogEntityDetailsProps<Entity>) => React.ReactElement;

View File

@ -153,6 +153,7 @@ export interface BaseIconProps {
focusable?: boolean;
sticker?: boolean;
disabled?: boolean;
"data-testid"?: string;
}
export interface IconProps extends React.HTMLAttributes<any>, BaseIconProps {}

View File

@ -131,6 +131,7 @@ export type ItemListLayoutProps<Item extends ItemObject, PreLoadStores extends b
failedToLoadMessage?: React.ReactNode;
filterCallbacks?: ItemsFilters<Item>;
"data-testid"?: string;
} & (
PreLoadStores extends true
? {
@ -271,11 +272,12 @@ class NonInjectedItemListLayout<I extends ItemObject, PreLoadStores extends bool
}
render() {
const { renderHeaderTitle } = this.props;
const { renderHeaderTitle, "data-testid": dataTestId } = this.props;
return untracked(() => (
<div
className={cssNames("ItemListLayout flex column", this.props.className)}
data-testid={dataTestId}
>
<ItemListLayoutHeader
getItems={() => this.items}

View File

@ -91,15 +91,22 @@ class NonInjectedMenuActions extends React.Component<MenuActionsProps & Dependen
}
renderTriggerIcon() {
if (this.props.toolbar) return null;
const { triggerIcon = "more_vert" } = this.props;
let className: string;
const {
triggerIcon = "more_vert",
toolbar,
"data-testid": dataTestId,
} = this.props;
if (toolbar) {
return null;
}
if (isValidElement<HTMLElement>(triggerIcon)) {
className = cssNames(triggerIcon.props.className, { active: this.isOpen });
const className = cssNames(triggerIcon.props.className, { active: this.isOpen });
return React.cloneElement(triggerIcon, { id: this.props.id, className });
}
const iconProps: IconProps & TooltipDecoratorProps = {
id: this.props.id,
interactive: true,
@ -108,6 +115,10 @@ class NonInjectedMenuActions extends React.Component<MenuActionsProps & Dependen
...(typeof triggerIcon === "object" ? triggerIcon : {}),
};
if (dataTestId) {
iconProps["data-testid"] = `icon-for-${dataTestId}`;
}
if (iconProps.tooltip && this.isOpen) {
delete iconProps.tooltip; // don't show tooltip for icon when menu is open
}

View File

@ -50,6 +50,7 @@ export interface MenuProps {
children?: ReactNode;
animated?: boolean;
toggleEvent?: "click" | "contextmenu";
"data-testid"?: string;
}
interface State {
@ -325,12 +326,11 @@ class NonInjectedMenu extends React.Component<MenuProps & Dependencies, State> {
}
render() {
const { position, id, animated } = this.props;
let { className, usePortal } = this.props;
className = cssNames("Menu", className, this.state.position || position, {
const { position, id, animated, "data-testid": dataTestId, usePortal, className } = this.props;
const classNames = cssNames("Menu", className, this.state.position || position, {
portal: usePortal,
});
// const menuChildren =
let children = this.props.children as ReactElement<any>;
@ -351,12 +351,13 @@ class NonInjectedMenu extends React.Component<MenuProps & Dependencies, State> {
<ul
id={id}
ref={this.bindRef}
className={className}
className={classNames}
style={{
left: this.state?.menuStyle?.left,
top: this.state?.menuStyle?.top,
}}
onKeyDown={this.onKeyDown}
data-testid={dataTestId}
>
{menuItems}
</ul>
@ -376,11 +377,13 @@ class NonInjectedMenu extends React.Component<MenuProps & Dependencies, State> {
</MenuContext.Provider>
);
if (usePortal === true) usePortal = document.body;
if (!usePortal) {
return menu;
}
return usePortal instanceof HTMLElement
? createPortal(menu, usePortal)
: menu;
const portal = usePortal === true ? document.body : usePortal;
return createPortal(menu, portal);
}
}

View File

@ -145,13 +145,19 @@ export const getDiForUnitTesting = (
callForPublicHelmRepositoriesInjectable,
]);
// eslint-disable-next-line unused-imports/no-unused-vars-ts
di.override(extensionsStoreInjectable, () => ({ isEnabled: ({ id, isBundled }) => false }) as ExtensionsStore);
di.override(extensionsStoreInjectable, () => ({
isEnabled: () => false,
}) as Partial<ExtensionsStore> as ExtensionsStore);
di.override(hotbarStoreInjectable, () => ({
getActive: () => ({ name: "some-hotbar", items: [] }),
getActive: () => ({
name: "some-hotbar",
items: [null, null, null, null, null, null, null, null, null, null, null, null],
id: "some-hotbar",
}),
getDisplayIndex: () => "0",
}) as unknown as HotbarStore);
isAddedToActive: () => false,
}) as Partial<HotbarStore> as HotbarStore);
di.override(fileSystemProvisionerStoreInjectable, () => ({}) as FileSystemProvisionerStore);

View File

@ -1,52 +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 { KubernetesCluster, WebLink } from "../../common/catalog-entities";
import type { CatalogEntityDetailsProps } from "../../extensions/registries";
import { CatalogEntityDetailRegistry } from "../../extensions/registries";
import { DrawerItem, DrawerTitle } from "../components/drawer";
export function initCatalogEntityDetailRegistry() {
CatalogEntityDetailRegistry.getInstance()
.add([
{
apiVersions: [KubernetesCluster.apiVersion],
kind: KubernetesCluster.kind,
components: {
Details: ({ entity }: CatalogEntityDetailsProps<KubernetesCluster>) => (
<>
<DrawerTitle>Kubernetes Information</DrawerTitle>
<div className="box grow EntityMetadata">
<DrawerItem name="Distribution">
{entity.metadata.distro || "unknown"}
</DrawerItem>
<DrawerItem name="Kubelet Version">
{entity.metadata.kubeVersion || "unknown"}
</DrawerItem>
</div>
</>
),
},
},
]);
CatalogEntityDetailRegistry.getInstance()
.add([
{
apiVersions: [WebLink.apiVersion],
kind: WebLink.kind,
components: {
Details: ({ entity }: CatalogEntityDetailsProps<WebLink>) => (
<>
<DrawerTitle>More Information</DrawerTitle>
<DrawerItem name="URL">
{entity.spec.url}
</DrawerItem>
</>
),
},
},
]);
}

View File

@ -3,7 +3,6 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
export * from "./catalog-entity-detail-registry";
export * from "./catalog";
export * from "./ipc";
export * from "./registries";

View File

@ -1,10 +0,0 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import * as registries from "../../extensions/registries";
export function initRegistries() {
registries.CatalogEntityDetailRegistry.createInstance();
}