mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Fix involuntary rerenders in item list layout (#4720)
* Fix involuntary re-renders in item list layout Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com> Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Enable skipped integration tests for being fixed Co-authored-by: Mikko Aspiala <mikko.aspiala@gmail.com> Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Remove comment Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * Improve code style Signed-off-by: Janne Savolainen <janne.savolainen@live.fi> * flatten file heirarchy Signed-off-by: Sebastian Malton <sebastian@malton.name> * Fix item list layout header rendering bug Signed-off-by: Sebastian Malton <sebastian@malton.name> Co-authored-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
parent
8214076ca0
commit
44a060ff42
@ -354,8 +354,7 @@ utils.describeIf(minikubeReady(TEST_NAMESPACE))("Minikube based tests", () => {
|
|||||||
}
|
}
|
||||||
}, 10*60*1000);
|
}, 10*60*1000);
|
||||||
|
|
||||||
// TODO: Make re-rendering of KubeObjectListLayout not cause namespaceSelector to be closed
|
it("show logs and highlight the log search entries", async () => {
|
||||||
xit("show logs and highlight the log search entries", async () => {
|
|
||||||
await frame.click(`a[href="/workloads"]`);
|
await frame.click(`a[href="/workloads"]`);
|
||||||
await frame.click(`a[href="/pods"]`);
|
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'");
|
await frame.waitForSelector("div.TableCell >> text='kube-system'");
|
||||||
}, 10*60*1000);
|
}, 10*60*1000);
|
||||||
|
|
||||||
// TODO: Make re-rendering of KubeObjectListLayout not cause namespaceSelector to be closed
|
it(`should create the ${TEST_NAMESPACE} and a pod in the namespace`, async () => {
|
||||||
xit(`should create the ${TEST_NAMESPACE} and a pod in the namespace`, async () => {
|
|
||||||
await frame.click('a[href="/namespaces"]');
|
await frame.click('a[href="/namespaces"]');
|
||||||
await frame.click("button.add-button");
|
await frame.click("button.add-button");
|
||||||
await frame.waitForSelector("div.AddNamespaceDialog >> text='Create Namespace'");
|
await frame.waitForSelector("div.AddNamespaceDialog >> text='Create Namespace'");
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import { helmChartStore } from "./helm-chart.store";
|
|||||||
import type { HelmChart } from "../../../common/k8s-api/endpoints/helm-charts.api";
|
import type { HelmChart } from "../../../common/k8s-api/endpoints/helm-charts.api";
|
||||||
import { HelmChartDetails } from "./helm-chart-details";
|
import { HelmChartDetails } from "./helm-chart-details";
|
||||||
import { navigation } from "../../navigation";
|
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 { helmChartsURL } from "../../../common/routes";
|
||||||
import type { HelmChartsRouteParams } from "../../../common/routes";
|
import type { HelmChartsRouteParams } from "../../../common/routes";
|
||||||
|
|
||||||
|
|||||||
@ -23,10 +23,8 @@ import { ReleaseRollbackDialog } from "./release-rollback-dialog";
|
|||||||
import { ReleaseDetails } from "./release-details/release-details";
|
import { ReleaseDetails } from "./release-details/release-details";
|
||||||
import removableReleasesInjectable from "./removable-releases.injectable";
|
import removableReleasesInjectable from "./removable-releases.injectable";
|
||||||
import type { RemovableHelmRelease } from "./removable-releases";
|
import type { RemovableHelmRelease } from "./removable-releases";
|
||||||
import { observer } from "mobx-react";
|
|
||||||
import type { IComputedValue } from "mobx";
|
import type { IComputedValue } from "mobx";
|
||||||
import releasesInjectable from "./releases.injectable";
|
import releasesInjectable from "./releases.injectable";
|
||||||
import { Spinner } from "../spinner";
|
|
||||||
|
|
||||||
enum columnId {
|
enum columnId {
|
||||||
name = "name",
|
name = "name",
|
||||||
@ -48,7 +46,6 @@ interface Dependencies {
|
|||||||
selectNamespace: (namespace: string) => void
|
selectNamespace: (namespace: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@observer
|
|
||||||
class NonInjectedHelmReleases extends Component<Dependencies & Props> {
|
class NonInjectedHelmReleases extends Component<Dependencies & Props> {
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
const { match: { params: { namespace }}} = this.props;
|
const { match: { params: { namespace }}} = this.props;
|
||||||
@ -89,12 +86,8 @@ class NonInjectedHelmReleases extends Component<Dependencies & Props> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (this.props.releasesArePending.get()) {
|
|
||||||
// TODO: Make Spinner "center" work properly
|
|
||||||
return <div className="flex center" style={{ height: "100%" }}><Spinner /></div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const releases = this.props.releases;
|
const releases = this.props.releases;
|
||||||
|
const releasesArePending = this.props.releasesArePending;
|
||||||
|
|
||||||
// TODO: Implement ItemListLayout without stateful stores
|
// TODO: Implement ItemListLayout without stateful stores
|
||||||
const legacyReleaseStore = {
|
const legacyReleaseStore = {
|
||||||
@ -103,7 +96,11 @@ class NonInjectedHelmReleases extends Component<Dependencies & Props> {
|
|||||||
},
|
},
|
||||||
|
|
||||||
loadAll: () => Promise.resolve(),
|
loadAll: () => Promise.resolve(),
|
||||||
isLoaded: true,
|
|
||||||
|
get isLoaded() {
|
||||||
|
return !releasesArePending.get();
|
||||||
|
},
|
||||||
|
|
||||||
failedLoading: false,
|
failedLoading: false,
|
||||||
|
|
||||||
getTotalCount: () => releases.get().length,
|
getTotalCount: () => releases.get().length,
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import "./port-forwards.scss";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { disposeOnUnmount, observer } from "mobx-react";
|
import { disposeOnUnmount, observer } from "mobx-react";
|
||||||
import type { RouteComponentProps } from "react-router-dom";
|
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 type { PortForwardItem, PortForwardStore } from "../../port-forward";
|
||||||
import { PortForwardMenu } from "./port-forward-menu";
|
import { PortForwardMenu } from "./port-forward-menu";
|
||||||
import { PortForwardsRouteParams, portForwardsURL } from "../../../common/routes";
|
import { PortForwardsRouteParams, portForwardsURL } from "../../../common/routes";
|
||||||
|
|||||||
271
src/renderer/components/item-object-list/content.tsx
Normal file
271
src/renderer/components/item-object-list/content.tsx
Normal file
@ -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<I extends ItemObject> {
|
||||||
|
getFilters: () => Filter[]
|
||||||
|
tableId?: string;
|
||||||
|
className: IClassName;
|
||||||
|
getItems: () => I[];
|
||||||
|
store: ItemStore<I>;
|
||||||
|
getIsReady: () => boolean; // show loading indicator while not ready
|
||||||
|
isSelectable?: boolean; // show checkbox in rows for selecting items
|
||||||
|
isConfigurable?: boolean;
|
||||||
|
copyClassNameFromHeadCells?: boolean;
|
||||||
|
sortingCallbacks?: TableSortCallbacks<I>;
|
||||||
|
tableProps?: Partial<TableProps<I>>; // low-level table configuration
|
||||||
|
renderTableHeader: TableCellProps[] | null;
|
||||||
|
renderTableContents: (item: I) => (ReactNode | TableCellProps)[];
|
||||||
|
renderItemMenu?: (item: I, store: ItemStore<I>) => ReactNode;
|
||||||
|
customizeTableRowProps?: (item: I) => Partial<TableRowProps>;
|
||||||
|
addRemoveButtons?: Partial<AddRemoveButtonsProps>;
|
||||||
|
virtual?: boolean;
|
||||||
|
|
||||||
|
// item details view
|
||||||
|
hasDetailsView?: boolean;
|
||||||
|
detailsItem?: I;
|
||||||
|
onDetails?: (item: I) => void;
|
||||||
|
|
||||||
|
// other
|
||||||
|
customizeRemoveDialog?: (selectedItems: I[]) => Partial<ConfirmDialogParams>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message to display when a store failed to load
|
||||||
|
*
|
||||||
|
* @default "Failed to load items"
|
||||||
|
*/
|
||||||
|
failedToLoadMessage?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
@observer
|
||||||
|
export class ItemListLayoutContent<I extends ItemObject> extends React.Component<ItemListLayoutContentProps<I>> {
|
||||||
|
constructor(props: ItemListLayoutContentProps<I>) {
|
||||||
|
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 (
|
||||||
|
<TableRow
|
||||||
|
key={itemId}
|
||||||
|
nowrap
|
||||||
|
searchItem={item}
|
||||||
|
sortItem={item}
|
||||||
|
selected={detailsItem && detailsItem.getId() === itemId}
|
||||||
|
onClick={hasDetailsView ? prevDefault(() => onDetails(item)) : undefined}
|
||||||
|
{...customizeTableRowProps(item)}
|
||||||
|
>
|
||||||
|
{isSelectable && (
|
||||||
|
<TableCell
|
||||||
|
checkbox
|
||||||
|
isChecked={isSelected(item)}
|
||||||
|
onClick={prevDefault(() => 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 <TableCell key={index} {...cellProps} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
{renderItemMenu && (
|
||||||
|
<TableCell className="menu">
|
||||||
|
<div onClick={stopPropagation}>
|
||||||
|
{renderItemMenu(item, store)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 <b>{tailCount}</b> more</> : null;
|
||||||
|
const message = selectedCount <= 1 ? <p>Remove item <b>{selectedNames}</b>?</p> : <p>Remove <b>{selectedCount}</b> items <b>{selectedNames}</b>{tail}?</p>;
|
||||||
|
|
||||||
|
ConfirmDialog.open({
|
||||||
|
ok: removeSelectedItems,
|
||||||
|
labelOk: "Remove",
|
||||||
|
message,
|
||||||
|
...dialogCustomProps,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderNoItems() {
|
||||||
|
if (this.failedToLoad) {
|
||||||
|
return <NoItems>{this.props.failedToLoadMessage}</NoItems>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.props.getIsReady()) {
|
||||||
|
return <Spinner center />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.props.getFilters().length > 0) {
|
||||||
|
return (
|
||||||
|
<NoItems>
|
||||||
|
No items found.
|
||||||
|
<p>
|
||||||
|
<a onClick={() => pageFilters.reset()} className="contrast">
|
||||||
|
Reset filters?
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</NoItems>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <NoItems />;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<TableHead showTopLine nowrap>
|
||||||
|
{isSelectable && (
|
||||||
|
<TableCell
|
||||||
|
checkbox
|
||||||
|
isChecked={store.isSelectedAll(enabledItems)}
|
||||||
|
onClick={prevDefault(() => store.toggleSelectionAll(enabledItems))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{renderTableHeader.map((cellProps, index) => (
|
||||||
|
this.showColumn(cellProps) && (
|
||||||
|
<TableCell key={cellProps.id ?? index} {...cellProps} />
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
<TableCell className="menu">
|
||||||
|
{isConfigurable && this.renderColumnVisibilityMenu()}
|
||||||
|
</TableCell>
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="items box grow flex column">
|
||||||
|
<Table
|
||||||
|
tableId={tableId}
|
||||||
|
virtual={virtual}
|
||||||
|
selectable={hasDetailsView}
|
||||||
|
sortable={sortingCallbacks}
|
||||||
|
getTableRow={this.getRow}
|
||||||
|
items={this.props.getItems()}
|
||||||
|
selectedItemId={selectedItemId}
|
||||||
|
noItems={this.renderNoItems()}
|
||||||
|
className={classNames}
|
||||||
|
{...tableProps}
|
||||||
|
>
|
||||||
|
{this.renderTableHeader()}
|
||||||
|
{this.renderItems()}
|
||||||
|
</Table>
|
||||||
|
<AddRemoveButtons
|
||||||
|
onRemove={selectedItems.length ? this.removeItemsDialog : null}
|
||||||
|
removeTooltip={`Remove selected items (${selectedItems.length})`}
|
||||||
|
{...addRemoveButtons}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<MenuActions className="ItemListLayoutVisibilityMenu" toolbar={false} autoCloseOnSelect={false}>
|
||||||
|
{renderTableHeader.map((cellProps, index) => (
|
||||||
|
!cellProps.showWithColumn && (
|
||||||
|
<MenuItem key={index} className="input">
|
||||||
|
<Checkbox
|
||||||
|
label={cellProps.title ?? `<${cellProps.className}>`}
|
||||||
|
value={this.showColumn(cellProps)}
|
||||||
|
onChange={() => UserStore.getInstance().toggleTableColumnVisibility(tableId, cellProps.id)}
|
||||||
|
/>
|
||||||
|
</MenuItem>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</MenuActions>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/renderer/components/item-object-list/filters.tsx
Normal file
29
src/renderer/components/item-object-list/filters.tsx
Normal file
@ -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 <PageFiltersList filters={filters} />;
|
||||||
|
});
|
||||||
|
|
||||||
105
src/renderer/components/item-object-list/header.tsx
Normal file
105
src/renderer/components/item-object-list/header.tsx
Normal file
@ -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<I extends ItemObject> {
|
||||||
|
getItems: () => I[];
|
||||||
|
getFilters: () => Filter[];
|
||||||
|
toggleFilters: () => void;
|
||||||
|
|
||||||
|
store: ItemStore<I>;
|
||||||
|
searchFilters?: SearchFilter<I>[];
|
||||||
|
|
||||||
|
// header (title, filtering, searching, etc.)
|
||||||
|
showHeader?: boolean;
|
||||||
|
headerClassName?: IClassName;
|
||||||
|
renderHeaderTitle?:
|
||||||
|
| ReactNode
|
||||||
|
| ((parent: ItemListLayoutHeader<I>) => ReactNode);
|
||||||
|
customizeHeader?: HeaderCustomizer | HeaderCustomizer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@observer
|
||||||
|
export class ItemListLayoutHeader<I extends ItemObject> extends React.Component<
|
||||||
|
ItemListLayoutHeaderProps<I>
|
||||||
|
> {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<a onClick={toggleFilters}>Filtered</a>: {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<HeaderPlaceholders>(
|
||||||
|
(prevPlaceholders, customizer) => customizer(prevPlaceholders),
|
||||||
|
{
|
||||||
|
title: <h5 className="title">{renderedTitle}</h5>,
|
||||||
|
info: renderInfo(),
|
||||||
|
searchProps: {},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cssNames("header flex gaps align-center", headerClassName)}>
|
||||||
|
{title}
|
||||||
|
{
|
||||||
|
info && (
|
||||||
|
<div className="info-panel box grow">
|
||||||
|
{info}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
{filters}
|
||||||
|
{searchFilters.length > 0 && searchProps && <SearchInputUrl {...searchProps} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,4 +3,4 @@
|
|||||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./item-list-layout";
|
export * from "./list-layout";
|
||||||
|
|||||||
@ -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<I extends ItemObject> = (item: I) => string | number | (string | number)[];
|
|
||||||
export type SearchFilters<I extends ItemObject> = Record<string, SearchFilter<I>>;
|
|
||||||
export type ItemsFilter<I extends ItemObject> = (items: I[]) => I[];
|
|
||||||
export type ItemsFilters<I extends ItemObject> = Record<string, ItemsFilter<I>>;
|
|
||||||
|
|
||||||
export interface HeaderPlaceholders {
|
|
||||||
title?: ReactNode;
|
|
||||||
searchProps?: SearchInputUrlProps;
|
|
||||||
filters?: ReactNode;
|
|
||||||
info?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type HeaderCustomizer = (placeholders: HeaderPlaceholders) => HeaderPlaceholders;
|
|
||||||
export interface ItemListLayoutProps<I extends ItemObject> {
|
|
||||||
tableId?: string;
|
|
||||||
className: IClassName;
|
|
||||||
items?: I[];
|
|
||||||
store: ItemStore<I>;
|
|
||||||
dependentStores?: ItemStore<ItemObject>[];
|
|
||||||
preloadStores?: boolean;
|
|
||||||
hideFilters?: boolean;
|
|
||||||
searchFilters?: SearchFilter<I>[];
|
|
||||||
/** @deprecated */
|
|
||||||
filterItems?: ItemsFilter<I>[];
|
|
||||||
|
|
||||||
// header (title, filtering, searching, etc.)
|
|
||||||
showHeader?: boolean;
|
|
||||||
headerClassName?: IClassName;
|
|
||||||
renderHeaderTitle?: ReactNode | ((parent: NonInjectedItemListLayout<I>) => 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<I>;
|
|
||||||
tableProps?: Partial<TableProps<I>>; // low-level table configuration
|
|
||||||
renderTableHeader: TableCellProps[] | null;
|
|
||||||
renderTableContents: (item: I) => (ReactNode | TableCellProps)[];
|
|
||||||
renderItemMenu?: (item: I, store: ItemStore<I>) => ReactNode;
|
|
||||||
customizeTableRowProps?: (item: I) => Partial<TableRowProps>;
|
|
||||||
addRemoveButtons?: Partial<AddRemoveButtonsProps>;
|
|
||||||
virtual?: boolean;
|
|
||||||
|
|
||||||
// item details view
|
|
||||||
hasDetailsView?: boolean;
|
|
||||||
detailsItem?: I;
|
|
||||||
onDetails?: (item: I) => void;
|
|
||||||
|
|
||||||
// other
|
|
||||||
customizeRemoveDialog?: (selectedItems: I[]) => Partial<ConfirmDialogParams>;
|
|
||||||
renderFooter?: (parent: NonInjectedItemListLayout<I>) => React.ReactNode;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Message to display when a store failed to load
|
|
||||||
*
|
|
||||||
* @default "Failed to load items"
|
|
||||||
*/
|
|
||||||
failedToLoadMessage?: React.ReactNode;
|
|
||||||
|
|
||||||
filterCallbacks?: ItemsFilters<I>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultProps: Partial<ItemListLayoutProps<ItemObject>> = {
|
|
||||||
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<I extends ItemObject> extends React.Component<ItemListLayoutProps<I> & Dependencies> {
|
|
||||||
static defaultProps = defaultProps as object;
|
|
||||||
|
|
||||||
constructor(props: ItemListLayoutProps<I> & 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<I> = {
|
|
||||||
[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<I>[], 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<Filter>(filters, ({ type }) => type);
|
|
||||||
|
|
||||||
const filterItems: ItemsFilter<I>[] = [];
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<TableRow
|
|
||||||
key={itemId}
|
|
||||||
nowrap
|
|
||||||
searchItem={item}
|
|
||||||
sortItem={item}
|
|
||||||
selected={detailsItem && detailsItem.getId() === itemId}
|
|
||||||
onClick={hasDetailsView ? prevDefault(() => onDetails(item)) : undefined}
|
|
||||||
{...customizeTableRowProps(item)}
|
|
||||||
>
|
|
||||||
{isSelectable && (
|
|
||||||
<TableCell
|
|
||||||
checkbox
|
|
||||||
isChecked={isSelected(item)}
|
|
||||||
onClick={prevDefault(() => 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 <TableCell key={index} {...cellProps} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
{renderItemMenu && (
|
|
||||||
<TableCell className="menu">
|
|
||||||
<div onClick={stopPropagation}>
|
|
||||||
{renderItemMenu(item, store)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
)}
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@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 <b>{tailCount}</b> more</> : null;
|
|
||||||
const message = selectedCount <= 1 ? <p>Remove item <b>{selectedNames}</b>?</p> : <p>Remove <b>{selectedCount}</b> items <b>{selectedNames}</b>{tail}?</p>;
|
|
||||||
|
|
||||||
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 <PageFiltersList filters={filters} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderNoItems() {
|
|
||||||
if (this.failedToLoad) {
|
|
||||||
return <NoItems>{this.props.failedToLoadMessage}</NoItems>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.isReady) {
|
|
||||||
return <Spinner center />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.filters.length > 0) {
|
|
||||||
return (
|
|
||||||
<NoItems>
|
|
||||||
No items found.
|
|
||||||
<p>
|
|
||||||
<a onClick={() => pageFilters.reset()} className="contrast">
|
|
||||||
Reset filters?
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</NoItems>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <NoItems />;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 && (
|
|
||||||
<div className="info-panel box grow">
|
|
||||||
{info}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
{filters}
|
|
||||||
{searchFilters.length > 0 && searchProps && <SearchInputUrl {...searchProps} />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderInfo() {
|
|
||||||
const { items, filters } = this;
|
|
||||||
const allItemsCount = this.props.store.getTotalCount();
|
|
||||||
const itemsCount = items.length;
|
|
||||||
|
|
||||||
if (filters.length > 0) {
|
|
||||||
return (
|
|
||||||
<><a onClick={this.toggleFilters}>Filtered</a>: {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: <h5 className="title">{title}</h5>,
|
|
||||||
info: this.renderInfo(),
|
|
||||||
searchProps: {},
|
|
||||||
};
|
|
||||||
const headerPlaceholders = customizeHeaders.reduce((prevPlaceholders, customizer) => customizer(prevPlaceholders), initialPlaceholders);
|
|
||||||
const header = this.renderHeaderContent(headerPlaceholders);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cssNames("header flex gaps align-center", headerClassName)}>
|
|
||||||
{header}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderTableHeader() {
|
|
||||||
const { customizeTableRowProps, renderTableHeader, isSelectable, isConfigurable, store } = this.props;
|
|
||||||
|
|
||||||
if (!renderTableHeader) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const enabledItems = this.items.filter(item => !customizeTableRowProps(item).disabled);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableHead showTopLine nowrap>
|
|
||||||
{isSelectable && (
|
|
||||||
<TableCell
|
|
||||||
checkbox
|
|
||||||
isChecked={store.isSelectedAll(enabledItems)}
|
|
||||||
onClick={prevDefault(() => store.toggleSelectionAll(enabledItems))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{renderTableHeader.map((cellProps, index) => (
|
|
||||||
this.showColumn(cellProps) && (
|
|
||||||
<TableCell key={cellProps.id ?? index} {...cellProps} />
|
|
||||||
)
|
|
||||||
))}
|
|
||||||
<TableCell className="menu">
|
|
||||||
{isConfigurable && this.renderColumnVisibilityMenu()}
|
|
||||||
</TableCell>
|
|
||||||
</TableHead>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="items box grow flex column">
|
|
||||||
<Table
|
|
||||||
tableId={tableId}
|
|
||||||
virtual={virtual}
|
|
||||||
selectable={hasDetailsView}
|
|
||||||
sortable={sortingCallbacks}
|
|
||||||
getTableRow={this.getRow}
|
|
||||||
items={items}
|
|
||||||
selectedItemId={selectedItemId}
|
|
||||||
noItems={this.renderNoItems()}
|
|
||||||
className={classNames}
|
|
||||||
{...tableProps}
|
|
||||||
>
|
|
||||||
{this.renderTableHeader()}
|
|
||||||
{this.renderItems()}
|
|
||||||
</Table>
|
|
||||||
<AddRemoveButtons
|
|
||||||
onRemove={selectedItems.length ? removeItemsDialog : null}
|
|
||||||
removeTooltip={`Remove selected items (${selectedItems.length})`}
|
|
||||||
{...addRemoveButtons}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<MenuActions className="ItemListLayoutVisibilityMenu" toolbar={false} autoCloseOnSelect={false}>
|
|
||||||
{renderTableHeader.map((cellProps, index) => (
|
|
||||||
!cellProps.showWithColumn && (
|
|
||||||
<MenuItem key={index} className="input">
|
|
||||||
<Checkbox
|
|
||||||
label={cellProps.title ?? `<${cellProps.className}>`}
|
|
||||||
value={this.showColumn(cellProps)}
|
|
||||||
onChange={() => UserStore.getInstance().toggleTableColumnVisibility(tableId, cellProps.id)}
|
|
||||||
/>
|
|
||||||
</MenuItem>
|
|
||||||
)
|
|
||||||
))}
|
|
||||||
</MenuActions>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderFooter() {
|
|
||||||
return this.props.renderFooter?.(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { className } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cssNames("ItemListLayout flex column", className)}>
|
|
||||||
{this.renderHeader()}
|
|
||||||
{this.renderFilters()}
|
|
||||||
{this.renderList()}
|
|
||||||
{this.renderFooter()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ItemListLayout<I extends ItemObject>(
|
|
||||||
props: ItemListLayoutProps<I>,
|
|
||||||
) {
|
|
||||||
const InjectedItemListLayout = withInjectables<
|
|
||||||
Dependencies,
|
|
||||||
ItemListLayoutProps<I>
|
|
||||||
>(
|
|
||||||
NonInjectedItemListLayout,
|
|
||||||
|
|
||||||
{
|
|
||||||
getProps: (di, props) => ({
|
|
||||||
namespaceStore: di.inject(namespaceStoreInjectable),
|
|
||||||
itemListLayoutStorage: di.inject(itemListLayoutStorageInjectable),
|
|
||||||
...props,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return <InjectedItemListLayout {...props} />;
|
|
||||||
}
|
|
||||||
313
src/renderer/components/item-object-list/list-layout.tsx
Normal file
313
src/renderer/components/item-object-list/list-layout.tsx
Normal file
@ -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<I extends ItemObject> = (item: I) => string | number | (string | number)[];
|
||||||
|
export type SearchFilters<I extends ItemObject> = Record<string, SearchFilter<I>>;
|
||||||
|
export type ItemsFilter<I extends ItemObject> = (items: I[]) => I[];
|
||||||
|
export type ItemsFilters<I extends ItemObject> = Record<string, ItemsFilter<I>>;
|
||||||
|
|
||||||
|
export interface HeaderPlaceholders {
|
||||||
|
title?: ReactNode;
|
||||||
|
searchProps?: SearchInputUrlProps;
|
||||||
|
filters?: ReactNode;
|
||||||
|
info?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HeaderCustomizer = (placeholders: HeaderPlaceholders) => HeaderPlaceholders;
|
||||||
|
export interface ItemListLayoutProps<I extends ItemObject> {
|
||||||
|
tableId?: string;
|
||||||
|
className: IClassName;
|
||||||
|
items?: I[];
|
||||||
|
getItems?: () => I[];
|
||||||
|
store: ItemStore<I>;
|
||||||
|
dependentStores?: ItemStore<ItemObject>[];
|
||||||
|
preloadStores?: boolean;
|
||||||
|
hideFilters?: boolean;
|
||||||
|
searchFilters?: SearchFilter<I>[];
|
||||||
|
/** @deprecated */
|
||||||
|
filterItems?: ItemsFilter<I>[];
|
||||||
|
|
||||||
|
// header (title, filtering, searching, etc.)
|
||||||
|
showHeader?: boolean;
|
||||||
|
headerClassName?: IClassName;
|
||||||
|
renderHeaderTitle?: ReactNode | ((parent: NonInjectedItemListLayout<I>) => 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<I>;
|
||||||
|
tableProps?: Partial<TableProps<I>>; // low-level table configuration
|
||||||
|
renderTableHeader: TableCellProps[] | null;
|
||||||
|
renderTableContents: (item: I) => (ReactNode | TableCellProps)[];
|
||||||
|
renderItemMenu?: (item: I, store: ItemStore<I>) => ReactNode;
|
||||||
|
customizeTableRowProps?: (item: I) => Partial<TableRowProps>;
|
||||||
|
addRemoveButtons?: Partial<AddRemoveButtonsProps>;
|
||||||
|
virtual?: boolean;
|
||||||
|
|
||||||
|
// item details view
|
||||||
|
hasDetailsView?: boolean;
|
||||||
|
detailsItem?: I;
|
||||||
|
onDetails?: (item: I) => void;
|
||||||
|
|
||||||
|
// other
|
||||||
|
customizeRemoveDialog?: (selectedItems: I[]) => Partial<ConfirmDialogParams>;
|
||||||
|
renderFooter?: (parent: NonInjectedItemListLayout<I>) => React.ReactNode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message to display when a store failed to load
|
||||||
|
*
|
||||||
|
* @default "Failed to load items"
|
||||||
|
*/
|
||||||
|
failedToLoadMessage?: React.ReactNode;
|
||||||
|
|
||||||
|
filterCallbacks?: ItemsFilters<I>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultProps: Partial<ItemListLayoutProps<ItemObject>> = {
|
||||||
|
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<I extends ItemObject> extends React.Component<ItemListLayoutProps<I> & Dependencies> {
|
||||||
|
static defaultProps = defaultProps as object;
|
||||||
|
|
||||||
|
constructor(props: ItemListLayoutProps<I> & 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 <PageFiltersList filters={filters} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
private filterCallbacks: ItemsFilters<I> = {
|
||||||
|
[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<Filter>(this.filters, ({ type }) => type);
|
||||||
|
|
||||||
|
const filterItems: ItemsFilter<I>[] = [];
|
||||||
|
|
||||||
|
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(() => (
|
||||||
|
<div
|
||||||
|
className={cssNames("ItemListLayout flex column", this.props.className)}
|
||||||
|
>
|
||||||
|
<ItemListLayoutHeader
|
||||||
|
getItems={() => 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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ItemListLayoutFilters
|
||||||
|
getIsReady={() => this.isReady}
|
||||||
|
getFilters={() => this.filters}
|
||||||
|
getFiltersAreShown={() => this.showFilters}
|
||||||
|
hideFilters={this.props.hideFilters}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ItemListLayoutContent
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{this.props.renderFooter?.(this)}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const InjectedItemListLayout = withInjectables<Dependencies, ItemListLayoutProps<ItemObject>>(NonInjectedItemListLayout, {
|
||||||
|
getProps: (di, props) => ({
|
||||||
|
namespaceStore: di.inject(namespaceStoreInjectable),
|
||||||
|
itemListLayoutStorage: di.inject(itemListLayoutStorageInjectable),
|
||||||
|
...props,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ItemListLayout<I extends ItemObject>(props: ItemListLayoutProps<I>) {
|
||||||
|
return <InjectedItemListLayout {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters<I extends ItemObject>(filters: ItemsFilter<I>[], items: I[]): I[] {
|
||||||
|
if (!filters || !filters.length) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filters.reduce((items, filter) => filter(items), items);
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@
|
|||||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||||
*/
|
*/
|
||||||
import { getInjectable, lifecycleEnum } from "@ogre-tools/injectable";
|
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({
|
const itemListLayoutStorageInjectable = getInjectable({
|
||||||
instantiate: (di) => {
|
instantiate: (di) => {
|
||||||
@ -10,7 +10,7 @@ import { computed, makeObservable, observable, reaction } from "mobx";
|
|||||||
import { disposeOnUnmount, observer } from "mobx-react";
|
import { disposeOnUnmount, observer } from "mobx-react";
|
||||||
import { cssNames, Disposer } from "../../utils";
|
import { cssNames, Disposer } from "../../utils";
|
||||||
import type { KubeObject } from "../../../common/k8s-api/kube-object";
|
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 type { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
|
||||||
import { KubeObjectMenu } from "../kube-object-menu";
|
import { KubeObjectMenu } from "../kube-object-menu";
|
||||||
import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter";
|
import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter";
|
||||||
@ -99,14 +99,14 @@ class NonInjectedKubeObjectListLayout<K extends KubeObject> extends React.Compon
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
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;
|
const placeholderString = ResourceNames[ResourceKindMap[store.api.kind]] || store.api.kind;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ItemListLayout
|
<ItemListLayout
|
||||||
className={cssNames("KubeObjectListLayout", className)}
|
className={cssNames("KubeObjectListLayout", className)}
|
||||||
store={store}
|
store={store}
|
||||||
items={items}
|
getItems={() => this.props.items || store.contextItems}
|
||||||
preloadStores={false} // loading handled in kubeWatchApi.subscribeStores()
|
preloadStores={false} // loading handled in kubeWatchApi.subscribeStores()
|
||||||
detailsItem={this.selectedItem}
|
detailsItem={this.selectedItem}
|
||||||
customizeHeader={[
|
customizeHeader={[
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user