/** * 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 type { ReactNode } from "react"; import React from "react"; import type { IComputedValue } from "mobx"; import { computed, makeObservable, untracked } from "mobx"; import type { ConfirmDialogParams } from "../confirm-dialog"; import type { TableCellProps, TableProps, TableRowProps, TableSortCallbacks } from "../table"; import type { IClassName, SingleOrMany, StorageLayer } from "../../utils"; import { autoBind, cssNames, noop } from "../../utils"; import type { AddRemoveButtonsProps } from "../add-remove-buttons"; import type { ItemObject } from "../../../common/item.store"; import type { SearchInputUrlProps } from "../input"; import type { PageFiltersStore } from "./page-filters/store"; import { FilterType } from "./page-filters/store"; import { PageFiltersList } from "./page-filters/list"; 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"; import type { Primitive } from "type-fest"; import type { SubscribableStore } from "../../kube-watch-api/kube-watch-api"; import selectedFilterNamespacesInjectable from "../../../common/k8s-api/selected-filter-namespaces.injectable"; import pageFiltersStoreInjectable from "./page-filters/store.injectable"; export type SearchFilter = (item: I) => SingleOrMany; export type SearchFilters = Record>; export type ItemsFilter = (items: I[]) => I[]; export type ItemsFilters = Record>; export interface HeaderPlaceholders { title?: ReactNode; searchProps?: SearchInputUrlProps; filters?: ReactNode; info?: ReactNode; } function normalizeText(value: Primitive) { return String(value).toLowerCase(); } export type ItemListStore = { readonly isLoaded: boolean; readonly failedLoading: boolean; getTotalCount: () => number; isSelected: (item: I) => boolean; toggleSelection: (item: I) => void; isSelectedAll: (items: I[]) => boolean; toggleSelectionAll: (enabledItems: I[]) => void; pickOnlySelected: (items: I[]) => I[]; } & ({ removeItems: (selectedItems: I[]) => Promise; readonly selectedItems: I[]; removeSelectedItems?: unknown; } | { removeSelectedItems: () => Promise; selectedItems?: unknown; removeItems?: unknown; }) & ( PreLoadStores extends true ? { loadAll: (selectedNamespaces: readonly string[]) => Promise; } : { loadAll?: unknown; } ); export type RenderHeaderTitle< Item extends ItemObject, PreLoadStores extends boolean, > = ReactNode | ((parent: NonInjectedItemListLayout) => ReactNode); export type HeaderCustomizer = (placeholders: HeaderPlaceholders) => HeaderPlaceholders; export type ItemListLayoutProps = { tableId?: string; className: IClassName; getItems: () => Item[]; store: ItemListStore; dependentStores?: SubscribableStore[]; preloadStores?: boolean; hideFilters?: boolean; searchFilters?: SearchFilter[]; /** @deprecated */ filterItems?: ItemsFilter[]; // header (title, filtering, searching, etc.) showHeader?: boolean; headerClassName?: IClassName; renderHeaderTitle?: RenderHeaderTitle; 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; tableProps?: Partial>; // low-level table configuration renderTableHeader?: (TableCellProps | undefined | null)[]; renderTableContents: (item: Item) => (ReactNode | TableCellProps)[]; renderItemMenu?: (item: Item, store: ItemListStore) => ReactNode; customizeTableRowProps?: (item: Item) => Partial>; addRemoveButtons?: Partial; virtual?: boolean; // item details view hasDetailsView?: boolean; detailsItem?: Item; onDetails?: (item: Item) => void; // other customizeRemoveDialog?: (selectedItems: Item[]) => Partial; renderFooter?: (parent: NonInjectedItemListLayout) => React.ReactNode; spinnerTestId?: string; /** * Message to display when a store failed to load * * @default "Failed to load items" */ failedToLoadMessage?: React.ReactNode; filterCallbacks?: ItemsFilters; "data-testid"?: string; } & ( PreLoadStores extends true ? { preloadStores?: true; } : { preloadStores: false; } ); const defaultProps: Partial> = { 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", }; export interface ItemListLayoutStorage { showFilters: boolean; } interface Dependencies { selectedFilterNamespaces: IComputedValue; itemListLayoutStorage: StorageLayer; pageFiltersStore: PageFiltersStore; } @observer class NonInjectedItemListLayout extends React.Component & Dependencies> { static defaultProps = defaultProps as object; constructor(props: ItemListLayoutProps & Dependencies) { super(props); makeObservable(this); autoBind(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 (preloadStores) { const { store, dependentStores = [] } = this.props; const stores = Array.from(new Set([store, ...dependentStores])) as ItemListStore[]; stores.forEach(store => store.loadAll(this.props.selectedFilterNamespaces.get())); } } get showFilters(): boolean { return this.props.itemListLayoutStorage.get().showFilters; } set showFilters(showFilters: boolean) { this.props.itemListLayoutStorage.merge({ showFilters }); } @computed get filters() { let { activeFilters } = this.props.pageFiltersStore; const { searchFilters = [] } = this.props; if (searchFilters.length === 0) { activeFilters = activeFilters.filter(({ type }) => type !== FilterType.SEARCH); } return activeFilters; } 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; const search = this.props.pageFiltersStore.getValues(FilterType.SEARCH)[0] || ""; if (search && searchFilters.length) { const searchTexts = [search].map(normalizeText); return items.filter(item => ( searchFilters.some(getTexts => ( [getTexts(item)] .flat() .map(normalizeText) .some(source => searchTexts.some(search => source.includes(search))) )) )); } return items; }, }; @computed get items() { const filterGroups = groupBy(this.filters, ({ type }) => type); const filterItems: ItemsFilter[] = []; for (const [type, filtersGroup] of Object.entries(filterGroups)) { const filterCallback = this.filterCallbacks[type] ?? this.props.filterCallbacks?.[type]; if (filterCallback && filtersGroup.length > 0) { filterItems.push(filterCallback); } } const items = this.props.getItems(); return applyFilters(filterItems.concat(this.props.filterItems ?? []), items); } render() { const { renderHeaderTitle, "data-testid": dataTestId } = 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={( typeof renderHeaderTitle === "function" ? () => renderHeaderTitle(this) : renderHeaderTitle )} customizeHeader={this.props.customizeHeader} /> this.isReady} getFilters={() => this.filters} getFiltersAreShown={() => this.showFilters} hideFilters={this.props.hideFilters ?? false} /> 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} spinnerTestId={this.props.spinnerTestId} /> {this.props.renderFooter?.(this)}
)); } } export const ItemListLayout = withInjectables>(NonInjectedItemListLayout, { getProps: (di, props) => ({ ...props, selectedFilterNamespaces: di.inject(selectedFilterNamespacesInjectable), itemListLayoutStorage: di.inject(itemListLayoutStorageInjectable), pageFiltersStore: di.inject(pageFiltersStoreInjectable), }), }) as (props: ItemListLayoutProps) => React.ReactElement; function applyFilters(filters: ItemsFilter[], items: I[]): I[] { if (!filters || !filters.length) { return items; } return filters.reduce((items, filter) => filter(items), items); }