/** * 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 "./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, TableSortCallback } from "../table"; import { boundMethod, createStorage, cssNames, IClassName, isReactNode, noop, ObservableToggleSet, 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 "../../item.store"; import { 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 { namespaceStore } from "../+namespaces/namespace.store"; import { KubeObjectStore } from "../../kube-object.store"; import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter"; // todo: refactor, split to small re-usable components export type SearchFilter = (item: T) => string | number | (string | number)[]; export type ItemsFilter = (items: T[]) => T[]; export interface IHeaderPlaceholders { title: ReactNode; search: ReactNode; filters: ReactNode; info: ReactNode; } export interface ItemListLayoutProps { tableId?: string; className: IClassName; items?: T[]; store: ItemStore; dependentStores?: ItemStore[]; preloadStores?: boolean; hideFilters?: boolean; searchFilters?: SearchFilter[]; /** @deprecated */ filterItems?: ItemsFilter[]; // header (title, filtering, searching, etc.) showHeader?: boolean; headerClassName?: IClassName; renderHeaderTitle?: ReactNode | ((parent: ItemListLayout) => ReactNode); customizeHeader?: (placeholders: IHeaderPlaceholders, content: ReactNode) => Partial | ReactNode; // items list configuration isReady?: boolean; // show loading indicator while not ready isSelectable?: boolean; // show checkbox in rows for selecting items isSearchable?: boolean; // apply search-filter & add search-input isConfigurable?: boolean; copyClassNameFromHeadCells?: boolean; sortingCallbacks?: { [sortBy: string]: TableSortCallback }; tableProps?: Partial; // low-level table configuration renderTableHeader: TableCellProps[] | null; renderTableContents: (item: T) => (ReactNode | TableCellProps)[]; renderItemMenu?: (item: T, store: ItemStore) => ReactNode; customizeTableRowProps?: (item: T) => Partial; addRemoveButtons?: Partial; virtual?: boolean; // item details view hasDetailsView?: boolean; detailsItem?: T; onDetails?: (item: T) => void; // other customizeRemoveDialog?: (selectedItems: T[]) => Partial; renderFooter?: (parent: ItemListLayout) => React.ReactNode; } const defaultProps: Partial = { showHeader: true, isSearchable: true, isSelectable: true, isConfigurable: false, copyClassNameFromHeadCells: true, preloadStores: true, dependentStores: [], filterItems: [], hasDetailsView: true, onDetails: noop, virtual: true, customizeTableRowProps: () => ({} as TableRowProps), }; @observer export class ItemListLayout extends React.Component { static defaultProps = defaultProps as object; private storage = createStorage("item_list_layout", { showFilters: false, // setup defaults }); constructor(props: ItemListLayoutProps) { super(props); makeObservable(this); } get showFilters(): boolean { return this.storage.get().showFilters; } set showFilters(showFilters: boolean) { this.storage.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(namespaceStore.contextNamespaces)); } private filterCallbacks: { [type: string]: ItemsFilter } = { [FilterType.SEARCH]: items => { const { searchFilters, isSearchable } = this.props; const search = pageFilters.getValues(FilterType.SEARCH)[0] || ""; if (search && isSearchable && searchFilters) { 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 { isSearchable, searchFilters } = this.props; if (!(isSearchable && searchFilters)) { activeFilters = activeFilters.filter(({ type }) => type !== FilterType.SEARCH); } return activeFilters; } applyFilters(filters: ItemsFilter[], items: T[]): T[] { if (!filters || !filters.length) return items; return filters.reduce((items, filter) => filter(items), items); } @computed get items() { const { filters, filterCallbacks } = this; const filterGroups = groupBy(filters, ({ type }) => type); const filterItems: ItemsFilter[] = []; Object.entries(filterGroups).forEach(([type, filtersGroup]) => { const filterCallback = 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 ( 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 Failed to load items.; } 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: IHeaderPlaceholders): ReactNode { const { isSearchable, searchFilters } = this.props; const { title, filters, search, info } = placeholders; return ( <> {title}
{info}
{filters} {isSearchable && searchFilters && search} ); } 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 showNamespaceSelectFilter = this.props.store instanceof KubeObjectStore && this.props.store.api.isNamespaced; const title = typeof renderHeaderTitle === "function" ? renderHeaderTitle(this) : renderHeaderTitle; const placeholders: IHeaderPlaceholders = { title:
{title}
, info: this.renderInfo(), filters: showNamespaceSelectFilter && , search: , }; let header = this.renderHeaderContent(placeholders); if (customizeHeader) { const modifiedHeader = customizeHeader(placeholders, header) ?? {}; if (isReactNode(modifiedHeader)) { header = modifiedHeader; } else { header = this.renderHeaderContent({ ...placeholders, ...modifiedHeader as IHeaderPlaceholders, }); } } 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); } render() { const { className } = this.props; return (
{this.renderHeader()} {this.renderFilters()} {this.renderList()} {this.renderFooter()}
); } }