diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts index 6dc89020e3..375321aaaf 100644 --- a/integration/__tests__/cluster-pages.tests.ts +++ b/integration/__tests__/cluster-pages.tests.ts @@ -354,8 +354,7 @@ utils.describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => { } }, 10*60*1000); - // TODO: Make re-rendering of KubeObjectListLayout not cause namespaceSelector to be closed - xit("show logs and highlight the log search entries", async () => { + it("show logs and highlight the log search entries", async () => { await frame.click(`a[href="/workloads"]`); 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'"); }, 10*60*1000); - // TODO: Make re-rendering of KubeObjectListLayout not cause namespaceSelector to be closed - xit(`should create the ${TEST_NAMESPACE} and a pod in the namespace`, async () => { + it(`should create the ${TEST_NAMESPACE} and a pod in the namespace`, async () => { await frame.click('a[href="/namespaces"]'); await frame.click("button.add-button"); await frame.waitForSelector("div.AddNamespaceDialog >> text='Create Namespace'"); diff --git a/src/renderer/components/+apps-helm-charts/helm-charts.tsx b/src/renderer/components/+apps-helm-charts/helm-charts.tsx index 36b84d5267..49f885a093 100644 --- a/src/renderer/components/+apps-helm-charts/helm-charts.tsx +++ b/src/renderer/components/+apps-helm-charts/helm-charts.tsx @@ -12,7 +12,7 @@ import { helmChartStore } from "./helm-chart.store"; import type { HelmChart } from "../../../common/k8s-api/endpoints/helm-charts.api"; import { HelmChartDetails } from "./helm-chart-details"; 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 type { HelmChartsRouteParams } from "../../../common/routes"; diff --git a/src/renderer/components/+apps-releases/releases.tsx b/src/renderer/components/+apps-releases/releases.tsx index a71cc8e7a1..34f9798783 100644 --- a/src/renderer/components/+apps-releases/releases.tsx +++ b/src/renderer/components/+apps-releases/releases.tsx @@ -23,10 +23,8 @@ import { ReleaseRollbackDialog } from "./release-rollback-dialog"; import { ReleaseDetails } from "./release-details/release-details"; import removableReleasesInjectable from "./removable-releases.injectable"; import type { RemovableHelmRelease } from "./removable-releases"; -import { observer } from "mobx-react"; import type { IComputedValue } from "mobx"; import releasesInjectable from "./releases.injectable"; -import { Spinner } from "../spinner"; enum columnId { name = "name", @@ -48,7 +46,6 @@ interface Dependencies { selectNamespace: (namespace: string) => void } -@observer class NonInjectedHelmReleases extends Component { componentDidMount() { const { match: { params: { namespace }}} = this.props; @@ -89,12 +86,8 @@ class NonInjectedHelmReleases extends Component { } render() { - if (this.props.releasesArePending.get()) { - // TODO: Make Spinner "center" work properly - return
; - } - const releases = this.props.releases; + const releasesArePending = this.props.releasesArePending; // TODO: Implement ItemListLayout without stateful stores const legacyReleaseStore = { @@ -103,7 +96,11 @@ class NonInjectedHelmReleases extends Component { }, loadAll: () => Promise.resolve(), - isLoaded: true, + + get isLoaded() { + return !releasesArePending.get(); + }, + failedLoading: false, getTotalCount: () => releases.get().length, diff --git a/src/renderer/components/+network-port-forwards/port-forwards.tsx b/src/renderer/components/+network-port-forwards/port-forwards.tsx index b3e7e4c1f3..9ceeadba38 100644 --- a/src/renderer/components/+network-port-forwards/port-forwards.tsx +++ b/src/renderer/components/+network-port-forwards/port-forwards.tsx @@ -8,7 +8,7 @@ import "./port-forwards.scss"; import React from "react"; import { disposeOnUnmount, observer } from "mobx-react"; 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 { PortForwardMenu } from "./port-forward-menu"; import { PortForwardsRouteParams, portForwardsURL } from "../../../common/routes"; diff --git a/src/renderer/components/item-object-list/content.tsx b/src/renderer/components/item-object-list/content.tsx new file mode 100644 index 0000000000..ccfdddd7d3 --- /dev/null +++ b/src/renderer/components/item-object-list/content.tsx @@ -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 { + getFilters: () => Filter[] + tableId?: string; + className: IClassName; + getItems: () => I[]; + store: ItemStore; + getIsReady: () => 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[] | null; + renderTableContents: (item: I) => (ReactNode | TableCellProps)[]; + renderItemMenu?: (item: I, store: ItemStore) => ReactNode; + customizeTableRowProps?: (item: I) => Partial; + addRemoveButtons?: Partial; + virtual?: boolean; + + // item details view + hasDetailsView?: boolean; + detailsItem?: I; + onDetails?: (item: I) => void; + + // other + customizeRemoveDialog?: (selectedItems: I[]) => Partial; + + /** + * Message to display when a store failed to load + * + * @default "Failed to load items" + */ + failedToLoadMessage?: React.ReactNode; +} + +@observer +export class ItemListLayoutContent extends React.Component> { + constructor(props: ItemListLayoutContentProps) { + 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 ( + 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, + }); + } + + renderNoItems() { + if (this.failedToLoad) { + return {this.props.failedToLoadMessage}; + } + + if (!this.props.getIsReady()) { + return ; + } + + if (this.props.getFilters().length > 0) { + return ( + + No items found. +

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

+
+ ); + } + + return ; + } + + 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 ( + + {isSelectable && ( + store.toggleSelectionAll(enabledItems))} + /> + )} + {renderTableHeader.map((cellProps, index) => ( + this.showColumn(cellProps) && ( + + ) + ))} + + {isConfigurable && this.renderColumnVisibilityMenu()} + + + ); + } + + 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 ( +
+ + {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)} + /> + + ) + ))} + + ); + } +} diff --git a/src/renderer/components/item-object-list/filters.tsx b/src/renderer/components/item-object-list/filters.tsx new file mode 100644 index 0000000000..5dd3c13dc2 --- /dev/null +++ b/src/renderer/components/item-object-list/filters.tsx @@ -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 ; +}); + diff --git a/src/renderer/components/item-object-list/header.tsx b/src/renderer/components/item-object-list/header.tsx new file mode 100644 index 0000000000..ce5f19fd05 --- /dev/null +++ b/src/renderer/components/item-object-list/header.tsx @@ -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 { + getItems: () => I[]; + getFilters: () => Filter[]; + toggleFilters: () => void; + + store: ItemStore; + searchFilters?: SearchFilter[]; + + // header (title, filtering, searching, etc.) + showHeader?: boolean; + headerClassName?: IClassName; + renderHeaderTitle?: + | ReactNode + | ((parent: ItemListLayoutHeader) => ReactNode); + customizeHeader?: HeaderCustomizer | HeaderCustomizer[]; +} + +@observer +export class ItemListLayoutHeader extends React.Component< + ItemListLayoutHeaderProps +> { + 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 ( + <> + Filtered: {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( + (prevPlaceholders, customizer) => customizer(prevPlaceholders), + { + title:
{renderedTitle}
, + info: renderInfo(), + searchProps: {}, + }, + ); + + return ( +
+ {title} + { + info && ( +
+ {info} +
+ ) + } + {filters} + {searchFilters.length > 0 && searchProps && } +
+ ); + } +} diff --git a/src/renderer/components/item-object-list/index.tsx b/src/renderer/components/item-object-list/index.tsx index 6861509d03..bb05729496 100644 --- a/src/renderer/components/item-object-list/index.tsx +++ b/src/renderer/components/item-object-list/index.tsx @@ -3,4 +3,4 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ -export * from "./item-list-layout"; +export * from "./list-layout"; diff --git a/src/renderer/components/item-object-list/item-list-layout.tsx b/src/renderer/components/item-object-list/item-list-layout.tsx deleted file mode 100644 index 2d6a91fe70..0000000000 --- a/src/renderer/components/item-object-list/item-list-layout.tsx +++ /dev/null @@ -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 = (item: I) => string | number | (string | number)[]; -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; -} - -export type HeaderCustomizer = (placeholders: HeaderPlaceholders) => HeaderPlaceholders; -export interface ItemListLayoutProps { - tableId?: string; - className: IClassName; - items?: I[]; - 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: NonInjectedItemListLayout) => 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; - tableProps?: Partial>; // low-level table configuration - renderTableHeader: TableCellProps[] | null; - renderTableContents: (item: I) => (ReactNode | TableCellProps)[]; - renderItemMenu?: (item: I, store: ItemStore) => ReactNode; - customizeTableRowProps?: (item: I) => Partial; - addRemoveButtons?: Partial; - virtual?: boolean; - - // item details view - hasDetailsView?: boolean; - detailsItem?: I; - onDetails?: (item: I) => void; - - // other - customizeRemoveDialog?: (selectedItems: I[]) => Partial; - renderFooter?: (parent: NonInjectedItemListLayout) => React.ReactNode; - - /** - * Message to display when a store failed to load - * - * @default "Failed to load items" - */ - failedToLoadMessage?: React.ReactNode; - - filterCallbacks?: ItemsFilters; -} - -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", -}; - -interface Dependencies { - namespaceStore: NamespaceStore; - itemListLayoutStorage: StorageHelper<{ showFilters: boolean }>; -} - -@observer -class NonInjectedItemListLayout extends React.Component & Dependencies> { - static defaultProps = defaultProps as object; - - constructor(props: ItemListLayoutProps & 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 = { - [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[], 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(filters, ({ type }) => type); - - const filterItems: ItemsFilter[] = []; - - 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 ( - 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 {this.props.failedToLoadMessage}; - } - - 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: HeaderPlaceholders): ReactNode { - const { searchFilters } = this.props; - const { title, filters, searchProps, info } = placeholders; - - return ( - <> - {title} - { - info && ( -
- {info} -
- ) - } - {filters} - {searchFilters.length > 0 && searchProps && } - - ); - } - - 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 title = typeof renderHeaderTitle === "function" ? renderHeaderTitle(this) : renderHeaderTitle; - const customizeHeaders = [customizeHeader].flat().filter(Boolean); - const initialPlaceholders: HeaderPlaceholders = { - title:
{title}
, - info: this.renderInfo(), - searchProps: {}, - }; - const headerPlaceholders = customizeHeaders.reduce((prevPlaceholders, customizer) => customizer(prevPlaceholders), initialPlaceholders); - const header = this.renderHeaderContent(headerPlaceholders); - - 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()} -
- ); - } -} - -export function ItemListLayout( - props: ItemListLayoutProps, -) { - const InjectedItemListLayout = withInjectables< - Dependencies, - ItemListLayoutProps - >( - NonInjectedItemListLayout, - - { - getProps: (di, props) => ({ - namespaceStore: di.inject(namespaceStoreInjectable), - itemListLayoutStorage: di.inject(itemListLayoutStorageInjectable), - ...props, - }), - }, - ); - - return ; -} diff --git a/src/renderer/components/item-object-list/list-layout.tsx b/src/renderer/components/item-object-list/list-layout.tsx new file mode 100644 index 0000000000..39211d0a6b --- /dev/null +++ b/src/renderer/components/item-object-list/list-layout.tsx @@ -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 = (item: I) => string | number | (string | number)[]; +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; +} + +export type HeaderCustomizer = (placeholders: HeaderPlaceholders) => HeaderPlaceholders; +export interface ItemListLayoutProps { + tableId?: string; + className: IClassName; + items?: I[]; + getItems?: () => I[]; + 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: NonInjectedItemListLayout) => 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; + tableProps?: Partial>; // low-level table configuration + renderTableHeader: TableCellProps[] | null; + renderTableContents: (item: I) => (ReactNode | TableCellProps)[]; + renderItemMenu?: (item: I, store: ItemStore) => ReactNode; + customizeTableRowProps?: (item: I) => Partial; + addRemoveButtons?: Partial; + virtual?: boolean; + + // item details view + hasDetailsView?: boolean; + detailsItem?: I; + onDetails?: (item: I) => void; + + // other + customizeRemoveDialog?: (selectedItems: I[]) => Partial; + renderFooter?: (parent: NonInjectedItemListLayout) => React.ReactNode; + + /** + * Message to display when a store failed to load + * + * @default "Failed to load items" + */ + failedToLoadMessage?: React.ReactNode; + + filterCallbacks?: ItemsFilters; +} + +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", +}; + +interface Dependencies { + namespaceStore: NamespaceStore; + itemListLayoutStorage: StorageHelper<{ showFilters: boolean }>; +} + +@observer +class NonInjectedItemListLayout extends React.Component & Dependencies> { + static defaultProps = defaultProps as object; + + constructor(props: ItemListLayoutProps & 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 ; + } + + private filterCallbacks: ItemsFilters = { + [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(this.filters, ({ type }) => type); + + const filterItems: ItemsFilter[] = []; + + 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(() => ( +
+ 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} + /> + + this.isReady} + getFilters={() => this.filters} + getFiltersAreShown={() => this.showFilters} + hideFilters={this.props.hideFilters} + /> + + 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)} +
+ )); + } +} + +const InjectedItemListLayout = withInjectables>(NonInjectedItemListLayout, { + getProps: (di, props) => ({ + namespaceStore: di.inject(namespaceStoreInjectable), + itemListLayoutStorage: di.inject(itemListLayoutStorageInjectable), + ...props, + }), +}); + +export function ItemListLayout(props: ItemListLayoutProps) { + return ; +} + +function applyFilters(filters: ItemsFilter[], items: I[]): I[] { + if (!filters || !filters.length) { + return items; + } + + return filters.reduce((items, filter) => filter(items), items); +} diff --git a/src/renderer/components/item-object-list/item-list-layout-storage/item-list-layout-storage.injectable.ts b/src/renderer/components/item-object-list/storage.injectable.ts similarity index 85% rename from src/renderer/components/item-object-list/item-list-layout-storage/item-list-layout-storage.injectable.ts rename to src/renderer/components/item-object-list/storage.injectable.ts index 9033e5fdfe..a105d35472 100644 --- a/src/renderer/components/item-object-list/item-list-layout-storage/item-list-layout-storage.injectable.ts +++ b/src/renderer/components/item-object-list/storage.injectable.ts @@ -3,7 +3,7 @@ * Licensed under MIT License. See LICENSE in root directory for more information. */ 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({ instantiate: (di) => { diff --git a/src/renderer/components/kube-object-list-layout/kube-object-list-layout.tsx b/src/renderer/components/kube-object-list-layout/kube-object-list-layout.tsx index 304694da0f..de1253fd65 100644 --- a/src/renderer/components/kube-object-list-layout/kube-object-list-layout.tsx +++ b/src/renderer/components/kube-object-list-layout/kube-object-list-layout.tsx @@ -10,7 +10,7 @@ import { computed, makeObservable, observable, reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import { cssNames, Disposer } from "../../utils"; 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 { KubeObjectMenu } from "../kube-object-menu"; import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter"; @@ -99,14 +99,14 @@ class NonInjectedKubeObjectListLayout extends React.Compon } 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; return ( this.props.items || store.contextItems} preloadStores={false} // loading handled in kubeWatchApi.subscribeStores() detailsItem={this.selectedItem} customizeHeader={[