From 4fe0a7d73e53c0c5f4f46538b1e3c2a8ff25e648 Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Wed, 21 Jul 2021 15:51:46 +0300 Subject: [PATCH] Catalog render optimizations (#3422) --- __mocks__/windowMock.ts | 32 +++ package.json | 3 +- src/common/user-store/user-store.ts | 6 +- .../+catalog/catalog-tree.module.css | 5 + src/renderer/components/+catalog/catalog.tsx | 197 ++++++++---------- .../cluster-manager/cluster-manager.scss | 6 +- .../cluster-manager/cluster-manager.tsx | 4 - .../cluster-manager/cluster-view.tsx | 2 + src/renderer/components/input/input.scss | 1 - src/renderer/components/menu/menu-actions.tsx | 51 ++--- src/renderer/components/menu/menu.tsx | 2 + .../__tests__/render-delay.test.tsx | 38 ++++ .../components/render-delay/render-delay.tsx | 63 ++++++ types/dom.d.ts | 5 + 14 files changed, 268 insertions(+), 147 deletions(-) create mode 100644 __mocks__/windowMock.ts create mode 100644 src/renderer/components/render-delay/__tests__/render-delay.test.tsx create mode 100644 src/renderer/components/render-delay/render-delay.tsx diff --git a/__mocks__/windowMock.ts b/__mocks__/windowMock.ts new file mode 100644 index 0000000000..baf8fa1a65 --- /dev/null +++ b/__mocks__/windowMock.ts @@ -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 {}; diff --git a/package.json b/package.json index 4157712350..af393433de 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,8 @@ }, "moduleNameMapper": { "\\.(css|scss)$": "/__mocks__/styleMock.ts", - "\\.(svg)$": "/__mocks__/imageMock.ts" + "\\.(svg)$": "/__mocks__/imageMock.ts", + "src/(.*)": "/__mocks__/windowMock.ts" }, "modulePathIgnorePatterns": [ "/dist", diff --git a/src/common/user-store/user-store.ts b/src/common/user-store/user-store.ts index beeb2078e1..7121357671 100644 --- a/src/common/user-store/user-store.ts +++ b/src/common/user-store/user-store.ts @@ -137,7 +137,11 @@ export class UserStore extends BaseStore /* implements UserStore * Toggles the hidden configuration of a table's column */ 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 diff --git a/src/renderer/components/+catalog/catalog-tree.module.css b/src/renderer/components/+catalog/catalog-tree.module.css index 4787fd71b8..0b16983dbc 100644 --- a/src/renderer/components/+catalog/catalog-tree.module.css +++ b/src/renderer/components/+catalog/catalog-tree.module.css @@ -46,6 +46,11 @@ border-radius: 2px; } +.content:active { + color: white; + background-color: var(--blue); +} + .group { margin-left: 0px; } diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index 76339e79a3..e70a8d5f47 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -42,6 +42,9 @@ import { CatalogEntityDetails } from "./catalog-entity-details"; import { catalogURL, CatalogViewRouteParam } from "../../../common/routes"; import { CatalogMenu } from "./catalog-menu"; 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", ""); @@ -144,7 +147,9 @@ export class Catalog extends React.Component { }; renderNavigation() { - return ; + return ( + + ); } renderItemMenu = (item: CatalogEntityItem) => { @@ -172,110 +177,73 @@ export class Catalog extends React.Component { renderIcon(item: CatalogEntityItem) { return ( - + + + ); } - renderSingleCategoryList() { - return ( - ) => item.name, - [sortBy.source]: (item: CatalogEntityItem) => item.source, - [sortBy.status]: (item: CatalogEntityItem) => item.phase, - }} - searchFilters={[ - (entity: CatalogEntityItem) => 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) => ({ - disabled: !item.enabled, - })} - renderTableContents={(item: CatalogEntityItem) => [ - this.renderIcon(item), - item.name, - item.source, - item.getLabelBadges(), - { title: item.phase, className: cssNames(css[item.phase]) } - ]} - onDetails={this.onDetails} - renderItemMenu={this.renderItemMenu} - /> - ); - } + renderList() { + const { activeCategory } = this.catalogEntityStore; + const tableId = activeCategory ? `catalog-items-${activeCategory.metadata.name.replace(" ", "")}` : "catalog-items"; + let sortingCallbacks: { [sortBy: string]: TableSortCallback } = { + [sortBy.name]: (item: CatalogEntityItem) => item.name, + [sortBy.source]: (item: CatalogEntityItem) => item.source, + [sortBy.status]: (item: CatalogEntityItem) => item.phase, + }; - renderAllCategoriesList() { - return ( - ) => item.name, - [sortBy.kind]: (item: CatalogEntityItem) => item.kind, - [sortBy.source]: (item: CatalogEntityItem) => item.source, - [sortBy.status]: (item: CatalogEntityItem) => item.phase, - }} - searchFilters={[ - (entity: CatalogEntityItem) => 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) => ({ - disabled: !item.enabled, - })} - renderTableContents={(item: CatalogEntityItem) => [ - 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} - /> - ); - } + sortingCallbacks = activeCategory ? sortingCallbacks : { + ...sortingCallbacks, + [sortBy.kind]: (item: CatalogEntityItem) => item.kind, + }; - renderCategoryList() { if (this.activeTab === undefined) { return null; } - return this.catalogEntityStore.activeCategory ? this.renderSingleCategoryList() : this.renderAllCategoriesList(); + return ( + ) => 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) => ({ + disabled: !item.enabled, + })} + renderTableContents={(item: CatalogEntityItem) => [ + 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() { @@ -284,21 +252,28 @@ export class Catalog extends React.Component { } return ( - -
- { this.renderCategoryList() } -
- { - this.catalogEntityStore.selectedItem - ? this.catalogEntityStore.selectedItemId = null} - /> - : - } -
+ <> + + +
+ { this.renderList() } +
+ { + this.catalogEntityStore.selectedItem + ? this.catalogEntityStore.selectedItemId = null} + /> + : ( + + + + ) + } +
+ ); } } diff --git a/src/renderer/components/cluster-manager/cluster-manager.scss b/src/renderer/components/cluster-manager/cluster-manager.scss index dddbb1a5e5..e82c0d3aca 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.scss +++ b/src/renderer/components/cluster-manager/cluster-manager.scss @@ -35,10 +35,6 @@ position: relative; display: flex; flex-direction: column; - - > * { - z-index: 1; - } } .HotbarMenu { @@ -52,7 +48,7 @@ #lens-views { position: absolute; left: 0; - top: 0; + top: 40px; // Move below top bar right: 0; bottom: 0; display: flex; diff --git a/src/renderer/components/cluster-manager/cluster-manager.tsx b/src/renderer/components/cluster-manager/cluster-manager.tsx index df5ae5d486..4bd14e7805 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.tsx +++ b/src/renderer/components/cluster-manager/cluster-manager.tsx @@ -34,8 +34,6 @@ import { Extensions } from "../+extensions"; import { HotbarMenu } from "../hotbar/hotbar-menu"; import { EntitySettings } from "../+entity-settings"; import { Welcome } from "../+welcome"; -import { ClusterTopbar } from "./cluster-topbar"; -import { CatalogTopbar } from "./catalog-topbar"; import * as routes from "../../../common/routes"; @observer @@ -43,8 +41,6 @@ export class ClusterManager extends React.Component { render() { return (
- -
diff --git a/src/renderer/components/cluster-manager/cluster-view.tsx b/src/renderer/components/cluster-manager/cluster-view.tsx index db48e051c8..3d0fea8e55 100644 --- a/src/renderer/components/cluster-manager/cluster-view.tsx +++ b/src/renderer/components/cluster-manager/cluster-view.tsx @@ -34,6 +34,7 @@ import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import { navigate } from "../../navigation"; import { catalogURL, ClusterViewRouteParams } from "../../../common/routes"; import { previousActiveTab } from "../+catalog"; +import { ClusterTopbar } from "./cluster-topbar"; interface Props extends RouteComponentProps { } @@ -104,6 +105,7 @@ export class ClusterView extends React.Component { render() { return (
+ {this.renderStatus()}
); diff --git a/src/renderer/components/input/input.scss b/src/renderer/components/input/input.scss index b683f46d3a..33369694a3 100644 --- a/src/renderer/components/input/input.scss +++ b/src/renderer/components/input/input.scss @@ -61,7 +61,6 @@ width: 0; height: 2px; background: $primary; - transition: width 250ms; } } diff --git a/src/renderer/components/menu/menu-actions.tsx b/src/renderer/components/menu/menu-actions.tsx index a69f226e5e..28747fec99 100644 --- a/src/renderer/components/menu/menu-actions.tsx +++ b/src/renderer/components/menu/menu-actions.tsx @@ -30,6 +30,7 @@ import { Icon, IconProps } from "../icon"; import { Menu, MenuItem, MenuProps } from "../menu"; import uniqueId from "lodash/uniqueId"; import isString from "lodash/isString"; +import { RenderDelay } from "../render-delay/render-delay"; export interface MenuActionsProps extends Partial { className?: string; @@ -124,30 +125,32 @@ export class MenuActions extends React.Component { return ( <> {this.renderTriggerIcon()} - - {children} - {updateAction && ( - - - Edit - - )} - {removeAction && ( - - - Remove - - )} - + + + {children} + {updateAction && ( + + + Edit + + )} + {removeAction && ( + + + Remove + + )} + + ); } diff --git a/src/renderer/components/menu/menu.tsx b/src/renderer/components/menu/menu.tsx index 228db7ceb4..30780e7ac4 100644 --- a/src/renderer/components/menu/menu.tsx +++ b/src/renderer/components/menu/menu.tsx @@ -260,6 +260,8 @@ export class Menu extends React.Component { } onBlur() { + if (!this.isOpen) return; // Prevents triggering document.activeElement for each instance + if (document.activeElement?.tagName == "IFRAME") { this.close(); } diff --git a/src/renderer/components/render-delay/__tests__/render-delay.test.tsx b/src/renderer/components/render-delay/__tests__/render-delay.test.tsx new file mode 100644 index 0000000000..f81003e588 --- /dev/null +++ b/src/renderer/components/render-delay/__tests__/render-delay.test.tsx @@ -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("", () => { + it("renders w/o errors", () => { + const { container } = render(); + + expect(container).toBeInstanceOf(HTMLElement); + }); + + it("renders it's child", () => { + const { getByText } = render(); + + expect(getByText("My button")).toBeInTheDocument(); + }); +}); diff --git a/src/renderer/components/render-delay/render-delay.tsx b/src/renderer/components/render-delay/render-delay.tsx new file mode 100644 index 0000000000..b90cb9148a --- /dev/null +++ b/src/renderer/components/render-delay/render-delay.tsx @@ -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 { + @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; + } +} diff --git a/types/dom.d.ts b/types/dom.d.ts index bb829706f0..72a2488aca 100644 --- a/types/dom.d.ts +++ b/types/dom.d.ts @@ -24,4 +24,9 @@ declare global { interface Element { scrollIntoViewIfNeeded(opt_center?: boolean): void; } + + interface Window { + requestIdleCallback(callback: () => void, options: { timeout: number }); + cancelIdleCallback(callback: () => void); + } }