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: true,
};
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);
const loadingStores = stores.filter(store => !store.isLoaded);
loadingStores.forEach(store => store.loadAll());
await when(() => loadingStores.every(store => !store.isLoading));
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()}
);
}
}