Replace CatalogEntityDetailRegistry with an injectable solution (#6605)

* Replace EntityDetailRegistry with an injectable solution

- Add some behavioural tests

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Update snapshots

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Fix import error

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Simplify loading extensions

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Fix lint

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Update snapshot

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Remove the last reminents of BaseRegistry

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Fix import errors

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Fix TypeError when loading extensions

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Update snapshots

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Cleanup LensExtensions

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Remove bad comment

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Fix type errors

Signed-off-by: Sebastian Malton <sebastian@malton.name>

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton authored and GitHub committed 2022-12-02 10:31:27 -05:00
1 parent 1acacbb7ef
commit 9ba92cb072
78 files changed
+15151 -573

No files matched your search

-6
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),
@@ -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;
@@ -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>;
+29 -20
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),
}),
});
@@ -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;
@@ -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;
@@ -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;
@@ -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,
});
@@ -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",
});
@@ -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;
+1
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 {}
@@ -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}
@@ -0,0 +1,22 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { IconProps } from "../icon";
import type React from "react";
import type { PageTarget } from "../../routes/page-registration";
import type { IComputedValue } from "mobx";
export interface ClusterPageMenuRegistration {
id?: string;
parentId?: string;
target?: PageTarget;
title: React.ReactNode;
components: ClusterPageMenuComponents;
visible?: IComputedValue<boolean>;
}
export interface ClusterPageMenuComponents {
Icon: React.ComponentType<IconProps>;
}
+15 -4
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
}
+11 -9
View File
@@ -50,6 +50,7 @@ export interface MenuProps {
children?: ReactNode;
animated?: boolean;
toggleEvent?: "click" | "contextmenu";
"data-testid"?: string;
}
interface State {
@@ -325,10 +326,8 @@ 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,
});
@@ -351,12 +350,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 +376,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);
}
}
@@ -2,7 +2,7 @@
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { Mutable } from "type-fest";
import type { Writable } from "type-fest";
import fileSystemProvisionerStoreInjectable from "../../../extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store.injectable";
import { lensExtensionDependencies } from "../../../extensions/lens-extension";
import { LensMainExtension } from "../../../extensions/lens-main-extension";
@@ -15,6 +15,7 @@ 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";
export class TestExtensionMain extends LensMainExtension {}
export class TestExtensionRenderer extends LensRendererExtension {}
@@ -45,12 +46,11 @@ export const getExtensionFakeForMain = (di: DiContainer, id: string, name: strin
Object.assign(instance, options);
(instance as Mutable<LensMainExtension>)[lensExtensionDependencies] = {
fileSystemProvisionerStore: di.inject(
fileSystemProvisionerStoreInjectable,
),
(instance as Writable<LensMainExtension>)[lensExtensionDependencies] = {
fileSystemProvisionerStore: di.inject(fileSystemProvisionerStoreInjectable),
entityRegistry: di.inject(catalogEntityRegistryForMainInjectable),
navigate: di.inject(navigateForExtensionInjectable),
logger: di.inject(loggerInjectable),
};
return instance;
@@ -75,13 +75,14 @@ export const getExtensionFakeForRenderer = (di: DiContainer, id: string, name: s
Object.assign(instance, options);
(instance as Mutable<LensRendererExtension>)[lensExtensionDependencies] = {
(instance as Writable<LensRendererExtension>)[lensExtensionDependencies] = {
categoryRegistry: di.inject(catalogCategoryRegistryInjectable),
entityRegistry: di.inject(catalogEntityRegistryForRendererInjectable),
fileSystemProvisionerStore: di.inject(fileSystemProvisionerStoreInjectable),
getExtensionPageParameters: di.inject(getExtensionPageParametersInjectable),
navigateToRoute: di.inject(navigateToRouteInjectable),
routes: di.inject(routesInjectable),
logger: di.inject(loggerInjectable),
};
return instance;
@@ -3,8 +3,9 @@
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import type { Mutable } from "type-fest";
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 fileSystemProvisionerStoreInjectable from "../../extensions/extension-loader/file-system-provisioner-store/file-system-provisioner-store.injectable";
import { lensExtensionDependencies } from "../../extensions/lens-extension";
@@ -25,12 +26,13 @@ const createExtensionInstanceInjectable = getInjectable({
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 LensRendererExtension;
(instance as Mutable<LensRendererExtension, typeof lensExtensionDependencies>)[lensExtensionDependencies] = deps;
(instance as Writable<LensRendererExtension>)[lensExtensionDependencies] = deps;
return instance;
};
@@ -4,13 +4,13 @@
*/
import { getInjectable } from "@ogre-tools/injectable";
import { initClusterFrame } from "./init-cluster-frame";
import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable";
import catalogEntityRegistryInjectable from "../../../api/catalog/entity/registry.injectable";
import frameRoutingIdInjectable from "./frame-routing-id/frame-routing-id.injectable";
import hostedClusterInjectable from "../../../cluster-frame-context/hosted-cluster.injectable";
import clusterFrameContextInjectable from "../../../cluster-frame-context/cluster-frame-context.injectable";
import assert from "assert";
import emitAppEventInjectable from "../../../../common/app-event-bus/emit-event.injectable";
import loadExtensionsInjectable from "../../load-extensions.injectable";
const initClusterFrameInjectable = getInjectable({
id: "init-cluster-frame",
@@ -22,7 +22,7 @@ const initClusterFrameInjectable = getInjectable({
return initClusterFrame({
hostedCluster,
loadExtensions: di.inject(extensionLoaderInjectable).loadOnClusterRenderer,
loadExtensions: di.inject(loadExtensionsInjectable),
catalogEntityRegistry: di.inject(catalogEntityRegistryInjectable),
frameRoutingId: di.inject(frameRoutingIdInjectable),
emitAppEvent: di.inject(emitAppEventInjectable),
@@ -5,9 +5,7 @@
import type { Cluster } from "../../../../common/cluster/cluster";
import type { CatalogEntityRegistry } from "../../../api/catalog/entity/registry";
import logger from "../../../../main/logger";
import type { KubernetesCluster } from "../../../../common/catalog-entities";
import { Notifications } from "../../../components/notifications";
import type { CatalogEntity } from "../../../../common/catalog";
import { when } from "mobx";
import type { ClusterFrameContext } from "../../../cluster-frame-context/cluster-frame-context";
import { KubeObjectStore } from "../../../../common/k8s-api/kube-object.store";
@@ -16,7 +14,7 @@ import type { EmitAppEvent } from "../../../../common/app-event-bus/emit-event.i
interface Dependencies {
hostedCluster: Cluster;
loadExtensions: (getCluster: () => CatalogEntity) => void;
loadExtensions: () => void;
catalogEntityRegistry: CatalogEntityRegistry;
frameRoutingId: number;
emitAppEvent: EmitAppEvent;
@@ -53,7 +51,7 @@ export const initClusterFrame = ({
when(
() => catalogEntityRegistry.items.get().length > 0,
() =>
loadExtensions(() => catalogEntityRegistry.activeEntity as KubernetesCluster),
loadExtensions(),
{
timeout: 15_000,
onError: (error) => {
@@ -0,0 +1,17 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable } from "@ogre-tools/injectable";
import extensionLoaderInjectable from "../../extensions/extension-loader/extension-loader.injectable";
const loadExtensionsInjectable = getInjectable({
id: "load-extensions",
instantiate: (di) => {
const extensionLoader = di.inject(extensionLoaderInjectable);
return () => extensionLoader.autoInitExtensions();
},
});
export default loadExtensionsInjectable;
@@ -4,17 +4,17 @@
*/
import { getInjectable } from "@ogre-tools/injectable";
import { initRootFrame } from "./init-root-frame";
import extensionLoaderInjectable from "../../../../extensions/extension-loader/extension-loader.injectable";
import ipcRendererInjectable from "../../../utils/channel/ipc-renderer.injectable";
import bindProtocolAddRouteHandlersInjectable from "../../../protocol-handler/bind-protocol-add-route-handlers/bind-protocol-add-route-handlers.injectable";
import lensProtocolRouterRendererInjectable from "../../../protocol-handler/lens-protocol-router-renderer/lens-protocol-router-renderer.injectable";
import catalogEntityRegistryInjectable from "../../../api/catalog/entity/registry.injectable";
import registerIpcListenersInjectable from "../../../ipc/register-ipc-listeners.injectable";
import loadExtensionsInjectable from "../../load-extensions.injectable";
const initRootFrameInjectable = getInjectable({
id: "init-root-frame",
instantiate: (di) => initRootFrame({
loadExtensions: di.inject(extensionLoaderInjectable).loadOnClusterManagerRenderer,
loadExtensions: di.inject(loadExtensionsInjectable),
registerIpcListeners: di.inject(registerIpcListenersInjectable),
ipcRenderer: di.inject(ipcRendererInjectable),
bindProtocolAddRouteHandlers: di.inject(bindProtocolAddRouteHandlersInjectable),
+10 -4
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);
@@ -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>
</>
),
},
},
]);
}
-2
View File
@@ -3,8 +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";
export * from "./catalog-category-registry";
-10
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();
}
@@ -6,10 +6,10 @@ import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import { pipeline } from "@ogre-tools/fp";
import type { PageParamInit } from "../navigation";
import type { LensRendererExtension } from "../../extensions/lens-renderer-extension";
import type { PageRegistration } from "../../extensions/registries";
import { map } from "lodash/fp";
import createPageParamInjectable from "../navigation/create-page-param.injectable";
import { object } from "../utils";
import type { PageRegistration } from "./page-registration";
export interface ExtensionPageParametersInstantiationParam {
extension: LensRendererExtension;
@@ -9,7 +9,6 @@ import type { LensRendererExtension } from "../../extensions/lens-renderer-exten
import { observer } from "mobx-react";
import React from "react";
import { isEmpty, matches } from "lodash/fp";
import type { PageRegistration } from "../../extensions/registries";
import { extensionRegistratorInjectionToken } from "../../extensions/extension-loader/extension-registrator-injection-token";
import { SiblingsInTabLayout } from "../components/layout/siblings-in-tab-layout";
import extensionPageParametersInjectable from "./extension-page-parameters.injectable";
@@ -19,6 +18,7 @@ import { computed } from "mobx";
import { frontEndRouteInjectionToken } from "../../common/front-end-routing/front-end-route-injection-token";
import { getExtensionRoutePath } from "./for-extension";
import extensionShouldBeEnabledForClusterFrameInjectable from "../extension-loader/extension-should-be-enabled-for-cluster-frame.injectable";
import type { PageRegistration } from "./page-registration";
const extensionRouteRegistratorInjectable = getInjectable({
id: "extension-route-registrator",
+47
View File
@@ -0,0 +1,47 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import type { IComputedValue } from "mobx";
import type { PageParamInit, PageParam } from "../navigation";
// Extensions-api -> Custom page registration
export interface PageRegistration {
/**
* Page ID, part of extension's page url, must be unique within same extension
* When not provided, first registered page without "id" would be used for page-menus without target.pageId for same extension
*/
id?: string;
params?: PageParams<string | Omit<PageParamInit<any>, "name" | "prefix">>;
components: PageComponents;
enabled?: IComputedValue<boolean>;
}
export interface PageComponents {
Page: React.ComponentType<any>;
}
export interface PageTarget {
extensionId?: string;
pageId?: string;
params?: PageParams;
}
export interface PageParams<V = any> {
[paramName: string]: V;
}
export interface PageComponentProps<P extends PageParams = {}> {
params?: {
[N in keyof P]: PageParam<P[N]>;
};
}
export interface RegisteredPage {
id: string;
extensionId: string;
url: string; // registered extension's page URL (without page params)
params: PageParams<PageParam<any>>; // normalized params
components: PageComponents; // normalized components
}