Technical requirements for behavioural unit tests (#5084)

* Implement a lot of technical requirements for behavioural unit tests

Note: the crux of this was to make routing env-agnostic, and not based on URLs as magic strings, but instead something type-enforced.

Note: extension-based routes comply to same exact interface by "late-registering" their routes when installed. Routes are just injectables.

Note: another chunk of global shared state is no more.

Note: a lot of explicit side effects have been cornered to injectables.

Note: a lot of stuff has become reactive as part if this.

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Make a directory commonly available

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Require id for <Select /> to prevent non-deterministic renders

This was caused by global state in a 3rd party lib: "react-select".

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Specify id for all <Select /> to satisfy previous commit

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Prevent explicit side effect in component by using existing dependency instead

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Extract instantiation of "conf" as injectables for causing side effects

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Introduce a legacy-helper to make gradual refactoring of inheritors of Singleton easier

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Make legacy unit tests for hotbar green and more simple by using the new legacy helper

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Temporarily kludge all unit tests green with a disclaimer about allowing side-effects

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Remove kludge in previous commit by explicitly permitting specific side effects where old unit tests require it

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Prevent old unit test with side effects from accessing file system

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Migrate to actual typing for di.permitSideEffects

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Prevent unit tests from failing because of non-standard method of HTML-element not present in js-dom

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Adapt integration tests to recent changes

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Fix code style

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Fix artifact from bad rebase

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Add a deprecation from a review comment

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Remove change that is not required

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove redundant comment

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Fix code style

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove redundant file

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Fix bad merge

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Improve variable name

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Tweak logger interface to be more descriptive

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Make injecting legacy singleton always provide new instance

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove conditional typing when not needed

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Improve naming of variable

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove unnecessary code style changes

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove flag for causing side effects from too broad scope

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Override side-effects in unit test using injectable instead of monkey patching

Co-authored-by: Janne Savolainen <janne.savolainen@live.fi>

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Flag some side-effects and add general overrides

Co-authored-by: Janne Savolainen <janne.savolainen@live.fi>

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Fix unit tests in CI by removing explicit side-effect

Co-authored-by: Janne Savolainen <janne.savolainen@live.fi>

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Remove explicit side-effect from getting default shell

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Introduce abstraction for getting absolute paths

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Switch to using abstraction for getting absolute path to control explicit side effect

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Introduce abstraction for joining paths

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Switch to using abstraction for joining paths to control explicit side effect

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Fix fake implementation for join paths

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Fix test after removing explicit side effect

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove explicit side effects from kubeconfig-syncs

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Fix arguments after removing explicit side effect

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Make registrators not async for not being needed anymore

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Make generalCatalogEntities non-observable, as there is no requirement

Co-authored-by: Janne Savolainen <janne.savolainen@live.fi>

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Remove redundant code

Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com>

Signed-off-by: Iku-turso <mikko.aspiala@gmail.com>

* Simplify logic for registering general catalog entity sources

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Add TODO

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Replace function for getting application menu items with reactive solution

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Fix typo in interface name

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove global shared state usages of hot bar store

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

* Remove redundant enum

Signed-off-by: Janne Savolainen <janne.savolainen@live.fi>

Co-authored-by: Janne Savolainen <janne.savolainen@live.fi>
This commit is contained in:
Iku-tursoandJanne Savolainen authored and GitHub committed 2022-03-31 16:57:05 +03:00
1 parent ab2cd27a27
commit a277cfcf02
583 files changed
+13073 -6556

No files matched your search

@@ -5,17 +5,14 @@
import React from "react";
import "@testing-library/jest-dom/extend-expect";
import { render, fireEvent } from "@testing-library/react";
import { fireEvent } from "@testing-library/react";
import { SidebarCluster } from "../sidebar-cluster";
import { KubernetesCluster } from "../../../../common/catalog-entities";
jest.mock("../../../../common/hotbar-store", () => ({
HotbarStore: {
getInstance: () => ({
isAddedToActive: jest.fn(),
}),
},
}));
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
import type { DiRender } from "../../test-utils/renderFor";
import { renderFor } from "../../test-utils/renderFor";
import hotbarStoreInjectable from "../../../../common/hotbar-store.injectable";
import type { HotbarStore } from "../../../../common/hotbar-store";
const clusterEntity = new KubernetesCluster({
metadata: {
@@ -34,6 +31,18 @@ const clusterEntity = new KubernetesCluster({
});
describe("<SidebarCluster/>", () => {
let render: DiRender;
beforeEach(() => {
const di = getDiForUnitTesting({ doGeneralOverrides: true });
di.override(hotbarStoreInjectable, () => ({
isAddedToActive: () => {},
}) as unknown as HotbarStore);
render = renderFor(di);
});
it("renders w/o errors", () => {
const { container } = render(<SidebarCluster clusterEntity={clusterEntity}/>);
@@ -0,0 +1,83 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { DiContainer, getInjectable } from "@ogre-tools/injectable";
import type { LensRendererExtension } from "../../../extensions/lens-renderer-extension";
import React from "react";
import { extensionRegistratorInjectionToken } from "../../../extensions/extension-loader/extension-registrator-injection-token";
import { SidebarItemRegistration, sidebarItemsInjectionToken } from "./sidebar-items.injectable";
import { computed } from "mobx";
import routesInjectable from "../../routes/routes.injectable";
import { matches, noop } from "lodash/fp";
import routeIsActiveInjectable from "../../routes/route-is-active.injectable";
import { getExtensionRoutePath } from "../../routes/get-extension-route-path";
import { navigateToRouteInjectionToken } from "../../../common/front-end-routing/navigate-to-route-injection-token";
const extensionSidebarItemRegistratorInjectable = getInjectable({
id: "extension-sidebar-item-registrator",
instantiate:
(di: DiContainer) =>
(
extension: LensRendererExtension,
extensionInstallationCount: number,
) => {
const sidebarItemsForExtensionInjectable = getInjectable({
id: `sidebar-items-for-extension-${extension.sanitizedExtensionId}-instance-${extensionInstallationCount}`,
injectionToken: sidebarItemsInjectionToken,
instantiate: (di) => {
const navigateToRoute = di.inject(navigateToRouteInjectionToken);
const routes = di.inject(routesInjectable);
return computed((): SidebarItemRegistration[] => {
const extensionRoutes = routes.get().filter(matches({ extension }));
return extension.clusterPageMenus.map((registration) => {
const targetRoutePath = getExtensionRoutePath(
extension,
registration.target?.pageId,
);
const targetRoute = extensionRoutes.find(
matches({ path: targetRoutePath }),
);
return {
id: `${extension.sanitizedExtensionId}-${registration.id}`,
extension,
orderNumber: 9999,
parentId: registration.parentId
? `${extension.sanitizedExtensionId}-${registration.parentId}`
: null,
title: registration.title,
getIcon: registration.components.Icon
? () => <registration.components.Icon />
: null,
...(targetRoute
? {
onClick: () => navigateToRoute(targetRoute),
isActive: di.inject(
routeIsActiveInjectable,
targetRoute,
),
}
: { onClick: noop }),
};
});
});
},
});
di.register(sidebarItemsForExtensionInjectable);
},
injectionToken: extensionRegistratorInjectionToken,
});
export default extensionSidebarItemRegistratorInjectable;
@@ -8,9 +8,10 @@ import "./setting-layout.scss";
import React from "react";
import { observer } from "mobx-react";
import { cssNames, IClassName } from "../../utils";
import { navigation } from "../../navigation";
import { catalogURL } from "../../../common/routes";
import { CloseButton } from "./close-button";
import { getLegacyGlobalDiForExtensionApi } from "../../../extensions/as-legacy-globals-for-extension-api/legacy-global-di-for-extension-api";
import navigateToCatalogInjectable from "../../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable";
import observableHistoryInjectable from "../../navigation/observable-history.injectable";
export interface SettingLayoutProps extends React.DOMAttributes<any> {
className?: IClassName;
@@ -19,16 +20,22 @@ export interface SettingLayoutProps extends React.DOMAttributes<any> {
contentGaps?: boolean;
navigation?: React.ReactNode;
back?: (evt: React.MouseEvent | KeyboardEvent) => void;
closeButtonProps?: { "data-testid"?: string };
}
const defaultProps: Partial<SettingLayoutProps> = {
closeButtonProps: {},
provideBackButtonNavigation: true,
contentGaps: true,
back: () => {
if (navigation.length <= 1) {
navigation.push(catalogURL());
const di = getLegacyGlobalDiForExtensionApi();
const navigateToCatalog = di.inject(navigateToCatalogInjectable);
const observableHistory = di.inject(observableHistoryInjectable);
if (observableHistory.length <= 1) {
navigateToCatalog();
} else {
navigation.goBack();
observableHistory.goBack();
}
},
};
@@ -68,7 +75,7 @@ export class SettingLayout extends React.Component<SettingLayoutProps> {
render() {
const {
contentClass, provideBackButtonNavigation,
contentGaps, navigation, children, back, ...elemProps
contentGaps, navigation, children, back, closeButtonProps, ...elemProps
} = this.props;
const className = cssNames("SettingLayout", { showNavigation: navigation }, this.props.className);
@@ -89,7 +96,8 @@ export class SettingLayout extends React.Component<SettingLayoutProps> {
{
this.props.provideBackButtonNavigation && (
<div className="fixed top-[60px]">
<CloseButton onClick={back}/>
<CloseButton onClick={back} {...closeButtonProps}/>
</div>
)
}
@@ -0,0 +1,47 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { withInjectables } from "@ogre-tools/injectable-react";
import type { IComputedValue } from "mobx";
import { observer } from "mobx-react";
import React from "react";
import siblingTabsInjectable from "../../routes/sibling-tabs.injectable";
import { TabLayout } from "./tab-layout-2";
import type { HierarchicalSidebarItem } from "./sidebar-items.injectable";
interface SiblingTabLayoutProps {
children: React.ReactNode;
}
interface Dependencies {
tabs: IComputedValue<HierarchicalSidebarItem[]>;
}
const NonInjectedSiblingsInTabLayout = observer(
({ tabs, children }: Dependencies & SiblingTabLayoutProps) => {
const dereferencedTabs = tabs.get();
if (dereferencedTabs.length) {
return (
<TabLayout tabs={dereferencedTabs}
>
{children}
</TabLayout>
);
}
return <>{children}</>;
},
);
export const SiblingsInTabLayout = withInjectables<Dependencies, SiblingTabLayoutProps>(
NonInjectedSiblingsInTabLayout,
{
getProps: (di, props) => ({
tabs: di.inject(siblingTabsInjectable),
...props,
}),
},
);
@@ -6,7 +6,6 @@
import styles from "./sidebar-cluster.module.scss";
import { observable } from "mobx";
import React, { useState } from "react";
import { HotbarStore } from "../../../common/hotbar-store";
import { broadcastMessage } from "../../../common/ipc";
import type { CatalogEntity, CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity";
import { IpcRendererNavigationEvents } from "../../navigation/events";
@@ -16,6 +15,9 @@ import { navigate } from "../../navigation";
import { Menu, MenuItem } from "../menu";
import { ConfirmDialog } from "../confirm-dialog";
import { Tooltip } from "../tooltip";
import { withInjectables } from "@ogre-tools/injectable-react";
import hotbarStoreInjectable from "../../../common/hotbar-store.injectable";
import type { HotbarStore } from "../../../common/hotbar-store";
import { observer } from "mobx-react";
const contextMenu: CatalogEntityContextMenuContext = observable({
@@ -60,7 +62,15 @@ function renderLoadingSidebarCluster() {
);
}
export const SidebarCluster = observer(({ clusterEntity }: { clusterEntity: CatalogEntity }) => {
interface Dependencies {
hotbarStore: HotbarStore;
}
interface SidebarClusterProps {
clusterEntity: CatalogEntity;
}
const NonInjectedSidebarCluster = observer(({ clusterEntity, hotbarStore }: Dependencies & SidebarClusterProps) => {
const [opened, setOpened] = useState(false);
if (!clusterEntity) {
@@ -68,8 +78,7 @@ export const SidebarCluster = observer(({ clusterEntity }: { clusterEntity: Cata
}
const onMenuOpen = () => {
const hotbarStore = HotbarStore.getInstance();
const isAddedToActive = HotbarStore.getInstance().isAddedToActive(clusterEntity);
const isAddedToActive = hotbarStore.isAddedToActive(clusterEntity);
const title = isAddedToActive
? "Remove from Hotbar"
: "Add to Hotbar";
@@ -140,3 +149,14 @@ export const SidebarCluster = observer(({ clusterEntity }: { clusterEntity: Cata
</div>
);
});
export const SidebarCluster = withInjectables<Dependencies, SidebarClusterProps>(
NonInjectedSidebarCluster,
{
getProps: (di, props) => ({
hotbarStore: di.inject(hotbarStoreInjectable),
...props,
}),
},
);
@@ -25,11 +25,6 @@
white-space: nowrap;
}
&.active, &:hover {
background: var(--blue);
color: var(--sidebarActiveColor);
}
.expand-icon {
--size: 20px;
}
@@ -39,10 +34,6 @@
$borderSize: 4px;
border-left: $borderSize solid transparent;
&.active {
border-left-color: var(--blue);
}
> .SidebarItem {
color: var(--textColorPrimary);
padding-left: 30px + $borderSize;
@@ -56,10 +47,49 @@
&.expandable {
font-weight: 500;
}
}
}
}
}
&.active, &:hover {
color: var(--sidebarSubmenuActiveColor);
background: none;
// TODO: Make less flaky.
.sidebar-active-status {
> .SidebarItem {
.nav-item {
&.active, &:hover {
background: var(--blue);
color: var(--sidebarActiveColor);
}
}
.sub-menu {
&.active {
border-left-color: var(--blue);
}
> .SidebarItem {
.nav-item {
&.active, &:hover {
color: var(--sidebarSubmenuActiveColor);
background: none;
}
&.expandable {
color: var(--textColorPrimary)
}
}
.sub-menu {
&.active {
border-left-color: transparent;
}
.nav-item {
&.active, &:hover {
color: var(--sidebarSubmenuActiveColor);
background: none;
}
}
}
}
}
+62 -53
View File
@@ -7,39 +7,28 @@ import "./sidebar-item.scss";
import React from "react";
import { computed, makeObservable } from "mobx";
import { cssNames, prevDefault, StorageHelper } from "../../utils";
import { cssNames, StorageHelper } from "../../utils";
import { observer } from "mobx-react";
import { NavLink } from "react-router-dom";
import { Icon } from "../icon";
import { isActiveRoute } from "../../navigation";
import { withInjectables } from "@ogre-tools/injectable-react";
import sidebarStorageInjectable, { SidebarStorageState } from "./sidebar-storage/sidebar-storage.injectable";
export interface SidebarItemProps {
/**
* Unique id, used in storage and integration tests
*/
id: string;
url: string;
className?: string;
text: React.ReactNode;
icon?: React.ReactNode;
isHidden?: boolean;
/**
* Forces this item to be also show as active or not.
*
* Default: dynamically checks the location against the `url` props to determine if
* this item should be shown as active
*/
isActive?: boolean;
}
import sidebarStorageInjectable, {
SidebarStorageState,
} from "./sidebar-storage/sidebar-storage.injectable";
import type { HierarchicalSidebarItem } from "./sidebar-items.injectable";
interface Dependencies {
sidebarStorage: StorageHelper<SidebarStorageState>;
}
export interface SidebarItemProps {
item: HierarchicalSidebarItem;
}
@observer
class NonInjectedSidebarItem extends React.Component<SidebarItemProps & Dependencies> {
class NonInjectedSidebarItem extends React.Component<
SidebarItemProps & Dependencies
> {
static displayName = "SidebarItem";
constructor(props: SidebarItemProps & Dependencies) {
@@ -48,65 +37,85 @@ class NonInjectedSidebarItem extends React.Component<SidebarItemProps & Dependen
}
get id(): string {
return this.props.id;
return this.registration.id;
}
@computed get expanded(): boolean {
return Boolean(this.props.sidebarStorage.get().expanded[this.id]);
}
@computed get isActive(): boolean {
return this.props.isActive ?? isActiveRoute({
path: this.props.url,
exact: true,
});
@computed get isExpandable(): boolean {
return this.props.item.children.length > 0;
}
@computed get isExpandable(): boolean {
return React.Children.count(this.props.children) > 0;
@computed get isActive(): boolean {
return this.props.item.isActive.get();
}
get registration() {
return this.props.item.registration;
}
toggleExpand = () => {
this.props.sidebarStorage.merge(draft => {
this.props.sidebarStorage.merge((draft) => {
draft.expanded[this.id] = !draft.expanded[this.id];
});
};
renderSubMenu() {
const { isExpandable, expanded, isActive } = this;
const { isExpandable, expanded } = this;
if (!isExpandable || !expanded) {
return null;
}
return (
<ul className={cssNames("sub-menu", { active: isActive })}>
{this.props.children}
<ul className={cssNames("sub-menu", { active: this.isActive })}>
{this.props.item.children.map(item => <SidebarItem key={item.registration.id} item={item} />)}
</ul>
);
}
render() {
const { isHidden, icon, text, url, className } = this.props;
if (isHidden) return null;
const { isActive, id, expanded, isExpandable, toggleExpand } = this;
const classNames = cssNames("SidebarItem", className);
return (
<div className={classNames} data-test-id={id}>
<div
className={cssNames("SidebarItem")}
data-testid="sidebar-item"
data-test-id={this.id}
data-id-test={this.id}
data-is-active-test={this.isActive}
data-parent-id-test={this.registration.parentId}
>
<NavLink
to={url}
isActive={() => isActive}
className={cssNames("nav-item flex gaps align-center", { expandable: isExpandable })}
onClick={isExpandable ? prevDefault(toggleExpand) : undefined}>
{icon}
<span className="link-text box grow">{text}</span>
{isExpandable && <Icon
className="expand-icon box right"
material={expanded ? "keyboard_arrow_up" : "keyboard_arrow_down"}
/>}
to={""}
isActive={() => this.isActive}
className={cssNames("nav-item flex gaps align-center", {
expandable: this.isExpandable,
})}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
if (this.isExpandable) {
this.toggleExpand();
} else {
this.registration.onClick();
}
}}
data-testid={`sidebar-item-link-for-${this.id}`}
>
{this.registration.getIcon?.()}
<span className="link-text box grow">{this.registration.title}</span>
{this.isExpandable && (
<Icon
className="expand-icon box right"
material={
this.expanded ? "keyboard_arrow_up" : "keyboard_arrow_down"
}
/>
)}
</NavLink>
{this.renderSubMenu()}
</div>
@@ -0,0 +1,131 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import { getInjectable, getInjectionToken } from "@ogre-tools/injectable";
import { computed, IComputedValue } from "mobx";
import { pipeline } from "@ogre-tools/fp";
import {
filter,
flatMap,
identity,
invokeMap,
isEmpty,
map,
orderBy,
overSome,
some,
} from "lodash/fp";
import rendererExtensionsInjectable from "../../../extensions/renderer-extensions.injectable";
import type { LensRendererExtension } from "../../../extensions/lens-renderer-extension";
export interface SidebarItemRegistration {
id: string;
parentId: string | null;
title: React.ReactNode;
onClick: () => void;
getIcon?: () => React.ReactNode;
isActive?: IComputedValue<boolean>;
isVisible?: IComputedValue<boolean>;
orderNumber: number;
extension?: LensRendererExtension;
}
export const sidebarItemsInjectionToken = getInjectionToken<
IComputedValue<SidebarItemRegistration[]>
>({ id: "sidebar-items-injection-token" });
export interface HierarchicalSidebarItem {
registration: SidebarItemRegistration;
children: HierarchicalSidebarItem[];
isActive: IComputedValue<boolean>;
}
const sidebarItemsInjectable = getInjectable({
id: "sidebar-items",
instantiate: (di) => {
const extensions = di.inject(rendererExtensionsInjectable);
return computed((): HierarchicalSidebarItem[] => {
const enabledExtensions = extensions.get();
const sidebarItemRegistrations = di.injectMany(
sidebarItemsInjectionToken,
);
const registrations = pipeline(
sidebarItemRegistrations,
flatMap(dereference),
filter(
overSome([
isNonExtensionSidebarItem,
isEnabledExtensionSidebarItemFor(enabledExtensions),
]),
),
);
const getSidebarItemsHierarchy = (
registrations: SidebarItemRegistration[],
) => {
const _getSidebarItemsHierarchy = (
parentId: string,
): HierarchicalSidebarItem[] =>
pipeline(
registrations,
filter((item) => item.parentId === parentId),
map((registration) => {
const children = _getSidebarItemsHierarchy(registration.id);
return {
registration,
children,
isActive: computed(() => {
if (isEmpty(children)) {
return registration.isActive ? registration.isActive.get() : false;
}
return pipeline(
children,
invokeMap("isActive.get"),
some(identity),
);
}),
};
}),
filter(item => item.registration.isVisible?.get() ?? true),
(items) =>
orderBy(
["registration.orderNumber"],
["asc"],
items,
),
);
return _getSidebarItemsHierarchy(null);
};
return getSidebarItemsHierarchy(registrations);
});
},
});
const isNonExtensionSidebarItem = (sidebarItem: SidebarItemRegistration) =>
!sidebarItem.extension;
const isEnabledExtensionSidebarItemFor =
(enabledExtensions: LensRendererExtension[]) =>
(sidebarItem: SidebarItemRegistration) =>
!!enabledExtensions.find((x) => x === sidebarItem.extension);
const dereference = (items: IComputedValue<SidebarItemRegistration[]>) =>
items.get();
export default sidebarItemsInjectable;
+34 -110
View File
@@ -4,137 +4,61 @@
*/
import styles from "./sidebar.module.scss";
import type { TabLayoutRoute } from "./tab-layout";
import React from "react";
import { observer } from "mobx-react";
import { cssNames } from "../../utils";
import { isActiveRoute } from "../../navigation";
import { ClusterPageMenuRegistration, ClusterPageMenuRegistry, ClusterPageRegistry, getExtensionPageUrl } from "../../../extensions/registries";
import { SidebarItem } from "./sidebar-item";
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
import { SidebarCluster } from "./sidebar-cluster";
import { renderTabRoutesSidebarItems } from "./tab-routes-sidebar-items";
import { ConfigSidebarItem } from "../+config/sidebar-item";
import { ClusterSidebarItem } from "../+cluster/sidebar-item";
import { NodesSidebarItem } from "../+nodes/sidebar-item";
import { WorkloadsSidebarItem } from "../+workloads/sidebar-item";
import { NetworkSidebarItem } from "../+network/sidebar-item";
import { StorageSidebarItem } from "../+storage/sidebar-item";
import { NamespacesSidebarItem } from "../+namespaces/sidebar-item";
import { EventsSidebarItem } from "../+events/sidebar-item";
import { HelmSidebarItem } from "../+helm/sidebar-item";
import { UserManagementSidebarItem } from "../+user-management/sidebar-item";
import { CustomResourcesSidebarItem } from "../+custom-resources/sidebar-item";
import { withInjectables } from "@ogre-tools/injectable-react";
import sidebarItemsInjectable, {
HierarchicalSidebarItem,
} from "./sidebar-items.injectable";
import type { IComputedValue } from "mobx";
export interface SidebarProps {
className?: string;
interface Dependencies {
sidebarItems: IComputedValue<HierarchicalSidebarItem[]>;
}
@observer
export class Sidebar extends React.Component<SidebarProps> {
class NonInjectedSidebar extends React.Component<Dependencies> {
static displayName = "Sidebar";
getTabLayoutRoutes(menu: ClusterPageMenuRegistration): TabLayoutRoute[] {
if (!menu.id) {
return [];
}
const routes: TabLayoutRoute[] = [];
const subMenus = ClusterPageMenuRegistry.getInstance().getSubItems(menu);
const clusterPageRegistry = ClusterPageRegistry.getInstance();
for (const subMenu of subMenus) {
const page = clusterPageRegistry.getByPageTarget(subMenu.target);
if (!page) {
continue;
}
const { extensionId, id: pageId, url, components } = page;
if (subMenu.components.Icon) {
console.warn(
"ClusterPageMenuRegistration has components.Icon defined and a valid parentId. Icon will not be displayed",
{
id: subMenu.id,
parentId: subMenu.parentId,
target: subMenu.target,
},
);
}
routes.push({
routePath: url,
url: getExtensionPageUrl({ extensionId, pageId, params: subMenu.target.params }),
title: subMenu.title,
component: components.Page,
});
}
return routes;
}
renderRegisteredMenus() {
return ClusterPageMenuRegistry.getInstance().getRootItems().map((menuItem, index) => {
const registeredPage = ClusterPageRegistry.getInstance().getByPageTarget(menuItem.target);
const tabRoutes = this.getTabLayoutRoutes(menuItem);
const id = `registered-item-${index}`;
let pageUrl: string;
let isActive = false;
if (registeredPage) {
const { extensionId, id: pageId } = registeredPage;
pageUrl = getExtensionPageUrl({ extensionId, pageId, params: menuItem.target.params });
isActive = isActiveRoute(registeredPage.url);
} else if (tabRoutes.length > 0) {
pageUrl = tabRoutes[0].url;
isActive = isActiveRoute(tabRoutes.map((tab) => tab.routePath));
} else {
return null;
}
return (
<SidebarItem
key={id}
id={id}
url={pageUrl}
isActive={isActive}
text={menuItem.title}
icon={<menuItem.components.Icon/>}
>
{renderTabRoutesSidebarItems(tabRoutes)}
</SidebarItem>
);
});
}
get clusterEntity() {
return catalogEntityRegistry.activeEntity;
}
render() {
const { className } = this.props;
return (
<div className={cssNames("flex flex-col", className)} data-testid="cluster-sidebar">
<SidebarCluster clusterEntity={this.clusterEntity}/>
<div className={styles.sidebarNav}>
<ClusterSidebarItem />
<NodesSidebarItem />
<WorkloadsSidebarItem />
<ConfigSidebarItem />
<NetworkSidebarItem />
<StorageSidebarItem />
<NamespacesSidebarItem />
<EventsSidebarItem />
<HelmSidebarItem />
<UserManagementSidebarItem />
<CustomResourcesSidebarItem />
{this.renderRegisteredMenus()}
<div className={cssNames("flex flex-col")} data-testid="cluster-sidebar">
<SidebarCluster clusterEntity={this.clusterEntity} />
<div className={`${styles.sidebarNav} sidebar-active-status`}>
{this.props.sidebarItems.get().map((
hierarchicalSidebarItem: HierarchicalSidebarItem,
) => (
<SidebarItem
item={hierarchicalSidebarItem}
key={hierarchicalSidebarItem.registration.id}
/>
))}
</div>
</div>
);
}
}
export const Sidebar = withInjectables<Dependencies>(
NonInjectedSidebar,
{
getProps: (di, props) => ({
sidebarItems: di.inject(sidebarItemsInjectable),
...props,
}),
},
);
@@ -0,0 +1,60 @@
/**
* Copyright (c) OpenLens Authors. All rights reserved.
* Licensed under MIT License. See LICENSE in root directory for more information.
*/
import "./tab-layout.scss";
import React from "react";
import { observer } from "mobx-react";
import { cssNames } from "../../utils";
import { Tab, Tabs } from "../tabs";
import { ErrorBoundary } from "../error-boundary";
import type { HierarchicalSidebarItem } from "./sidebar-items.injectable";
export interface TabLayoutProps {
tabs?: HierarchicalSidebarItem[];
children?: React.ReactNode;
}
export const TabLayout = observer(
({
tabs = [],
children,
}: TabLayoutProps) => {
const hasTabs = tabs.length > 0;
return (
<div
className={cssNames("TabLayout")}
data-testid="tab-layout"
>
{hasTabs && (
<Tabs center>
{tabs.map(({ registration, isActive }) => {
const active = isActive.get();
return (
<Tab
onClick={registration.onClick}
key={registration.id}
label={registration.title}
active={active}
data-is-active-test={active}
data-testid={`tab-link-for-${registration.id}`}
/>
);
})}
</Tabs>
)}
<main>
<ErrorBoundary>
{children}
</ErrorBoundary>
</main>
</div>
);
},
);
@@ -1,37 +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 { isActiveRoute } from "../../navigation";
import { SidebarItem } from "./sidebar-item";
import type { TabLayoutRoute } from "./tab-layout";
function withId(src: TabLayoutRoute) {
return {
...src,
id: `tab-route-item-${src.url ?? src.routePath}`,
};
}
/**
* Renders a sidebar item for each route
*
* NOTE: this cannot be a component because then the `<SidebarItem>.isExandable`
* check will always return true because a component that renders to `null` is
* still a present child to the parent `<SidebarItem>`
*/
export const renderTabRoutesSidebarItems = (routes: TabLayoutRoute[]) => (
routes
.map(withId)
.map(({ title, routePath, url = routePath, exact = true, id }) => (
<SidebarItem
key={id}
id={id}
url={url}
text={title}
isActive={isActiveRoute({ path: routePath, exact })}
/>
))
);
@@ -7,41 +7,31 @@ import React from "react";
import { fireEvent } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import { TopBar } from "./top-bar";
import * as vars from "../../../../common/vars";
import { getDiForUnitTesting } from "../../../getDiForUnitTesting";
import { DiRender, renderFor } from "../../test-utils/renderFor";
import directoryForUserDataInjectable from "../../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import mockFs from "mock-fs";
import { emitOpenAppMenuAsContextMenu, requestWindowAction } from "../../../ipc";
const mockConfig = vars as { isWindows: boolean; isLinux: boolean };
import isLinuxInjectable from "../../../../common/vars/is-linux.injectable";
import isMacInjectable from "../../../../common/vars/is-mac.injectable";
import isWindowsInjectable from "../../../../common/vars/is-windows.injectable";
import type { DiContainer } from "@ogre-tools/injectable";
jest.mock("../../../../common/ipc");
jest.mock("../../../ipc");
jest.mock("../../../../common/vars", () => {
const { SemVer } = require("semver");
return {
...jest.requireActual<{}>("../../../../common/vars"),
__esModule: true,
isWindows: null,
isLinux: null,
appSemVer: new SemVer("1.0.0"),
};
});
describe("<TopBar/> in Windows and Linux", () => {
let render: DiRender;
let di: DiContainer;
beforeEach(async () => {
const di = getDiForUnitTesting({ doGeneralOverrides: true });
beforeEach(() => {
di = getDiForUnitTesting({ doGeneralOverrides: true });
mockFs();
di.override(isMacInjectable, () => false);
di.override(directoryForUserDataInjectable, () => "some-directory-for-user-data");
await di.runSetups();
mockFs();
render = renderFor(di);
});
@@ -51,8 +41,7 @@ describe("<TopBar/> in Windows and Linux", () => {
});
it("shows window controls on Windows", () => {
mockConfig.isWindows = true;
mockConfig.isLinux = false;
di.override(isWindowsInjectable, () => true);
const { getByTestId } = render(<TopBar />);
@@ -63,8 +52,7 @@ describe("<TopBar/> in Windows and Linux", () => {
});
it("shows window controls on Linux", () => {
mockConfig.isWindows = false;
mockConfig.isLinux = true;
di.override(isLinuxInjectable, () => true);
const { getByTestId } = render(<TopBar />);
@@ -75,7 +63,7 @@ describe("<TopBar/> in Windows and Linux", () => {
});
it("triggers ipc events on click", () => {
mockConfig.isWindows = true;
di.override(isWindowsInjectable, () => true);
const { getByTestId } = render(<TopBar />);
@@ -11,8 +11,6 @@ import { Icon } from "../../icon";
import { observable } from "mobx";
import { ipcRendererOn } from "../../../../common/ipc";
import { watchHistoryState } from "../../../remote-helpers/history-updater";
import { isActiveRoute, navigate } from "../../../navigation";
import { catalogRoute, catalogURL } from "../../../../common/routes";
import { cssNames } from "../../../utils";
import topBarItemsInjectable from "./top-bar-items/top-bar-items.injectable";
import { withInjectables } from "@ogre-tools/injectable-react";
@@ -21,10 +19,13 @@ import { emitOpenAppMenuAsContextMenu, requestWindowAction } from "../../../ipc"
import { WindowAction } from "../../../../common/ipc/window";
import isLinuxInjectable from "../../../../common/vars/is-linux.injectable";
import isWindowsInjectable from "../../../../common/vars/is-windows.injectable";
export interface TopBarProps {}
import navigateToCatalogInjectable, { NavigateToCatalog } from "../../../../common/front-end-routing/routes/catalog/navigate-to-catalog.injectable";
import catalogRouteInjectable from "../../../../common/front-end-routing/routes/catalog/catalog-route.injectable";
import routeIsActiveInjectable from "../../../routes/route-is-active.injectable";
interface Dependencies {
navigateToCatalog: NavigateToCatalog;
catalogRouteIsActive: IComputedValue<boolean>;
items: IComputedValue<TopBarRegistration[]>;
isWindows: boolean;
isLinux: boolean;
@@ -41,7 +42,7 @@ ipcRendererOn("history:can-go-forward", (event, state: boolean) => {
nextEnabled.set(state);
});
const NonInjectedTopBar = observer(({ items, isWindows, isLinux }: TopBarProps & Dependencies) => {
const NonInjectedTopBar = observer(({ items, navigateToCatalog, catalogRouteIsActive, isWindows, isLinux }: Dependencies) => {
const elem = useRef<HTMLDivElement>();
const openAppContextMenu = () => {
@@ -49,7 +50,7 @@ const NonInjectedTopBar = observer(({ items, isWindows, isLinux }: TopBarProps &
};
const goHome = () => {
navigate(catalogURL());
navigateToCatalog();
};
const goBack = () => {
@@ -98,7 +99,7 @@ const NonInjectedTopBar = observer(({ items, isWindows, isLinux }: TopBarProps &
data-testid="home-button"
material="home"
onClick={goHome}
disabled={isActiveRoute(catalogRoute)}
disabled={catalogRouteIsActive.get()}
/>
<Icon
data-testid="history-back"
@@ -149,11 +150,23 @@ const renderRegisteredItems = (items: TopBarRegistration[]) => (
})
);
export const TopBar = withInjectables<Dependencies, TopBarProps>(NonInjectedTopBar, {
getProps: (di, props) => ({
items: di.inject(topBarItemsInjectable),
isLinux: di.inject(isLinuxInjectable),
isWindows: di.inject(isWindowsInjectable),
...props,
}),
});
export const TopBar = withInjectables<Dependencies>(
NonInjectedTopBar,
{
getProps: (di) => {
const catalogRoute = di.inject(catalogRouteInjectable);
return {
navigateToCatalog: di.inject(navigateToCatalogInjectable),
items: di.inject(topBarItemsInjectable),
isLinux: di.inject(isLinuxInjectable),
isWindows: di.inject(isWindowsInjectable),
catalogRouteIsActive: di.inject(
routeIsActiveInjectable,
catalogRoute,
),
};
},
},
);