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

Fix involuntary rerenders in item list layout (#4720)

* Fix involuntary re-renders in item list layout

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

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

* Enable skipped integration tests for being fixed

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

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

* Remove comment

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

* Improve code style

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

* flatten file heirarchy

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

* Fix item list layout header rendering bug

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

Co-authored-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Janne Savolainen 2022-01-31 07:38:24 +01:00 committed by Sebastian Malton
parent 8214076ca0
commit 44a060ff42
12 changed files with 733 additions and 569 deletions

View File

@ -354,8 +354,7 @@ utils.describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => {
} }
}, 10*60*1000); }, 10*60*1000);
// TODO: Make re-rendering of KubeObjectListLayout not cause namespaceSelector to be closed it("show logs and highlight the log search entries", async () => {
xit("show logs and highlight the log search entries", async () => {
await frame.click(`a[href="/workloads"]`); await frame.click(`a[href="/workloads"]`);
await frame.click(`a[href="/pods"]`); await frame.click(`a[href="/pods"]`);
@ -400,8 +399,7 @@ utils.describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => {
await frame.waitForSelector("div.TableCell >> text='kube-system'"); await frame.waitForSelector("div.TableCell >> text='kube-system'");
}, 10*60*1000); }, 10*60*1000);
// TODO: Make re-rendering of KubeObjectListLayout not cause namespaceSelector to be closed it(`should create the ${TEST_NAMESPACE} and a pod in the namespace`, async () => {
xit(`should create the ${TEST_NAMESPACE} and a pod in the namespace`, async () => {
await frame.click('a[href="/namespaces"]'); await frame.click('a[href="/namespaces"]');
await frame.click("button.add-button"); await frame.click("button.add-button");
await frame.waitForSelector("div.AddNamespaceDialog >> text='Create Namespace'"); await frame.waitForSelector("div.AddNamespaceDialog >> text='Create Namespace'");

View File

@ -12,7 +12,7 @@ import { helmChartStore } from "./helm-chart.store";
import type { HelmChart } from "../../../common/k8s-api/endpoints/helm-charts.api"; import type { HelmChart } from "../../../common/k8s-api/endpoints/helm-charts.api";
import { HelmChartDetails } from "./helm-chart-details"; import { HelmChartDetails } from "./helm-chart-details";
import { navigation } from "../../navigation"; import { navigation } from "../../navigation";
import { ItemListLayout } from "../item-object-list/item-list-layout"; import { ItemListLayout } from "../item-object-list/list-layout";
import { helmChartsURL } from "../../../common/routes"; import { helmChartsURL } from "../../../common/routes";
import type { HelmChartsRouteParams } from "../../../common/routes"; import type { HelmChartsRouteParams } from "../../../common/routes";

View File

@ -23,10 +23,8 @@ import { ReleaseRollbackDialog } from "./release-rollback-dialog";
import { ReleaseDetails } from "./release-details/release-details"; import { ReleaseDetails } from "./release-details/release-details";
import removableReleasesInjectable from "./removable-releases.injectable"; import removableReleasesInjectable from "./removable-releases.injectable";
import type { RemovableHelmRelease } from "./removable-releases"; import type { RemovableHelmRelease } from "./removable-releases";
import { observer } from "mobx-react";
import type { IComputedValue } from "mobx"; import type { IComputedValue } from "mobx";
import releasesInjectable from "./releases.injectable"; import releasesInjectable from "./releases.injectable";
import { Spinner } from "../spinner";
enum columnId { enum columnId {
name = "name", name = "name",
@ -48,7 +46,6 @@ interface Dependencies {
selectNamespace: (namespace: string) => void selectNamespace: (namespace: string) => void
} }
@observer
class NonInjectedHelmReleases extends Component<Dependencies & Props> { class NonInjectedHelmReleases extends Component<Dependencies & Props> {
componentDidMount() { componentDidMount() {
const { match: { params: { namespace }}} = this.props; const { match: { params: { namespace }}} = this.props;
@ -89,12 +86,8 @@ class NonInjectedHelmReleases extends Component<Dependencies & Props> {
} }
render() { render() {
if (this.props.releasesArePending.get()) {
// TODO: Make Spinner "center" work properly
return <div className="flex center" style={{ height: "100%" }}><Spinner /></div>;
}
const releases = this.props.releases; const releases = this.props.releases;
const releasesArePending = this.props.releasesArePending;
// TODO: Implement ItemListLayout without stateful stores // TODO: Implement ItemListLayout without stateful stores
const legacyReleaseStore = { const legacyReleaseStore = {
@ -103,7 +96,11 @@ class NonInjectedHelmReleases extends Component<Dependencies & Props> {
}, },
loadAll: () => Promise.resolve(), loadAll: () => Promise.resolve(),
isLoaded: true,
get isLoaded() {
return !releasesArePending.get();
},
failedLoading: false, failedLoading: false,
getTotalCount: () => releases.get().length, getTotalCount: () => releases.get().length,

View File

@ -8,7 +8,7 @@ import "./port-forwards.scss";
import React from "react"; import React from "react";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import type { RouteComponentProps } from "react-router-dom"; import type { RouteComponentProps } from "react-router-dom";
import { ItemListLayout } from "../item-object-list/item-list-layout"; import { ItemListLayout } from "../item-object-list/list-layout";
import type { PortForwardItem, PortForwardStore } from "../../port-forward"; import type { PortForwardItem, PortForwardStore } from "../../port-forward";
import { PortForwardMenu } from "./port-forward-menu"; import { PortForwardMenu } from "./port-forward-menu";
import { PortForwardsRouteParams, portForwardsURL } from "../../../common/routes"; import { PortForwardsRouteParams, portForwardsURL } from "../../../common/routes";

View File

@ -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<I extends ItemObject> {
getFilters: () => Filter[]
tableId?: string;
className: IClassName;
getItems: () => I[];
store: ItemStore<I>;
getIsReady: () => boolean; // show loading indicator while not ready
isSelectable?: boolean; // show checkbox in rows for selecting items
isConfigurable?: boolean;
copyClassNameFromHeadCells?: boolean;
sortingCallbacks?: TableSortCallbacks<I>;
tableProps?: Partial<TableProps<I>>; // low-level table configuration
renderTableHeader: TableCellProps[] | null;
renderTableContents: (item: I) => (ReactNode | TableCellProps)[];
renderItemMenu?: (item: I, store: ItemStore<I>) => ReactNode;
customizeTableRowProps?: (item: I) => Partial<TableRowProps>;
addRemoveButtons?: Partial<AddRemoveButtonsProps>;
virtual?: boolean;
// item details view
hasDetailsView?: boolean;
detailsItem?: I;
onDetails?: (item: I) => void;
// other
customizeRemoveDialog?: (selectedItems: I[]) => Partial<ConfirmDialogParams>;
/**
* Message to display when a store failed to load
*
* @default "Failed to load items"
*/
failedToLoadMessage?: React.ReactNode;
}
@observer
export class ItemListLayoutContent<I extends ItemObject> extends React.Component<ItemListLayoutContentProps<I>> {
constructor(props: ItemListLayoutContentProps<I>) {
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 (
<TableRow
key={itemId}
nowrap
searchItem={item}
sortItem={item}
selected={detailsItem && detailsItem.getId() === itemId}
onClick={hasDetailsView ? prevDefault(() => onDetails(item)) : undefined}
{...customizeTableRowProps(item)}
>
{isSelectable && (
<TableCell
checkbox
isChecked={isSelected(item)}
onClick={prevDefault(() => 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 <TableCell key={index} {...cellProps} />;
}
return null;
})
}
{renderItemMenu && (
<TableCell className="menu">
<div onClick={stopPropagation}>
{renderItemMenu(item, store)}
</div>
</TableCell>
)}
</TableRow>
);
}
@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 <b>{tailCount}</b> more</> : null;
const message = selectedCount <= 1 ? <p>Remove item <b>{selectedNames}</b>?</p> : <p>Remove <b>{selectedCount}</b> items <b>{selectedNames}</b>{tail}?</p>;
ConfirmDialog.open({
ok: removeSelectedItems,
labelOk: "Remove",
message,
...dialogCustomProps,
});
}
renderNoItems() {
if (this.failedToLoad) {
return <NoItems>{this.props.failedToLoadMessage}</NoItems>;
}
if (!this.props.getIsReady()) {
return <Spinner center />;
}
if (this.props.getFilters().length > 0) {
return (
<NoItems>
No items found.
<p>
<a onClick={() => pageFilters.reset()} className="contrast">
Reset filters?
</a>
</p>
</NoItems>
);
}
return <NoItems />;
}
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 (
<TableHead showTopLine nowrap>
{isSelectable && (
<TableCell
checkbox
isChecked={store.isSelectedAll(enabledItems)}
onClick={prevDefault(() => store.toggleSelectionAll(enabledItems))}
/>
)}
{renderTableHeader.map((cellProps, index) => (
this.showColumn(cellProps) && (
<TableCell key={cellProps.id ?? index} {...cellProps} />
)
))}
<TableCell className="menu">
{isConfigurable && this.renderColumnVisibilityMenu()}
</TableCell>
</TableHead>
);
}
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 (
<div className="items box grow flex column">
<Table
tableId={tableId}
virtual={virtual}
selectable={hasDetailsView}
sortable={sortingCallbacks}
getTableRow={this.getRow}
items={this.props.getItems()}
selectedItemId={selectedItemId}
noItems={this.renderNoItems()}
className={classNames}
{...tableProps}
>
{this.renderTableHeader()}
{this.renderItems()}
</Table>
<AddRemoveButtons
onRemove={selectedItems.length ? this.removeItemsDialog : null}
removeTooltip={`Remove selected items (${selectedItems.length})`}
{...addRemoveButtons}
/>
</div>
);
}
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 (
<MenuActions className="ItemListLayoutVisibilityMenu" toolbar={false} autoCloseOnSelect={false}>
{renderTableHeader.map((cellProps, index) => (
!cellProps.showWithColumn && (
<MenuItem key={index} className="input">
<Checkbox
label={cellProps.title ?? `<${cellProps.className}>`}
value={this.showColumn(cellProps)}
onChange={() => UserStore.getInstance().toggleTableColumnVisibility(tableId, cellProps.id)}
/>
</MenuItem>
)
))}
</MenuActions>
);
}
}

View File

@ -0,0 +1,29 @@
/**
* 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";
export interface ItemListLayoutFilterProps {
getIsReady: () => boolean
getFilters: () => Filter[]
getFiltersAreShown: () => boolean
hideFilters: boolean
}
export const ItemListLayoutFilters = observer(({ getFilters, getFiltersAreShown, getIsReady, hideFilters }: ItemListLayoutFilterProps) => {
const filters = getFilters();
if (!getIsReady() || !filters.length || hideFilters || !getFiltersAreShown()) {
return null;
}
return <PageFiltersList filters={filters} />;
});

View File

@ -0,0 +1,105 @@
/**
* 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 type { HeaderCustomizer, HeaderPlaceholders, SearchFilter } from "./list-layout";
import { SearchInputUrl } from "../input";
export interface ItemListLayoutHeaderProps<I extends ItemObject> {
getItems: () => I[];
getFilters: () => Filter[];
toggleFilters: () => void;
store: ItemStore<I>;
searchFilters?: SearchFilter<I>[];
// header (title, filtering, searching, etc.)
showHeader?: boolean;
headerClassName?: IClassName;
renderHeaderTitle?:
| ReactNode
| ((parent: ItemListLayoutHeader<I>) => ReactNode);
customizeHeader?: HeaderCustomizer | HeaderCustomizer[];
}
@observer
export class ItemListLayoutHeader<I extends ItemObject> extends React.Component<
ItemListLayoutHeaderProps<I>
> {
render() {
const {
showHeader,
customizeHeader,
renderHeaderTitle,
headerClassName,
searchFilters,
getItems,
store,
getFilters,
toggleFilters,
} = this.props;
if (!showHeader) {
return null;
}
const renderInfo = () => {
const allItemsCount = store.getTotalCount();
const itemsCount = getItems().length;
if (getFilters().length > 0) {
return (
<>
<a onClick={toggleFilters}>Filtered</a>: {itemsCount} / {allItemsCount}
</>
);
}
return allItemsCount === 1
? `${allItemsCount} item`
: `${allItemsCount} items`;
};
const customizeHeaderFunctions = [customizeHeader].flat().filter(Boolean);
const renderedTitle = typeof renderHeaderTitle === "function"
? renderHeaderTitle(this)
: renderHeaderTitle;
const {
filters,
info,
searchProps,
title,
} = customizeHeaderFunctions.reduce<HeaderPlaceholders>(
(prevPlaceholders, customizer) => customizer(prevPlaceholders),
{
title: <h5 className="title">{renderedTitle}</h5>,
info: renderInfo(),
searchProps: {},
},
);
return (
<div className={cssNames("header flex gaps align-center", headerClassName)}>
{title}
{
info && (
<div className="info-panel box grow">
{info}
</div>
)
}
{filters}
{searchFilters.length > 0 && searchProps && <SearchInputUrl {...searchProps} />}
</div>
);
}
}

View File

@ -3,4 +3,4 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
export * from "./item-list-layout"; export * from "./list-layout";

View File

@ -1,549 +0,0 @@
/**
* 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 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 {
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 { ItemObject, ItemStore } from "../../../common/item.store";
import { SearchInputUrlProps, SearchInputUrl } 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";
export type SearchFilter<I extends ItemObject> = (item: I) => string | number | (string | number)[];
export type SearchFilters<I extends ItemObject> = Record<string, SearchFilter<I>>;
export type ItemsFilter<I extends ItemObject> = (items: I[]) => I[];
export type ItemsFilters<I extends ItemObject> = Record<string, ItemsFilter<I>>;
export interface HeaderPlaceholders {
title?: ReactNode;
searchProps?: SearchInputUrlProps;
filters?: ReactNode;
info?: ReactNode;
}
export type HeaderCustomizer = (placeholders: HeaderPlaceholders) => HeaderPlaceholders;
export interface ItemListLayoutProps<I extends ItemObject> {
tableId?: string;
className: IClassName;
items?: I[];
store: ItemStore<I>;
dependentStores?: ItemStore<ItemObject>[];
preloadStores?: boolean;
hideFilters?: boolean;
searchFilters?: SearchFilter<I>[];
/** @deprecated */
filterItems?: ItemsFilter<I>[];
// header (title, filtering, searching, etc.)
showHeader?: boolean;
headerClassName?: IClassName;
renderHeaderTitle?: ReactNode | ((parent: NonInjectedItemListLayout<I>) => ReactNode);
customizeHeader?: HeaderCustomizer | HeaderCustomizer[];
// items list configuration
isReady?: boolean; // show loading indicator while not ready
isSelectable?: boolean; // show checkbox in rows for selecting items
isConfigurable?: boolean;
copyClassNameFromHeadCells?: boolean;
sortingCallbacks?: TableSortCallbacks<I>;
tableProps?: Partial<TableProps<I>>; // low-level table configuration
renderTableHeader: TableCellProps[] | null;
renderTableContents: (item: I) => (ReactNode | TableCellProps)[];
renderItemMenu?: (item: I, store: ItemStore<I>) => ReactNode;
customizeTableRowProps?: (item: I) => Partial<TableRowProps>;
addRemoveButtons?: Partial<AddRemoveButtonsProps>;
virtual?: boolean;
// item details view
hasDetailsView?: boolean;
detailsItem?: I;
onDetails?: (item: I) => void;
// other
customizeRemoveDialog?: (selectedItems: I[]) => Partial<ConfirmDialogParams>;
renderFooter?: (parent: NonInjectedItemListLayout<I>) => React.ReactNode;
/**
* Message to display when a store failed to load
*
* @default "Failed to load items"
*/
failedToLoadMessage?: React.ReactNode;
filterCallbacks?: ItemsFilters<I>;
}
const defaultProps: Partial<ItemListLayoutProps<ItemObject>> = {
showHeader: true,
isSelectable: true,
isConfigurable: false,
copyClassNameFromHeadCells: true,
preloadStores: true,
dependentStores: [],
searchFilters: [],
customizeHeader: [],
filterItems: [],
hasDetailsView: true,
onDetails: noop,
virtual: true,
customizeTableRowProps: () => ({}),
failedToLoadMessage: "Failed to load items",
};
interface Dependencies {
namespaceStore: NamespaceStore;
itemListLayoutStorage: StorageHelper<{ showFilters: boolean }>;
}
@observer
class NonInjectedItemListLayout<I extends ItemObject> extends React.Component<ItemListLayoutProps<I> & Dependencies> {
static defaultProps = defaultProps as object;
constructor(props: ItemListLayoutProps<I> & Dependencies) {
super(props);
makeObservable(this);
}
get showFilters(): boolean {
return this.props.itemListLayoutStorage.get().showFilters;
}
set showFilters(showFilters: boolean) {
this.props.itemListLayoutStorage.merge({ showFilters });
}
async componentDidMount() {
const { isConfigurable, tableId, preloadStores } = this.props;
if (isConfigurable && !tableId) {
throw new Error("[ItemListLayout]: configurable list require props.tableId to be specified");
}
if (isConfigurable && !UserStore.getInstance().hiddenTableColumns.has(tableId)) {
UserStore.getInstance().hiddenTableColumns.set(tableId, new ObservableToggleSet());
}
if (preloadStores) {
this.loadStores();
}
}
private loadStores() {
const { store, dependentStores } = this.props;
const stores = Array.from(new Set([store, ...dependentStores]));
stores.forEach(store => store.loadAll(this.props.namespaceStore.contextNamespaces));
}
private filterCallbacks: ItemsFilters<I> = {
[FilterType.SEARCH]: items => {
const { searchFilters } = this.props;
const search = pageFilters.getValues(FilterType.SEARCH)[0] || "";
if (search && searchFilters.length) {
const normalizeText = (text: string) => String(text).toLowerCase();
const searchTexts = [search].map(normalizeText);
return items.filter(item => {
return searchFilters.some(getTexts => {
const sourceTexts: string[] = [getTexts(item)].flat().map(normalizeText);
return sourceTexts.some(source => searchTexts.some(search => source.includes(search)));
});
});
}
return items;
},
};
@computed get isReady() {
return this.props.isReady ?? this.props.store.isLoaded;
}
@computed get failedToLoad() {
return this.props.store.failedLoading;
}
@computed get filters() {
let { activeFilters } = pageFilters;
const { searchFilters } = this.props;
if (searchFilters.length === 0) {
activeFilters = activeFilters.filter(({ type }) => type !== FilterType.SEARCH);
}
return activeFilters;
}
applyFilters(filters: ItemsFilter<I>[], 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<Filter>(filters, ({ type }) => type);
const filterItems: ItemsFilter<I>[] = [];
Object.entries(filterGroups).forEach(([type, filtersGroup]) => {
const filterCallback = filterCallbacks[type] ?? props.filterCallbacks?.[type];
if (filterCallback && filtersGroup.length > 0) {
filterItems.push(filterCallback);
}
});
const items = 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 (
<TableRow
key={itemId}
nowrap
searchItem={item}
sortItem={item}
selected={detailsItem && detailsItem.getId() === itemId}
onClick={hasDetailsView ? prevDefault(() => onDetails(item)) : undefined}
{...customizeTableRowProps(item)}
>
{isSelectable && (
<TableCell
checkbox
isChecked={isSelected(item)}
onClick={prevDefault(() => 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 <TableCell key={index} {...cellProps} />;
}
return null;
})
}
{renderItemMenu && (
<TableCell className="menu">
<div onClick={stopPropagation}>
{renderItemMenu(item, store)}
</div>
</TableCell>
)}
</TableRow>
);
}
@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 <b>{tailCount}</b> more</> : null;
const message = selectedCount <= 1 ? <p>Remove item <b>{selectedNames}</b>?</p> : <p>Remove <b>{selectedCount}</b> items <b>{selectedNames}</b>{tail}?</p>;
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 <PageFiltersList filters={filters} />;
}
renderNoItems() {
if (this.failedToLoad) {
return <NoItems>{this.props.failedToLoadMessage}</NoItems>;
}
if (!this.isReady) {
return <Spinner center />;
}
if (this.filters.length > 0) {
return (
<NoItems>
No items found.
<p>
<a onClick={() => pageFilters.reset()} className="contrast">
Reset filters?
</a>
</p>
</NoItems>
);
}
return <NoItems />;
}
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 && (
<div className="info-panel box grow">
{info}
</div>
)
}
{filters}
{searchFilters.length > 0 && searchProps && <SearchInputUrl {...searchProps} />}
</>
);
}
renderInfo() {
const { items, filters } = this;
const allItemsCount = this.props.store.getTotalCount();
const itemsCount = items.length;
if (filters.length > 0) {
return (
<><a onClick={this.toggleFilters}>Filtered</a>: {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: <h5 className="title">{title}</h5>,
info: this.renderInfo(),
searchProps: {},
};
const headerPlaceholders = customizeHeaders.reduce((prevPlaceholders, customizer) => customizer(prevPlaceholders), initialPlaceholders);
const header = this.renderHeaderContent(headerPlaceholders);
return (
<div className={cssNames("header flex gaps align-center", headerClassName)}>
{header}
</div>
);
}
renderTableHeader() {
const { customizeTableRowProps, renderTableHeader, isSelectable, isConfigurable, store } = this.props;
if (!renderTableHeader) {
return null;
}
const enabledItems = this.items.filter(item => !customizeTableRowProps(item).disabled);
return (
<TableHead showTopLine nowrap>
{isSelectable && (
<TableCell
checkbox
isChecked={store.isSelectedAll(enabledItems)}
onClick={prevDefault(() => store.toggleSelectionAll(enabledItems))}
/>
)}
{renderTableHeader.map((cellProps, index) => (
this.showColumn(cellProps) && (
<TableCell key={cellProps.id ?? index} {...cellProps} />
)
))}
<TableCell className="menu">
{isConfigurable && this.renderColumnVisibilityMenu()}
</TableCell>
</TableHead>
);
}
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 (
<div className="items box grow flex column">
<Table
tableId={tableId}
virtual={virtual}
selectable={hasDetailsView}
sortable={sortingCallbacks}
getTableRow={this.getRow}
items={items}
selectedItemId={selectedItemId}
noItems={this.renderNoItems()}
className={classNames}
{...tableProps}
>
{this.renderTableHeader()}
{this.renderItems()}
</Table>
<AddRemoveButtons
onRemove={selectedItems.length ? removeItemsDialog : null}
removeTooltip={`Remove selected items (${selectedItems.length})`}
{...addRemoveButtons}
/>
</div>
);
}
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 (
<MenuActions className="ItemListLayoutVisibilityMenu" toolbar={false} autoCloseOnSelect={false}>
{renderTableHeader.map((cellProps, index) => (
!cellProps.showWithColumn && (
<MenuItem key={index} className="input">
<Checkbox
label={cellProps.title ?? `<${cellProps.className}>`}
value={this.showColumn(cellProps)}
onChange={() => UserStore.getInstance().toggleTableColumnVisibility(tableId, cellProps.id)}
/>
</MenuItem>
)
))}
</MenuActions>
);
}
renderFooter() {
return this.props.renderFooter?.(this);
}
render() {
const { className } = this.props;
return (
<div className={cssNames("ItemListLayout flex column", className)}>
{this.renderHeader()}
{this.renderFilters()}
{this.renderList()}
{this.renderFooter()}
</div>
);
}
}
export function ItemListLayout<I extends ItemObject>(
props: ItemListLayoutProps<I>,
) {
const InjectedItemListLayout = withInjectables<
Dependencies,
ItemListLayoutProps<I>
>(
NonInjectedItemListLayout,
{
getProps: (di, props) => ({
namespaceStore: di.inject(namespaceStoreInjectable),
itemListLayoutStorage: di.inject(itemListLayoutStorageInjectable),
...props,
}),
},
);
return <InjectedItemListLayout {...props} />;
}

View File

@ -0,0 +1,313 @@
/**
* 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, untracked } from "mobx";
import type { ConfirmDialogParams } from "../confirm-dialog";
import type {
TableCellProps,
TableProps,
TableRowProps,
TableSortCallbacks,
} from "../table";
import {
boundMethod,
cssNames,
IClassName,
noop,
ObservableToggleSet,
StorageHelper,
} from "../../utils";
import type { AddRemoveButtonsProps } from "../add-remove-buttons";
import type { ItemObject, ItemStore } from "../../../common/item.store";
import type { SearchInputUrlProps } from "../input";
import { Filter, FilterType, pageFilters } from "./page-filters.store";
import { PageFiltersList } from "./page-filters-list";
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 "./storage.injectable";
import { ItemListLayoutContent } from "./content";
import { ItemListLayoutHeader } from "./header";
import groupBy from "lodash/groupBy";
import { ItemListLayoutFilters } from "./filters";
import { observer } from "mobx-react";
export type SearchFilter<I extends ItemObject> = (item: I) => string | number | (string | number)[];
export type SearchFilters<I extends ItemObject> = Record<string, SearchFilter<I>>;
export type ItemsFilter<I extends ItemObject> = (items: I[]) => I[];
export type ItemsFilters<I extends ItemObject> = Record<string, ItemsFilter<I>>;
export interface HeaderPlaceholders {
title?: ReactNode;
searchProps?: SearchInputUrlProps;
filters?: ReactNode;
info?: ReactNode;
}
export type HeaderCustomizer = (placeholders: HeaderPlaceholders) => HeaderPlaceholders;
export interface ItemListLayoutProps<I extends ItemObject> {
tableId?: string;
className: IClassName;
items?: I[];
getItems?: () => I[];
store: ItemStore<I>;
dependentStores?: ItemStore<ItemObject>[];
preloadStores?: boolean;
hideFilters?: boolean;
searchFilters?: SearchFilter<I>[];
/** @deprecated */
filterItems?: ItemsFilter<I>[];
// header (title, filtering, searching, etc.)
showHeader?: boolean;
headerClassName?: IClassName;
renderHeaderTitle?: ReactNode | ((parent: NonInjectedItemListLayout<I>) => ReactNode);
customizeHeader?: HeaderCustomizer | HeaderCustomizer[];
// items list configuration
isReady?: boolean; // show loading indicator while not ready
isSelectable?: boolean; // show checkbox in rows for selecting items
isConfigurable?: boolean;
copyClassNameFromHeadCells?: boolean;
sortingCallbacks?: TableSortCallbacks<I>;
tableProps?: Partial<TableProps<I>>; // low-level table configuration
renderTableHeader: TableCellProps[] | null;
renderTableContents: (item: I) => (ReactNode | TableCellProps)[];
renderItemMenu?: (item: I, store: ItemStore<I>) => ReactNode;
customizeTableRowProps?: (item: I) => Partial<TableRowProps>;
addRemoveButtons?: Partial<AddRemoveButtonsProps>;
virtual?: boolean;
// item details view
hasDetailsView?: boolean;
detailsItem?: I;
onDetails?: (item: I) => void;
// other
customizeRemoveDialog?: (selectedItems: I[]) => Partial<ConfirmDialogParams>;
renderFooter?: (parent: NonInjectedItemListLayout<I>) => React.ReactNode;
/**
* Message to display when a store failed to load
*
* @default "Failed to load items"
*/
failedToLoadMessage?: React.ReactNode;
filterCallbacks?: ItemsFilters<I>;
}
const defaultProps: Partial<ItemListLayoutProps<ItemObject>> = {
showHeader: true,
isSelectable: true,
isConfigurable: false,
copyClassNameFromHeadCells: true,
preloadStores: true,
dependentStores: [],
searchFilters: [],
customizeHeader: [],
filterItems: [],
hasDetailsView: true,
onDetails: noop,
virtual: true,
customizeTableRowProps: () => ({}),
failedToLoadMessage: "Failed to load items",
};
interface Dependencies {
namespaceStore: NamespaceStore;
itemListLayoutStorage: StorageHelper<{ showFilters: boolean }>;
}
@observer
class NonInjectedItemListLayout<I extends ItemObject> extends React.Component<ItemListLayoutProps<I> & Dependencies> {
static defaultProps = defaultProps as object;
constructor(props: ItemListLayoutProps<I> & Dependencies) {
super(props);
makeObservable(this);
}
async componentDidMount() {
const { isConfigurable, tableId, preloadStores } = this.props;
if (isConfigurable && !tableId) {
throw new Error("[ItemListLayout]: configurable list require props.tableId to be specified");
}
if (isConfigurable && !UserStore.getInstance().hiddenTableColumns.has(tableId)) {
UserStore.getInstance().hiddenTableColumns.set(tableId, new ObservableToggleSet());
}
if (preloadStores) {
this.loadStores();
}
}
private loadStores() {
const { store, dependentStores } = this.props;
const stores = Array.from(new Set([store, ...dependentStores]));
stores.forEach(store => 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 <PageFiltersList filters={filters} />;
}
private filterCallbacks: ItemsFilters<I> = {
[FilterType.SEARCH]: items => {
const { searchFilters } = this.props;
const search = pageFilters.getValues(FilterType.SEARCH)[0] || "";
if (search && searchFilters.length) {
const normalizeText = (text: string) => String(text).toLowerCase();
const searchTexts = [search].map(normalizeText);
return items.filter(item => {
return searchFilters.some(getTexts => {
const sourceTexts: string[] = [getTexts(item)].flat().map(normalizeText);
return sourceTexts.some(source => searchTexts.some(search => source.includes(search)));
});
});
}
return items;
},
};
@computed get items() {
const filterGroups = groupBy<Filter>(this.filters, ({ type }) => type);
const filterItems: ItemsFilter<I>[] = [];
Object.entries(filterGroups).forEach(([type, filtersGroup]) => {
const filterCallback = this.filterCallbacks[type] ?? this.props.filterCallbacks?.[type];
if (filterCallback && filtersGroup.length > 0) {
filterItems.push(filterCallback);
}
});
const items = this.props.getItems ? this.props.getItems() : (this.props.items ?? this.props.store.items);
return applyFilters(filterItems.concat(this.props.filterItems), items);
}
render() {
return untracked(() => (
<div
className={cssNames("ItemListLayout flex column", this.props.className)}
>
<ItemListLayoutHeader
getItems={() => 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}
/>
<ItemListLayoutFilters
getIsReady={() => this.isReady}
getFilters={() => this.filters}
getFiltersAreShown={() => this.showFilters}
hideFilters={this.props.hideFilters}
/>
<ItemListLayoutContent
getItems={() => 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)}
</div>
));
}
}
const InjectedItemListLayout = withInjectables<Dependencies, ItemListLayoutProps<ItemObject>>(NonInjectedItemListLayout, {
getProps: (di, props) => ({
namespaceStore: di.inject(namespaceStoreInjectable),
itemListLayoutStorage: di.inject(itemListLayoutStorageInjectable),
...props,
}),
});
export function ItemListLayout<I extends ItemObject>(props: ItemListLayoutProps<I>) {
return <InjectedItemListLayout {...props} />;
}
function applyFilters<I extends ItemObject>(filters: ItemsFilter<I>[], items: I[]): I[] {
if (!filters || !filters.length) {
return items;
}
return filters.reduce((items, filter) => filter(items), items);
}

View File

@ -3,7 +3,7 @@
* Licensed under MIT License. See LICENSE in root directory for more information. * Licensed under MIT License. See LICENSE in root directory for more information.
*/ */
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable"; import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
import createStorageInjectable from "../../../utils/create-storage/create-storage.injectable"; import createStorageInjectable from "../../utils/create-storage/create-storage.injectable";
const itemListLayoutStorageInjectable = getInjectable({ const itemListLayoutStorageInjectable = getInjectable({
instantiate: (di) => { instantiate: (di) => {

View File

@ -10,7 +10,7 @@ import { computed, makeObservable, observable, reaction } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { cssNames, Disposer } from "../../utils"; import { cssNames, Disposer } from "../../utils";
import type { KubeObject } from "../../../common/k8s-api/kube-object"; import type { KubeObject } from "../../../common/k8s-api/kube-object";
import { ItemListLayout, ItemListLayoutProps } from "../item-object-list/item-list-layout"; import { ItemListLayout, ItemListLayoutProps } from "../item-object-list/list-layout";
import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store"; import type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
import { KubeObjectMenu } from "../kube-object-menu"; import { KubeObjectMenu } from "../kube-object-menu";
import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter"; import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter";
@ -99,14 +99,14 @@ class NonInjectedKubeObjectListLayout<K extends KubeObject> extends React.Compon
} }
render() { 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; const placeholderString = ResourceNames[ResourceKindMap[store.api.kind]] || store.api.kind;
return ( return (
<ItemListLayout <ItemListLayout
className={cssNames("KubeObjectListLayout", className)} className={cssNames("KubeObjectListLayout", className)}
store={store} store={store}
items={items} getItems={() => this.props.items || store.contextItems}
preloadStores={false} // loading handled in kubeWatchApi.subscribeStores() preloadStores={false} // loading handled in kubeWatchApi.subscribeStores()
detailsItem={this.selectedItem} detailsItem={this.selectedItem}
customizeHeader={[ customizeHeader={[