From 15249df5557d31fa776c6e9054a61f11168fb705 Mon Sep 17 00:00:00 2001 From: Janne Savolainen Date: Wed, 19 Jan 2022 10:56:33 +0200 Subject: [PATCH] Fix involuntary re-renders in item list layout Co-authored-by: Mikko Aspiala Signed-off-by: Janne Savolainen --- .../components/+apps-releases/releases.tsx | 15 +- .../item-list-layout-content.tsx | 271 +++++++++++ .../item-list-layout-filters.tsx | 32 ++ .../item-list-layout-header-filters.tsx | 17 + .../item-list-layout-header-info.tsx | 55 +++ .../item-list-layout-header-search.tsx | 32 ++ .../item-list-layout-header-title.tsx | 25 + .../item-list-layout-header.tsx | 87 ++++ .../item-object-list/item-list-layout.tsx | 460 +++++------------- .../kube-object-list-layout.tsx | 4 +- 10 files changed, 644 insertions(+), 354 deletions(-) create mode 100644 src/renderer/components/item-object-list/item-list-layout-content/item-list-layout-content.tsx create mode 100644 src/renderer/components/item-object-list/item-list-layout-filters/item-list-layout-filters.tsx create mode 100644 src/renderer/components/item-object-list/item-list-layout-header/item-list-layout-header-filters/item-list-layout-header-filters.tsx create mode 100644 src/renderer/components/item-object-list/item-list-layout-header/item-list-layout-header-info/item-list-layout-header-info.tsx create mode 100644 src/renderer/components/item-object-list/item-list-layout-header/item-list-layout-header-search/item-list-layout-header-search.tsx create mode 100644 src/renderer/components/item-object-list/item-list-layout-header/item-list-layout-header-title/item-list-layout-header-title.tsx create mode 100644 src/renderer/components/item-object-list/item-list-layout-header/item-list-layout-header.tsx diff --git a/src/renderer/components/+apps-releases/releases.tsx b/src/renderer/components/+apps-releases/releases.tsx index a71cc8e7a1..34f9798783 100644 --- a/src/renderer/components/+apps-releases/releases.tsx +++ b/src/renderer/components/+apps-releases/releases.tsx @@ -23,10 +23,8 @@ import { ReleaseRollbackDialog } from "./release-rollback-dialog"; import { ReleaseDetails } from "./release-details/release-details"; import removableReleasesInjectable from "./removable-releases.injectable"; import type { RemovableHelmRelease } from "./removable-releases"; -import { observer } from "mobx-react"; import type { IComputedValue } from "mobx"; import releasesInjectable from "./releases.injectable"; -import { Spinner } from "../spinner"; enum columnId { name = "name", @@ -48,7 +46,6 @@ interface Dependencies { selectNamespace: (namespace: string) => void } -@observer class NonInjectedHelmReleases extends Component { componentDidMount() { const { match: { params: { namespace }}} = this.props; @@ -89,12 +86,8 @@ class NonInjectedHelmReleases extends Component { } render() { - if (this.props.releasesArePending.get()) { - // TODO: Make Spinner "center" work properly - return
; - } - const releases = this.props.releases; + const releasesArePending = this.props.releasesArePending; // TODO: Implement ItemListLayout without stateful stores const legacyReleaseStore = { @@ -103,7 +96,11 @@ class NonInjectedHelmReleases extends Component { }, loadAll: () => Promise.resolve(), - isLoaded: true, + + get isLoaded() { + return !releasesArePending.get(); + }, + failedLoading: false, getTotalCount: () => releases.get().length, diff --git a/src/renderer/components/item-object-list/item-list-layout-content/item-list-layout-content.tsx b/src/renderer/components/item-object-list/item-list-layout-content/item-list-layout-content.tsx new file mode 100644 index 0000000000..b8e716ce8b --- /dev/null +++ b/src/renderer/components/item-object-list/item-list-layout-content/item-list-layout-content.tsx @@ -0,0 +1,271 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "../item-list-layout.scss"; + +import React, { ReactNode } from "react"; +import { computed, makeObservable } from "mobx"; +import { observer } from "mobx-react"; +import { ConfirmDialog, ConfirmDialogParams } from "../../confirm-dialog"; +import { Table, TableCell, TableCellProps, TableHead, TableProps, TableRow, TableRowProps, TableSortCallbacks } from "../../table"; +import { boundMethod, cssNames, IClassName, isReactNode, prevDefault, stopPropagation } from "../../../utils"; +import { AddRemoveButtons, AddRemoveButtonsProps } from "../../add-remove-buttons"; +import { NoItems } from "../../no-items"; +import { Spinner } from "../../spinner"; +import type { ItemObject, ItemStore } from "../../../../common/item.store"; +import { Filter, pageFilters } from "../page-filters.store"; +import { ThemeStore } from "../../../theme.store"; +import { MenuActions } from "../../menu/menu-actions"; +import { MenuItem } from "../../menu"; +import { Checkbox } from "../../checkbox"; +import { UserStore } from "../../../../common/user-store"; + +interface ItemListLayoutContentProps { + getFilters: () => Filter[] + tableId?: string; + className: IClassName; + getItems: () => I[]; + store: ItemStore; + getIsReady: () => boolean; // show loading indicator while not ready + isSelectable?: boolean; // show checkbox in rows for selecting items + isConfigurable?: boolean; + copyClassNameFromHeadCells?: boolean; + sortingCallbacks?: TableSortCallbacks; + tableProps?: Partial>; // low-level table configuration + renderTableHeader: TableCellProps[] | null; + renderTableContents: (item: I) => (ReactNode | TableCellProps)[]; + renderItemMenu?: (item: I, store: ItemStore) => ReactNode; + customizeTableRowProps?: (item: I) => Partial; + addRemoveButtons?: Partial; + virtual?: boolean; + + // item details view + hasDetailsView?: boolean; + detailsItem?: I; + onDetails?: (item: I) => void; + + // other + customizeRemoveDialog?: (selectedItems: I[]) => Partial; + + /** + * Message to display when a store failed to load + * + * @default "Failed to load items" + */ + failedToLoadMessage?: React.ReactNode; +} + +@observer +export class ItemListLayoutContent extends React.Component> { + constructor(props: ItemListLayoutContentProps) { + super(props); + makeObservable(this); + } + + @computed get failedToLoad() { + return this.props.store.failedLoading; + } + + @boundMethod + getRow(uid: string) { + const { + isSelectable, renderTableHeader, renderTableContents, renderItemMenu, + store, hasDetailsView, onDetails, + copyClassNameFromHeadCells, customizeTableRowProps, detailsItem, + } = this.props; + const { isSelected } = store; + const item = this.props.getItems().find(item => item.getId() == uid); + + if (!item) return null; + const itemId = item.getId(); + + return ( + onDetails(item)) : undefined} + {...customizeTableRowProps(item)} + > + {isSelectable && ( + store.toggleSelection(item))} + /> + )} + { + renderTableContents(item).map((content, index) => { + const cellProps: TableCellProps = isReactNode(content) ? { children: content } : content; + const headCell = renderTableHeader?.[index]; + + if (copyClassNameFromHeadCells && headCell) { + cellProps.className = cssNames(cellProps.className, headCell.className); + } + + if (!headCell || this.showColumn(headCell)) { + return ; + } + + return null; + }) + } + {renderItemMenu && ( + +
+ {renderItemMenu(item, store)} +
+
+ )} +
+ ); + } + + @boundMethod + removeItemsDialog() { + const { customizeRemoveDialog, store } = this.props; + const { selectedItems, removeSelectedItems } = store; + const visibleMaxNamesCount = 5; + const selectedNames = selectedItems.map(ns => ns.getName()).slice(0, visibleMaxNamesCount).join(", "); + const dialogCustomProps = customizeRemoveDialog ? customizeRemoveDialog(selectedItems) : {}; + const selectedCount = selectedItems.length; + const tailCount = selectedCount > visibleMaxNamesCount ? selectedCount - visibleMaxNamesCount : 0; + const tail = tailCount > 0 ? <>, and {tailCount} more : null; + const message = selectedCount <= 1 ?

Remove item {selectedNames}?

:

Remove {selectedCount} items {selectedNames}{tail}?

; + + ConfirmDialog.open({ + ok: removeSelectedItems, + labelOk: "Remove", + message, + ...dialogCustomProps, + }); + } + + renderNoItems() { + if (this.failedToLoad) { + return {this.props.failedToLoadMessage}; + } + + if (!this.props.getIsReady()) { + return ; + } + + if (this.props.getFilters().length > 0) { + return ( + + No items found. +

+ pageFilters.reset()} className="contrast"> + Reset filters? + +

+
+ ); + } + + return ; + } + + renderItems() { + if (this.props.virtual) { + return null; + } + + return this.props.getItems().map(item => this.getRow(item.getId())); + } + + renderTableHeader() { + const { customizeTableRowProps, renderTableHeader, isSelectable, isConfigurable, store } = this.props; + + if (!renderTableHeader) { + return null; + } + + const enabledItems = this.props.getItems().filter(item => !customizeTableRowProps(item).disabled); + + return ( + + {isSelectable && ( + store.toggleSelectionAll(enabledItems))} + /> + )} + {renderTableHeader.map((cellProps, index) => ( + this.showColumn(cellProps) && ( + + ) + ))} + + {isConfigurable && this.renderColumnVisibilityMenu()} + + + ); + } + + render() { + const { + store, hasDetailsView, addRemoveButtons = {}, virtual, sortingCallbacks, + detailsItem, className, tableProps = {}, tableId, + } = this.props; + const { selectedItems } = store; + const selectedItemId = detailsItem && detailsItem.getId(); + const classNames = cssNames(className, "box", "grow", ThemeStore.getInstance().activeTheme.type); + + return ( +
+ + {this.renderTableHeader()} + {this.renderItems()} +
+ +
+ ); + } + + showColumn({ id: columnId, showWithColumn }: TableCellProps): boolean { + const { tableId, isConfigurable } = this.props; + + return !isConfigurable || !UserStore.getInstance().isTableColumnHidden(tableId, columnId, showWithColumn); + } + + renderColumnVisibilityMenu() { + const { renderTableHeader, tableId } = this.props; + + return ( + + {renderTableHeader.map((cellProps, index) => ( + !cellProps.showWithColumn && ( + + `} + value={this.showColumn(cellProps)} + onChange={() => UserStore.getInstance().toggleTableColumnVisibility(tableId, cellProps.id)} + /> + + ) + ))} + + ); + } +} diff --git a/src/renderer/components/item-object-list/item-list-layout-filters/item-list-layout-filters.tsx b/src/renderer/components/item-object-list/item-list-layout-filters/item-list-layout-filters.tsx new file mode 100644 index 0000000000..bea811a105 --- /dev/null +++ b/src/renderer/components/item-object-list/item-list-layout-filters/item-list-layout-filters.tsx @@ -0,0 +1,32 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "../item-list-layout.scss"; + +import React from "react"; +import { PageFiltersList } from "../page-filters-list"; +import { observer } from "mobx-react"; +import type { Filter } from "../page-filters.store"; + +interface ItemListLayoutFilterProps { + getIsReady: () => boolean + getFilters: () => Filter[] + getFiltersAreShown: () => boolean + hideFilters: boolean +} + +@observer +export class ItemListLayoutFilters extends React.Component { + render() { + const filters = this.props.getFilters(); + + if (!this.props.getIsReady() || !filters.length || this.props.hideFilters || !this.props.getFiltersAreShown()) { + return null; + } + + return ; + } +} + diff --git a/src/renderer/components/item-object-list/item-list-layout-header/item-list-layout-header-filters/item-list-layout-header-filters.tsx b/src/renderer/components/item-object-list/item-list-layout-header/item-list-layout-header-filters/item-list-layout-header-filters.tsx new file mode 100644 index 0000000000..b271e90890 --- /dev/null +++ b/src/renderer/components/item-object-list/item-list-layout-header/item-list-layout-header-filters/item-list-layout-header-filters.tsx @@ -0,0 +1,17 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { observer } from "mobx-react"; +import React from "react"; +import type { HeaderPlaceholders } from "../../item-list-layout"; + +interface ItemListLayoutHeaderFiltersProps { + headerPlaceholders: HeaderPlaceholders +} + +export const ItemListLayoutHeaderFilters = observer( + ({ headerPlaceholders }: ItemListLayoutHeaderFiltersProps) => ( + <>{headerPlaceholders.filters} + ), +); diff --git a/src/renderer/components/item-object-list/item-list-layout-header/item-list-layout-header-info/item-list-layout-header-info.tsx b/src/renderer/components/item-object-list/item-list-layout-header/item-list-layout-header-info/item-list-layout-header-info.tsx new file mode 100644 index 0000000000..8f767e78de --- /dev/null +++ b/src/renderer/components/item-object-list/item-list-layout-header/item-list-layout-header-info/item-list-layout-header-info.tsx @@ -0,0 +1,55 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import React from "react"; +import { observer } from "mobx-react"; +import type { ItemObject, ItemStore } from "../../../../../common/item.store"; +import type { Filter } from "../../page-filters.store"; +import type { HeaderPlaceholders } from "../../item-list-layout"; + +interface ItemListLayoutHeaderInfoProps { + headerPlaceholders: HeaderPlaceholders; + getItems: () => I[]; + store: ItemStore; + getFilters: () => Filter[] + toggleFilters: () => void +} + +export const ItemListLayoutHeaderInfo = observer( + ({ + headerPlaceholders, + getItems, + getFilters, + store, + toggleFilters, + }: ItemListLayoutHeaderInfoProps) => { + const renderInfo = () => { + const allItemsCount = store.getTotalCount(); + const itemsCount = getItems().length; + + if (getFilters().length > 0) { + return ( + <> + Filtered: {itemsCount} /{" "} + {allItemsCount} + + ); + } + + return allItemsCount === 1 + ? `${allItemsCount} item` + : `${allItemsCount} items`; + }; + + const info = headerPlaceholders.info === undefined + ? renderInfo() + : headerPlaceholders.info; + + if (!info) { + return null; + } + + return
{info}
; + }, +); diff --git a/src/renderer/components/item-object-list/item-list-layout-header/item-list-layout-header-search/item-list-layout-header-search.tsx b/src/renderer/components/item-object-list/item-list-layout-header/item-list-layout-header-search/item-list-layout-header-search.tsx new file mode 100644 index 0000000000..223f4b2acf --- /dev/null +++ b/src/renderer/components/item-object-list/item-list-layout-header/item-list-layout-header-search/item-list-layout-header-search.tsx @@ -0,0 +1,32 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { observer } from "mobx-react"; +import type { ItemObject } from "../../../../../common/item.store"; +import { SearchInputUrl } from "../../../input"; +import React from "react"; +import type { HeaderPlaceholders, SearchFilter } from "../../item-list-layout"; + +interface ItemListLayoutHeaderSearchProps { + searchFilters: SearchFilter[]; + headerPlaceholders: HeaderPlaceholders; +} + +export const ItemListLayoutHeaderSearch = observer( + ({ + searchFilters, + headerPlaceholders, + }: ItemListLayoutHeaderSearchProps) => { + const searchProps = + headerPlaceholders.searchProps !== undefined + ? headerPlaceholders.searchProps + : {}; + + if (searchFilters.length === 0 || !searchProps) { + return null; + } + + return ; + }, +); diff --git a/src/renderer/components/item-object-list/item-list-layout-header/item-list-layout-header-title/item-list-layout-header-title.tsx b/src/renderer/components/item-object-list/item-list-layout-header/item-list-layout-header-title/item-list-layout-header-title.tsx new file mode 100644 index 0000000000..6402dbca79 --- /dev/null +++ b/src/renderer/components/item-object-list/item-list-layout-header/item-list-layout-header-title/item-list-layout-header-title.tsx @@ -0,0 +1,25 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { observer } from "mobx-react"; +import React from "react"; +import type { HeaderPlaceholders } from "../../item-list-layout"; + +interface ItemListLayoutHeaderTitleProps { + renderHeaderTitle: React.ReactNode | (() => React.ReactNode); // MIKKO: "this" parameter + headerPlaceholders: HeaderPlaceholders; +} + +export const ItemListLayoutHeaderTitle = observer(({ renderHeaderTitle, headerPlaceholders }: ItemListLayoutHeaderTitleProps) => { + if (headerPlaceholders.title) { + return <>{headerPlaceholders.title}; + } + + const title = + typeof renderHeaderTitle === "function" + ? renderHeaderTitle() + : renderHeaderTitle; + + return
{title}
; +}); diff --git a/src/renderer/components/item-object-list/item-list-layout-header/item-list-layout-header.tsx b/src/renderer/components/item-object-list/item-list-layout-header/item-list-layout-header.tsx new file mode 100644 index 0000000000..72b0d58b31 --- /dev/null +++ b/src/renderer/components/item-object-list/item-list-layout-header/item-list-layout-header.tsx @@ -0,0 +1,87 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import "../item-list-layout.scss"; + +import React, { ReactNode } from "react"; +import { observer } from "mobx-react"; +import { cssNames, IClassName } from "../../../utils"; +import type { ItemObject, ItemStore } from "../../../../common/item.store"; +import type { Filter } from "../page-filters.store"; +import { ItemListLayoutHeaderTitle } from "./item-list-layout-header-title/item-list-layout-header-title"; +import { ItemListLayoutHeaderInfo } from "./item-list-layout-header-info/item-list-layout-header-info"; +import { ItemListLayoutHeaderFilters } from "./item-list-layout-header-filters/item-list-layout-header-filters"; +import { ItemListLayoutHeaderSearch } from "./item-list-layout-header-search/item-list-layout-header-search"; +import type { HeaderCustomizer, SearchFilter } from "../item-list-layout"; + +interface ItemListLayoutHeaderProps { + getItems: () => I[]; + getFilters: () => Filter[]; + toggleFilters: () => void; + + store: ItemStore; + searchFilters?: SearchFilter[]; + + // header (title, filtering, searching, etc.) + showHeader?: boolean; + headerClassName?: IClassName; + renderHeaderTitle?: + | ReactNode + | ((parent: ItemListLayoutHeader) => ReactNode); + customizeHeader?: HeaderCustomizer | HeaderCustomizer[]; +} + +@observer +export class ItemListLayoutHeader extends React.Component< + ItemListLayoutHeaderProps +> { + render() { + const { + showHeader, + customizeHeader, + renderHeaderTitle, + headerClassName, + searchFilters, + } = this.props; + + if (!showHeader) { + return null; + } + + const customizeHeaders = [customizeHeader].flat().filter(Boolean); + + const headerPlaceholders = customizeHeaders.reduce( + (prevPlaceholders, customizer) => customizer(prevPlaceholders), + {}, + ); + + + return ( +
+ + + + + + + +
+ ); + } +} diff --git a/src/renderer/components/item-object-list/item-list-layout.tsx b/src/renderer/components/item-object-list/item-list-layout.tsx index 2d6a91fe70..b5846e98a9 100644 --- a/src/renderer/components/item-object-list/item-list-layout.tsx +++ b/src/renderer/components/item-object-list/item-list-layout.tsx @@ -4,42 +4,40 @@ */ import "./item-list-layout.scss"; -import groupBy from "lodash/groupBy"; import React, { ReactNode } from "react"; -import { computed, makeObservable } from "mobx"; -import { observer } from "mobx-react"; -import { ConfirmDialog, ConfirmDialogParams } from "../confirm-dialog"; -import { Table, TableCell, TableCellProps, TableHead, TableProps, TableRow, TableRowProps, TableSortCallbacks } from "../table"; +import { computed, makeObservable, untracked } from "mobx"; +import type { ConfirmDialogParams } from "../confirm-dialog"; +import type { + TableCellProps, + TableProps, + TableRowProps, + TableSortCallbacks, +} from "../table"; import { boundMethod, cssNames, IClassName, - isReactNode, noop, ObservableToggleSet, - prevDefault, - stopPropagation, StorageHelper, } from "../../utils"; -import { AddRemoveButtons, AddRemoveButtonsProps } from "../add-remove-buttons"; -import { NoItems } from "../no-items"; -import { Spinner } from "../spinner"; +import type { AddRemoveButtonsProps } from "../add-remove-buttons"; import type { ItemObject, ItemStore } from "../../../common/item.store"; -import { SearchInputUrlProps, SearchInputUrl } from "../input"; +import type { SearchInputUrlProps } from "../input"; import { Filter, FilterType, pageFilters } from "./page-filters.store"; import { PageFiltersList } from "./page-filters-list"; -import { ThemeStore } from "../../theme.store"; -import { MenuActions } from "../menu/menu-actions"; -import { MenuItem } from "../menu"; -import { Checkbox } from "../checkbox"; import { UserStore } from "../../../common/user-store"; import type { NamespaceStore } from "../+namespaces/namespace-store/namespace.store"; import namespaceStoreInjectable from "../+namespaces/namespace-store/namespace-store.injectable"; import { withInjectables } from "@ogre-tools/injectable-react"; import itemListLayoutStorageInjectable from "./item-list-layout-storage/item-list-layout-storage.injectable"; - +import { ItemListLayoutContent } from "./item-list-layout-content/item-list-layout-content"; +import { ItemListLayoutHeader } from "./item-list-layout-header/item-list-layout-header"; +import groupBy from "lodash/groupBy"; +import { ItemListLayoutFilters } from "./item-list-layout-filters/item-list-layout-filters"; +import { observer } from "mobx-react"; export type SearchFilter = (item: I) => string | number | (string | number)[]; export type SearchFilters = Record>; @@ -58,6 +56,7 @@ export interface ItemListLayoutProps { tableId?: string; className: IClassName; items?: I[]; + getItems?: () => I[]; store: ItemStore; dependentStores?: ItemStore[]; preloadStores?: boolean; @@ -136,14 +135,6 @@ class NonInjectedItemListLayout extends React.Component extends React.Component store.loadAll(this.props.namespaceStore.contextNamespaces)); } + get showFilters(): boolean { + return this.props.itemListLayoutStorage.get().showFilters; + } + + set showFilters(showFilters: boolean) { + this.props.itemListLayoutStorage.merge({ showFilters }); + } + + @computed get filters() { + let { activeFilters } = pageFilters; + const { searchFilters } = this.props; + + if (searchFilters.length === 0) { + activeFilters = activeFilters.filter(({ type }) => type !== FilterType.SEARCH); + } + + return activeFilters; + } + + @boundMethod + toggleFilters() { + this.showFilters = !this.showFilters; + } + + @computed get isReady() { + return this.props.isReady ?? this.props.store.isLoaded; + } + + renderFilters() { + const { hideFilters } = this.props; + const { isReady, filters } = this; + + if (!isReady || !filters.length || hideFilters || !this.showFilters) { + return null; + } + + return ; + } + private filterCallbacks: ItemsFilters = { [FilterType.SEARCH]: items => { const { searchFilters } = this.props; @@ -189,341 +219,76 @@ class NonInjectedItemListLayout extends React.Component type !== FilterType.SEARCH); - } - - return activeFilters; - } - - applyFilters(filters: ItemsFilter[], items: I[]): I[] { - if (!filters || !filters.length) return items; - - return filters.reduce((items, filter) => filter(items), items); - } - @computed get items() { - const { filters, filterCallbacks, props } = this; - const filterGroups = groupBy(filters, ({ type }) => type); + const filterGroups = groupBy(this.filters, ({ type }) => type); const filterItems: ItemsFilter[] = []; Object.entries(filterGroups).forEach(([type, filtersGroup]) => { - const filterCallback = filterCallbacks[type] ?? props.filterCallbacks?.[type]; + const filterCallback = this.filterCallbacks[type] ?? this.props.filterCallbacks?.[type]; if (filterCallback && filtersGroup.length > 0) { filterItems.push(filterCallback); } }); - const items = this.props.items ?? this.props.store.items; + const items = this.props.getItems ? this.props.getItems() : (this.props.items ?? this.props.store.items); - return this.applyFilters(filterItems.concat(this.props.filterItems), items); - } - - @boundMethod - getRow(uid: string) { - const { - isSelectable, renderTableHeader, renderTableContents, renderItemMenu, - store, hasDetailsView, onDetails, - copyClassNameFromHeadCells, customizeTableRowProps, detailsItem, - } = this.props; - const { isSelected } = store; - const item = this.items.find(item => item.getId() == uid); - - if (!item) return null; - const itemId = item.getId(); - - return ( - onDetails(item)) : undefined} - {...customizeTableRowProps(item)} - > - {isSelectable && ( - store.toggleSelection(item))} - /> - )} - { - renderTableContents(item).map((content, index) => { - const cellProps: TableCellProps = isReactNode(content) ? { children: content } : content; - const headCell = renderTableHeader?.[index]; - - if (copyClassNameFromHeadCells && headCell) { - cellProps.className = cssNames(cellProps.className, headCell.className); - } - - if (!headCell || this.showColumn(headCell)) { - return ; - } - - return null; - }) - } - {renderItemMenu && ( - -
- {renderItemMenu(item, store)} -
-
- )} -
- ); - } - - @boundMethod - removeItemsDialog() { - const { customizeRemoveDialog, store } = this.props; - const { selectedItems, removeSelectedItems } = store; - const visibleMaxNamesCount = 5; - const selectedNames = selectedItems.map(ns => ns.getName()).slice(0, visibleMaxNamesCount).join(", "); - const dialogCustomProps = customizeRemoveDialog ? customizeRemoveDialog(selectedItems) : {}; - const selectedCount = selectedItems.length; - const tailCount = selectedCount > visibleMaxNamesCount ? selectedCount - visibleMaxNamesCount : 0; - const tail = tailCount > 0 ? <>, and {tailCount} more : null; - const message = selectedCount <= 1 ?

Remove item {selectedNames}?

:

Remove {selectedCount} items {selectedNames}{tail}?

; - - ConfirmDialog.open({ - ok: removeSelectedItems, - labelOk: "Remove", - message, - ...dialogCustomProps, - }); - } - - @boundMethod - toggleFilters() { - this.showFilters = !this.showFilters; - } - - renderFilters() { - const { hideFilters } = this.props; - const { isReady, filters } = this; - - if (!isReady || !filters.length || hideFilters || !this.showFilters) { - return null; - } - - return ; - } - - renderNoItems() { - if (this.failedToLoad) { - return {this.props.failedToLoadMessage}; - } - - if (!this.isReady) { - return ; - } - - if (this.filters.length > 0) { - return ( - - No items found. -

- pageFilters.reset()} className="contrast"> - Reset filters? - -

-
- ); - } - - return ; - } - - renderItems() { - if (this.props.virtual) { - return null; - } - - return this.items.map(item => this.getRow(item.getId())); - } - - renderHeaderContent(placeholders: HeaderPlaceholders): ReactNode { - const { searchFilters } = this.props; - const { title, filters, searchProps, info } = placeholders; - - return ( - <> - {title} - { - info && ( -
- {info} -
- ) - } - {filters} - {searchFilters.length > 0 && searchProps && } - - ); - } - - renderInfo() { - const { items, filters } = this; - const allItemsCount = this.props.store.getTotalCount(); - const itemsCount = items.length; - - if (filters.length > 0) { - return ( - <>Filtered: {itemsCount} / {allItemsCount} - ); - } - - return allItemsCount === 1 ? `${allItemsCount} item` : `${allItemsCount} items`; - } - - renderHeader() { - const { showHeader, customizeHeader, renderHeaderTitle, headerClassName } = this.props; - - if (!showHeader) { - return null; - } - - const title = typeof renderHeaderTitle === "function" ? renderHeaderTitle(this) : renderHeaderTitle; - const customizeHeaders = [customizeHeader].flat().filter(Boolean); - const initialPlaceholders: HeaderPlaceholders = { - title:
{title}
, - info: this.renderInfo(), - searchProps: {}, - }; - const headerPlaceholders = customizeHeaders.reduce((prevPlaceholders, customizer) => customizer(prevPlaceholders), initialPlaceholders); - const header = this.renderHeaderContent(headerPlaceholders); - - return ( -
- {header} -
- ); - } - - renderTableHeader() { - const { customizeTableRowProps, renderTableHeader, isSelectable, isConfigurable, store } = this.props; - - if (!renderTableHeader) { - return null; - } - - const enabledItems = this.items.filter(item => !customizeTableRowProps(item).disabled); - - return ( - - {isSelectable && ( - store.toggleSelectionAll(enabledItems))} - /> - )} - {renderTableHeader.map((cellProps, index) => ( - this.showColumn(cellProps) && ( - - ) - ))} - - {isConfigurable && this.renderColumnVisibilityMenu()} - - - ); - } - - renderList() { - const { - store, hasDetailsView, addRemoveButtons = {}, virtual, sortingCallbacks, - detailsItem, className, tableProps = {}, tableId, - } = this.props; - const { removeItemsDialog, items } = this; - const { selectedItems } = store; - const selectedItemId = detailsItem && detailsItem.getId(); - const classNames = cssNames(className, "box", "grow", ThemeStore.getInstance().activeTheme.type); - - return ( -
- - {this.renderTableHeader()} - {this.renderItems()} -
- -
- ); - } - - showColumn({ id: columnId, showWithColumn }: TableCellProps): boolean { - const { tableId, isConfigurable } = this.props; - - return !isConfigurable || !UserStore.getInstance().isTableColumnHidden(tableId, columnId, showWithColumn); - } - - renderColumnVisibilityMenu() { - const { renderTableHeader, tableId } = this.props; - - return ( - - {renderTableHeader.map((cellProps, index) => ( - !cellProps.showWithColumn && ( - - `} - value={this.showColumn(cellProps)} - onChange={() => UserStore.getInstance().toggleTableColumnVisibility(tableId, cellProps.id)} - /> - - ) - ))} - - ); - } - - renderFooter() { - return this.props.renderFooter?.(this); + return applyFilters(filterItems.concat(this.props.filterItems), items); } render() { - const { className } = this.props; + return untracked(() => ( +
+ this.items} + getFilters={() => this.filters} + toggleFilters={this.toggleFilters} + store={this.props.store} + searchFilters={this.props.searchFilters} + showHeader={this.props.showHeader} + headerClassName={this.props.headerClassName} + renderHeaderTitle={this.props.renderHeaderTitle} + customizeHeader={this.props.customizeHeader} + /> - return ( -
- {this.renderHeader()} - {this.renderFilters()} - {this.renderList()} - {this.renderFooter()} + this.isReady} + getFilters={() => this.filters} + getFiltersAreShown={() => this.showFilters} + hideFilters={this.props.hideFilters} + /> + + this.items} + getFilters={() => this.filters} + tableId={this.props.tableId} + className={this.props.className} + store={this.props.store} + getIsReady={() => this.isReady} + isSelectable={this.props.isSelectable} + isConfigurable={this.props.isConfigurable} + copyClassNameFromHeadCells={this.props.copyClassNameFromHeadCells} + sortingCallbacks={this.props.sortingCallbacks} + tableProps={this.props.tableProps} + renderTableHeader={this.props.renderTableHeader} + renderTableContents={this.props.renderTableContents} + renderItemMenu={this.props.renderItemMenu} + customizeTableRowProps={this.props.customizeTableRowProps} + addRemoveButtons={this.props.addRemoveButtons} + virtual={this.props.virtual} + hasDetailsView={this.props.hasDetailsView} + detailsItem={this.props.detailsItem} + onDetails={this.props.onDetails} + customizeRemoveDialog={this.props.customizeRemoveDialog} + failedToLoadMessage={this.props.failedToLoadMessage} + /> + + {this.props.renderFooter && this.props.renderFooter(this)}
- ); + )); } } @@ -547,3 +312,12 @@ export function ItemListLayout( return ; } + +const applyFilters = ( + filters: ItemsFilter[], + items: I[], +): I[] => { + if (!filters || !filters.length) return items; + + return filters.reduce((items, filter) => filter(items), items); +}; diff --git a/src/renderer/components/kube-object-list-layout/kube-object-list-layout.tsx b/src/renderer/components/kube-object-list-layout/kube-object-list-layout.tsx index e02a069c25..a3bf749fc3 100644 --- a/src/renderer/components/kube-object-list-layout/kube-object-list-layout.tsx +++ b/src/renderer/components/kube-object-list-layout/kube-object-list-layout.tsx @@ -99,7 +99,7 @@ class NonInjectedKubeObjectListLayout extends React.Compon } render() { - const { className, customizeHeader, store, items = store.contextItems, ...layoutProps } = this.props; + const { className, customizeHeader, store, items, ...layoutProps } = this.props; const placeholderString = ResourceNames[ResourceKindMap[store.api.kind]] || store.api.kind; return ( @@ -107,7 +107,7 @@ class NonInjectedKubeObjectListLayout extends React.Compon {...layoutProps} className={cssNames("KubeObjectListLayout", className)} store={store} - items={items} + getItems={() => this.props.items || store.contextItems} preloadStores={false} // loading handled in kubeWatchApi.subscribeStores() detailsItem={this.selectedItem} customizeHeader={[