import "./item-list-layout.scss" import groupBy from "lodash/groupBy" import React, { ReactNode } from "react"; import { computed, observable, reaction, toJS, when } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import { Plural, Trans } from "@lingui/macro"; import { ConfirmDialog, IConfirmDialogParams } from "../confirm-dialog"; import { SortingCallback, Table, TableCell, TableCellProps, TableHead, TableProps, TableRow, TableRowProps } from "../table"; import { autobind, createStorage, cssNames, IClassName, isReactNode, noop, prevDefault, stopPropagation } from "../../utils"; import { AddRemoveButtons, AddRemoveButtonsProps } from "../add-remove-buttons"; import { NoItems } from "../no-items"; import { Spinner } from "../spinner"; import { ItemObject, ItemStore } from "../../item.store"; import { SearchInput } from "../input"; import { namespaceStore } from "../+namespaces/namespace.store"; import { Filter, FilterType, pageFilters } from "./page-filters.store"; import { PageFiltersList } from "./page-filters-list"; import { PageFiltersSelect } from "./page-filters-select"; import { NamespaceSelectFilter } from "../+namespaces/namespace-select"; import { themeStore } from "../../theme.store"; // todo: refactor, split to small re-usable components export type SearchFilter = (item: T) => string | number | (string | number)[]; export type ItemsFilter = (items: T[]) => T[]; interface IHeaderPlaceholders { title: ReactNode; search: ReactNode; filters: ReactNode; info: ReactNode; } export interface ItemListLayoutProps { className: IClassName; store: ItemStore; dependentStores?: ItemStore[]; isClusterScoped?: boolean; hideFilters?: boolean; searchFilters?: SearchFilter[]; 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 copyClassNameFromHeadCells?: boolean; sortingCallbacks?: { [sortBy: string]: SortingCallback }; 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, copyClassNameFromHeadCells: true, dependentStores: [], filterItems: [], hasDetailsView: true, onDetails: noop, virtual: true }; interface ItemListLayoutUserSettings { showAppliedFilters?: boolean; } @observer export class ItemListLayout extends React.Component { static defaultProps = defaultProps as object; @observable isUnmounting = false; // default user settings (ui show-hide tweaks mostly) @observable userSettings: ItemListLayoutUserSettings = { showAppliedFilters: false, }; constructor(props: ItemListLayoutProps) { super(props); // keep ui user settings in local storage const defaultUserSettings = toJS(this.userSettings); const storage = createStorage("items_list_layout", defaultUserSettings); Object.assign(this.userSettings, storage.get()); // restore disposeOnUnmount(this, [ reaction(() => toJS(this.userSettings), settings => storage.set(settings)), ]); } async componentDidMount() { const { store, dependentStores, isClusterScoped } = this.props; const stores = [store, ...dependentStores]; if (!isClusterScoped) stores.push(namespaceStore); await Promise.all(stores.map(store => store.loadAll())); const subscriptions = stores.map(store => store.subscribe()); await when(() => this.isUnmounting); subscriptions.forEach(dispose => dispose()); // unsubscribe all } componentWillUnmount() { this.isUnmounting = true; const { store, isSelectable } = this.props; if (isSelectable) store.resetSelection(); } 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; }, [FilterType.NAMESPACE]: items => { const filterValues = pageFilters.getValues(FilterType.NAMESPACE); if (filterValues.length > 0) { return items.filter(item => filterValues.includes(item.getNs())) } return items; }, } @computed get isReady() { const { isReady, store } = this.props; return typeof isReady == "boolean" ? isReady : store.isLoaded; } @computed get filters() { let { activeFilters } = pageFilters; const { isClusterScoped, isSearchable, searchFilters } = this.props; if (isClusterScoped) { activeFilters = activeFilters.filter(({ type }) => type !== FilterType.NAMESPACE); } 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 allItems() { const { filterItems, store } = this.props; return this.applyFilters(filterItems, store.items); } @computed get items() { const { allItems, filters, filterCallbacks } = this; const filterItems: ItemsFilter[] = []; const filterGroups = groupBy(filters, ({ type }) => type); Object.entries(filterGroups).forEach(([type, filtersGroup]) => { const filterCallback = filterCallbacks[type]; if (filterCallback && filtersGroup.length > 0) { filterItems.push(filterCallback); } }); return this.applyFilters(filterItems, allItems); } @autobind() 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; const itemId = item.getId(); return ( onDetails(item)) : undefined} {...(customizeTableRowProps ? customizeTableRowProps(item) : {})} > {isSelectable && ( store.toggleSelection(item))} /> )} { renderTableContents(item) .map((content, index) => { const cellProps: TableCellProps = isReactNode(content) ? { children: content } : content; if (copyClassNameFromHeadCells && renderTableHeader) { const headCell = renderTableHeader[index]; if (headCell) { cellProps.className = cssNames(cellProps.className, headCell.className); } } return }) } {renderItemMenu && ( {renderItemMenu(item, store)} )} ); } @autobind() 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; ConfirmDialog.open({ ok: removeSelectedItems, labelOk: Remove, message: ( Remove item {selectedNames}?

} other={

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

} /> ), ...dialogCustomProps, }) } renderFilters() { const { hideFilters } = this.props; const { isReady, userSettings, filters } = this; if (!isReady || !filters.length || hideFilters || !userSettings.showAppliedFilters) { return; } return } renderNoItems() { const { allItems, items, filters } = this; const allItemsCount = allItems.length; const itemsCount = items.length; const isFiltered = filters.length > 0 && allItemsCount > itemsCount; if (isFiltered) { return ( No items found.

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

) } return } renderHeaderContent(placeholders: IHeaderPlaceholders): ReactNode { const { title, filters, search, info } = placeholders; return ( <> {title}
{this.isReady && info}
{filters} {search} ) } renderInfo() { const { allItems, items, isReady, userSettings, filters } = this; const allItemsCount = allItems.length; const itemsCount = items.length; const isFiltered = isReady && filters.length > 0; if (isFiltered) { const toggleFilters = () => userSettings.showAppliedFilters = !userSettings.showAppliedFilters; return ( Filtered: {itemsCount} / {allItemsCount} ) } return ( ); } renderHeader() { const { showHeader, customizeHeader, renderHeaderTitle, headerClassName, isClusterScoped } = this.props; if (!showHeader) return; const title = typeof renderHeaderTitle === "function" ? renderHeaderTitle(this) : renderHeaderTitle; const placeholders: IHeaderPlaceholders = { title:
{title}
, info: this.renderInfo(), filters: <> {!isClusterScoped && } , 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}
) } renderList() { const { isSelectable, tableProps = {}, renderTableHeader, renderItemMenu, store, hasDetailsView, addRemoveButtons = {}, virtual, sortingCallbacks, detailsItem } = this.props; const { isReady, removeItemsDialog, items } = this; const { selectedItems } = store; const selectedItemId = detailsItem && detailsItem.getId(); return (
{!isReady && ( )} {isReady && ( {renderTableHeader && ( {isSelectable && ( store.toggleSelectionAll(items))} /> )} {renderTableHeader.map((cellProps, index) => )} {renderItemMenu && } )} { !virtual && items.map(item => this.getRow(item.getId())) }
)} Remove selected items ({selectedItems.length})} {...addRemoveButtons} />
) } renderFooter() { if (this.props.renderFooter) { return this.props.renderFooter(this); } } render() { const { className } = this.props; return (
{this.renderHeader()} {this.renderFilters()} {this.renderList()} {this.renderFooter()}
); } }