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

Catalog render optimizations (#3422)

This commit is contained in:
Alex Andreev 2021-07-21 15:51:46 +03:00 committed by GitHub
parent 86db8e5e2c
commit 4fe0a7d73e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 268 additions and 147 deletions

32
__mocks__/windowMock.ts Normal file
View File

@ -0,0 +1,32 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
Object.defineProperty(window, "requestIdleCallback", {
writable: true,
value: jest.fn().mockImplementation(callback => callback()),
});
Object.defineProperty(window, "cancelIdleCallback", {
writable: true,
value: jest.fn(),
});
export default {};

View File

@ -63,7 +63,8 @@
}, },
"moduleNameMapper": { "moduleNameMapper": {
"\\.(css|scss)$": "<rootDir>/__mocks__/styleMock.ts", "\\.(css|scss)$": "<rootDir>/__mocks__/styleMock.ts",
"\\.(svg)$": "<rootDir>/__mocks__/imageMock.ts" "\\.(svg)$": "<rootDir>/__mocks__/imageMock.ts",
"src/(.*)": "<rootDir>/__mocks__/windowMock.ts"
}, },
"modulePathIgnorePatterns": [ "modulePathIgnorePatterns": [
"<rootDir>/dist", "<rootDir>/dist",

View File

@ -137,7 +137,11 @@ export class UserStore extends BaseStore<UserStoreModel> /* implements UserStore
* Toggles the hidden configuration of a table's column * Toggles the hidden configuration of a table's column
*/ */
toggleTableColumnVisibility(tableId: string, columnId: string) { toggleTableColumnVisibility(tableId: string, columnId: string) {
this.hiddenTableColumns.get(tableId)?.toggle(columnId); if (!this.hiddenTableColumns.get(tableId)) {
this.hiddenTableColumns.set(tableId, new ObservableToggleSet());
}
this.hiddenTableColumns.get(tableId).toggle(columnId);
} }
@action @action

View File

@ -46,6 +46,11 @@
border-radius: 2px; border-radius: 2px;
} }
.content:active {
color: white;
background-color: var(--blue);
}
.group { .group {
margin-left: 0px; margin-left: 0px;
} }

View File

@ -42,6 +42,9 @@ import { CatalogEntityDetails } from "./catalog-entity-details";
import { catalogURL, CatalogViewRouteParam } from "../../../common/routes"; import { catalogURL, CatalogViewRouteParam } from "../../../common/routes";
import { CatalogMenu } from "./catalog-menu"; import { CatalogMenu } from "./catalog-menu";
import { HotbarIcon } from "../hotbar/hotbar-icon"; import { HotbarIcon } from "../hotbar/hotbar-icon";
import { RenderDelay } from "../render-delay/render-delay";
import { CatalogTopbar } from "../cluster-manager/catalog-topbar";
import type { TableSortCallback } from "../table";
export const previousActiveTab = createAppStorage("catalog-previous-active-tab", ""); export const previousActiveTab = createAppStorage("catalog-previous-active-tab", "");
@ -144,7 +147,9 @@ export class Catalog extends React.Component<Props> {
}; };
renderNavigation() { renderNavigation() {
return <CatalogMenu activeItem={this.activeTab} onItemClick={this.onTabChange}/>; return (
<CatalogMenu activeItem={this.activeTab} onItemClick={this.onTabChange}/>
);
} }
renderItemMenu = (item: CatalogEntityItem<CatalogEntity>) => { renderItemMenu = (item: CatalogEntityItem<CatalogEntity>) => {
@ -172,6 +177,7 @@ export class Catalog extends React.Component<Props> {
renderIcon(item: CatalogEntityItem<CatalogEntity>) { renderIcon(item: CatalogEntityItem<CatalogEntity>) {
return ( return (
<RenderDelay>
<HotbarIcon <HotbarIcon
uid={`catalog-icon-${item.getId()}`} uid={`catalog-icon-${item.getId()}`}
title={item.getName()} title={item.getName()}
@ -181,101 +187,63 @@ export class Catalog extends React.Component<Props> {
background={item.entity.spec.icon?.background} background={item.entity.spec.icon?.background}
size={24} size={24}
/> />
</RenderDelay>
); );
} }
renderSingleCategoryList() { renderList() {
return ( const { activeCategory } = this.catalogEntityStore;
<ItemListLayout const tableId = activeCategory ? `catalog-items-${activeCategory.metadata.name.replace(" ", "")}` : "catalog-items";
key={this.catalogEntityStore.activeCategory.getId()} let sortingCallbacks: { [sortBy: string]: TableSortCallback } = {
tableId={`catalog-items-${this.catalogEntityStore.activeCategory?.metadata.name.replace(" ", "")}`}
renderHeaderTitle={this.catalogEntityStore.activeCategory?.metadata.name}
isSelectable={false}
isConfigurable={true}
className="CatalogItemList"
store={this.catalogEntityStore}
sortingCallbacks={{
[sortBy.name]: (item: CatalogEntityItem<CatalogEntity>) => item.name, [sortBy.name]: (item: CatalogEntityItem<CatalogEntity>) => item.name,
[sortBy.source]: (item: CatalogEntityItem<CatalogEntity>) => item.source, [sortBy.source]: (item: CatalogEntityItem<CatalogEntity>) => item.source,
[sortBy.status]: (item: CatalogEntityItem<CatalogEntity>) => item.phase, [sortBy.status]: (item: CatalogEntityItem<CatalogEntity>) => item.phase,
}} };
searchFilters={[
(entity: CatalogEntityItem<CatalogEntity>) => entity.searchFields,
]}
renderTableHeader={[
{ title: "", className: css.iconCell, id: "icon" },
{ title: "Name", className: css.nameCell, sortBy: sortBy.name, id: "name" },
{ title: "Source", className: css.sourceCell, sortBy: sortBy.source, id: "source" },
{ title: "Labels", className: css.labelsCell, id: "labels" },
{ title: "Status", className: css.statusCell, sortBy: sortBy.status, id: "status" },
]}
customizeTableRowProps={(item: CatalogEntityItem<CatalogEntity>) => ({
disabled: !item.enabled,
})}
renderTableContents={(item: CatalogEntityItem<CatalogEntity>) => [
this.renderIcon(item),
item.name,
item.source,
item.getLabelBadges(),
{ title: item.phase, className: cssNames(css[item.phase]) }
]}
onDetails={this.onDetails}
renderItemMenu={this.renderItemMenu}
/>
);
}
renderAllCategoriesList() { sortingCallbacks = activeCategory ? sortingCallbacks : {
return ( ...sortingCallbacks,
<ItemListLayout
key="all"
renderHeaderTitle={"Browse All"}
isSelectable={false}
isConfigurable={true}
className="CatalogItemList"
store={this.catalogEntityStore}
tableId="catalog-items"
sortingCallbacks={{
[sortBy.name]: (item: CatalogEntityItem<CatalogEntity>) => item.name,
[sortBy.kind]: (item: CatalogEntityItem<CatalogEntity>) => item.kind, [sortBy.kind]: (item: CatalogEntityItem<CatalogEntity>) => item.kind,
[sortBy.source]: (item: CatalogEntityItem<CatalogEntity>) => item.source, };
[sortBy.status]: (item: CatalogEntityItem<CatalogEntity>) => item.phase,
}}
searchFilters={[
(entity: CatalogEntityItem<CatalogEntity>) => entity.searchFields,
]}
renderTableHeader={[
{ title: "", className: css.iconCell, id: "icon" },
{ title: "Name", className: css.nameCell, sortBy: sortBy.name, id: "name" },
{ title: "Kind", className: css.kindCell, sortBy: sortBy.kind, id: "kind" },
{ title: "Source", className: css.sourceCell, sortBy: sortBy.source, id: "source" },
{ title: "Labels", className: css.labelsCell, id: "labels" },
{ title: "Status", className: css.statusCell, sortBy: sortBy.status, id: "status" },
]}
customizeTableRowProps={(item: CatalogEntityItem<CatalogEntity>) => ({
disabled: !item.enabled,
})}
renderTableContents={(item: CatalogEntityItem<CatalogEntity>) => [
this.renderIcon(item),
item.name,
item.kind,
item.source,
item.getLabelBadges(),
{ title: item.phase, className: cssNames(css[item.phase]) }
]}
detailsItem={this.catalogEntityStore.selectedItem}
onDetails={this.onDetails}
renderItemMenu={this.renderItemMenu}
/>
);
}
renderCategoryList() {
if (this.activeTab === undefined) { if (this.activeTab === undefined) {
return null; return null;
} }
return this.catalogEntityStore.activeCategory ? this.renderSingleCategoryList() : this.renderAllCategoriesList(); return (
<ItemListLayout
tableId={tableId}
renderHeaderTitle={activeCategory?.metadata.name || "Browse All"}
isSelectable={false}
isConfigurable={true}
className="CatalogItemList"
store={this.catalogEntityStore}
sortingCallbacks={sortingCallbacks}
searchFilters={[
(entity: CatalogEntityItem<CatalogEntity>) => entity.searchFields,
]}
renderTableHeader={[
{ title: "", className: css.iconCell, id: "icon" },
{ title: "Name", className: css.nameCell, sortBy: sortBy.name, id: "name" },
!activeCategory && { title: "Kind", className: css.kindCell, sortBy: sortBy.kind, id: "kind" },
{ title: "Source", className: css.sourceCell, sortBy: sortBy.source, id: "source" },
{ title: "Labels", className: css.labelsCell, id: "labels" },
{ title: "Status", className: css.statusCell, sortBy: sortBy.status, id: "status" },
].filter(Boolean)}
customizeTableRowProps={(item: CatalogEntityItem<CatalogEntity>) => ({
disabled: !item.enabled,
})}
renderTableContents={(item: CatalogEntityItem<CatalogEntity>) => [
this.renderIcon(item),
item.name,
!activeCategory && item.kind,
item.source,
item.getLabelBadges(),
{ title: item.phase, className: cssNames(css[item.phase]) }
].filter(Boolean)}
onDetails={this.onDetails}
renderItemMenu={this.renderItemMenu}
/>
);
} }
render() { render() {
@ -284,9 +252,11 @@ export class Catalog extends React.Component<Props> {
} }
return ( return (
<>
<CatalogTopbar/>
<MainLayout sidebar={this.renderNavigation()}> <MainLayout sidebar={this.renderNavigation()}>
<div className="p-6 h-full"> <div className="p-6 h-full">
{ this.renderCategoryList() } { this.renderList() }
</div> </div>
{ {
this.catalogEntityStore.selectedItem this.catalogEntityStore.selectedItem
@ -294,11 +264,16 @@ export class Catalog extends React.Component<Props> {
item={this.catalogEntityStore.selectedItem} item={this.catalogEntityStore.selectedItem}
hideDetails={() => this.catalogEntityStore.selectedItemId = null} hideDetails={() => this.catalogEntityStore.selectedItemId = null}
/> />
: <CatalogAddButton : (
<RenderDelay>
<CatalogAddButton
category={this.catalogEntityStore.activeCategory} category={this.catalogEntityStore.activeCategory}
/> />
</RenderDelay>
)
} }
</MainLayout> </MainLayout>
</>
); );
} }
} }

View File

@ -35,10 +35,6 @@
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
> * {
z-index: 1;
}
} }
.HotbarMenu { .HotbarMenu {
@ -52,7 +48,7 @@
#lens-views { #lens-views {
position: absolute; position: absolute;
left: 0; left: 0;
top: 0; top: 40px; // Move below top bar
right: 0; right: 0;
bottom: 0; bottom: 0;
display: flex; display: flex;

View File

@ -34,8 +34,6 @@ import { Extensions } from "../+extensions";
import { HotbarMenu } from "../hotbar/hotbar-menu"; import { HotbarMenu } from "../hotbar/hotbar-menu";
import { EntitySettings } from "../+entity-settings"; import { EntitySettings } from "../+entity-settings";
import { Welcome } from "../+welcome"; import { Welcome } from "../+welcome";
import { ClusterTopbar } from "./cluster-topbar";
import { CatalogTopbar } from "./catalog-topbar";
import * as routes from "../../../common/routes"; import * as routes from "../../../common/routes";
@observer @observer
@ -43,8 +41,6 @@ export class ClusterManager extends React.Component {
render() { render() {
return ( return (
<div className="ClusterManager"> <div className="ClusterManager">
<Route component={CatalogTopbar} {...routes.catalogRoute} />
<Route component={ClusterTopbar} {...routes.clusterViewRoute} />
<main> <main>
<div id="lens-views"/> <div id="lens-views"/>
<Switch> <Switch>

View File

@ -34,6 +34,7 @@ import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
import { navigate } from "../../navigation"; import { navigate } from "../../navigation";
import { catalogURL, ClusterViewRouteParams } from "../../../common/routes"; import { catalogURL, ClusterViewRouteParams } from "../../../common/routes";
import { previousActiveTab } from "../+catalog"; import { previousActiveTab } from "../+catalog";
import { ClusterTopbar } from "./cluster-topbar";
interface Props extends RouteComponentProps<ClusterViewRouteParams> { interface Props extends RouteComponentProps<ClusterViewRouteParams> {
} }
@ -104,6 +105,7 @@ export class ClusterView extends React.Component<Props> {
render() { render() {
return ( return (
<div className="ClusterView flex column align-center"> <div className="ClusterView flex column align-center">
<ClusterTopbar {...this.props}/>
{this.renderStatus()} {this.renderStatus()}
</div> </div>
); );

View File

@ -61,7 +61,6 @@
width: 0; width: 0;
height: 2px; height: 2px;
background: $primary; background: $primary;
transition: width 250ms;
} }
} }

View File

@ -30,6 +30,7 @@ import { Icon, IconProps } from "../icon";
import { Menu, MenuItem, MenuProps } from "../menu"; import { Menu, MenuItem, MenuProps } from "../menu";
import uniqueId from "lodash/uniqueId"; import uniqueId from "lodash/uniqueId";
import isString from "lodash/isString"; import isString from "lodash/isString";
import { RenderDelay } from "../render-delay/render-delay";
export interface MenuActionsProps extends Partial<MenuProps> { export interface MenuActionsProps extends Partial<MenuProps> {
className?: string; className?: string;
@ -124,6 +125,7 @@ export class MenuActions extends React.Component<MenuActionsProps> {
return ( return (
<> <>
{this.renderTriggerIcon()} {this.renderTriggerIcon()}
<RenderDelay>
<Menu <Menu
htmlFor={this.id} htmlFor={this.id}
isOpen={this.isOpen} open={this.toggle} close={this.toggle} isOpen={this.isOpen} open={this.toggle} close={this.toggle}
@ -148,6 +150,7 @@ export class MenuActions extends React.Component<MenuActionsProps> {
</MenuItem> </MenuItem>
)} )}
</Menu> </Menu>
</RenderDelay>
</> </>
); );
} }

View File

@ -260,6 +260,8 @@ export class Menu extends React.Component<MenuProps, State> {
} }
onBlur() { onBlur() {
if (!this.isOpen) return; // Prevents triggering document.activeElement for each <Menu/> instance
if (document.activeElement?.tagName == "IFRAME") { if (document.activeElement?.tagName == "IFRAME") {
this.close(); this.close();
} }

View File

@ -0,0 +1,38 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import React from "react";
import "@testing-library/jest-dom/extend-expect";
import { render } from "@testing-library/react";
import { RenderDelay } from "../render-delay";
describe("<RenderDelay/>", () => {
it("renders w/o errors", () => {
const { container } = render(<RenderDelay><button>My button</button></RenderDelay>);
expect(container).toBeInstanceOf(HTMLElement);
});
it("renders it's child", () => {
const { getByText } = render(<RenderDelay><button>My button</button></RenderDelay>);
expect(getByText("My button")).toBeInTheDocument();
});
});

View File

@ -0,0 +1,63 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import React from "react";
import { makeObservable, observable } from "mobx";
import { observer } from "mobx-react";
import { boundMethod } from "../../utils";
interface Props {
placeholder?: React.ReactNode;
children: unknown;
}
@observer
export class RenderDelay extends React.Component<Props> {
@observable isVisible = false;
constructor(props: Props) {
super(props);
makeObservable(this);
}
componentDidMount() {
const guaranteedFireTime = 1000;
window.requestIdleCallback(this.showContents, { timeout: guaranteedFireTime });
}
componentWillUnmount() {
window.cancelIdleCallback(this.showContents);
}
@boundMethod
showContents() {
this.isVisible = true;
}
render() {
if (!this.isVisible) {
return this.props.placeholder || null;
}
return this.props.children;
}
}

5
types/dom.d.ts vendored
View File

@ -24,4 +24,9 @@ declare global {
interface Element { interface Element {
scrollIntoViewIfNeeded(opt_center?: boolean): void; scrollIntoViewIfNeeded(opt_center?: boolean): void;
} }
interface Window {
requestIdleCallback(callback: () => void, options: { timeout: number });
cancelIdleCallback(callback: () => void);
}
} }