mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
1 parent
e810eab39f
commit
b7974827d2
786 files changed
+14567
-24066
No files matched your search
@@ -0,0 +1,21 @@
|
||||
import React from "react"
|
||||
import { Icon, IconProps } from "../icon";
|
||||
import { FilterType } from "./page-filters.store";
|
||||
|
||||
interface Props extends Partial<IconProps> {
|
||||
type: FilterType;
|
||||
}
|
||||
|
||||
export function FilterIcon(props: Props) {
|
||||
const { type, ...iconProps } = props;
|
||||
switch (type) {
|
||||
case FilterType.NAMESPACE:
|
||||
return <Icon small material="layers" {...iconProps}/>;
|
||||
|
||||
case FilterType.SEARCH:
|
||||
return <Icon small material="search" {...iconProps}/>;
|
||||
|
||||
default:
|
||||
return <Icon small material="filter_list" {...iconProps}/>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./item-list-layout"
|
||||
@@ -0,0 +1,39 @@
|
||||
.ItemListLayout {
|
||||
background: $contentColor;
|
||||
height: 100%;
|
||||
|
||||
> .header {
|
||||
--flex-gap: #{$padding * 2};
|
||||
padding: var(--flex-gap);
|
||||
|
||||
.title {
|
||||
color: $textColorPrimary;
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.SearchInput {
|
||||
label {
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: $radius;
|
||||
box-shadow: 0 0 0 1px $halfGray;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .items {
|
||||
position: relative;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.TableCell {
|
||||
&.menu {
|
||||
@include table-cell-action;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
import "./item-list-layout.scss"
|
||||
import groupBy from "lodash/groupBy"
|
||||
|
||||
import React, { ReactNode } from "react";
|
||||
import { computed, observable, reaction, toJS, when } from "mobx";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
import { Plural, Trans } from "@lingui/macro";
|
||||
import { ConfirmDialog, IConfirmDialogParams } from "../confirm-dialog";
|
||||
import { SortingCallback, Table, TableCell, TableCellProps, TableHead, TableProps, TableRow, TableRowProps } from "../table";
|
||||
import { autobind, createStorage, cssNames, IClassName, isReactNode, noop, prevDefault, stopPropagation } from "../../utils";
|
||||
import { AddRemoveButtons, AddRemoveButtonsProps } from "../add-remove-buttons";
|
||||
import { NoItems } from "../no-items";
|
||||
import { Spinner } from "../spinner";
|
||||
import { ItemObject, ItemStore } from "../../item.store";
|
||||
import { SearchInput } from "../input";
|
||||
import { namespaceStore } from "../+namespaces/namespace.store";
|
||||
import { Filter, FilterType, pageFilters } from "./page-filters.store";
|
||||
import { PageFiltersList } from "./page-filters-list";
|
||||
import { PageFiltersSelect } from "./page-filters-select";
|
||||
import { NamespaceSelectFilter } from "../+namespaces/namespace-select";
|
||||
import { themeStore } from "../../theme.store";
|
||||
|
||||
// todo: refactor, split to small re-usable components
|
||||
|
||||
export type SearchFilter<T extends ItemObject = any> = (item: T) => string | number | (string | number)[];
|
||||
export type ItemsFilter<T extends ItemObject = any> = (items: T[]) => T[];
|
||||
|
||||
interface IHeaderPlaceholders {
|
||||
title: ReactNode;
|
||||
search: ReactNode;
|
||||
filters: ReactNode;
|
||||
info: ReactNode;
|
||||
}
|
||||
|
||||
export interface ItemListLayoutProps<T extends ItemObject = ItemObject> {
|
||||
className: IClassName;
|
||||
store: ItemStore<T>;
|
||||
dependentStores?: ItemStore[];
|
||||
isClusterScoped?: boolean;
|
||||
hideFilters?: boolean;
|
||||
searchFilters?: SearchFilter<T>[];
|
||||
filterItems?: ItemsFilter<T>[];
|
||||
|
||||
// header (title, filtering, searching, etc.)
|
||||
showHeader?: boolean;
|
||||
headerClassName?: IClassName;
|
||||
renderHeaderTitle?: ReactNode | ((parent: ItemListLayout) => ReactNode);
|
||||
customizeHeader?: (placeholders: IHeaderPlaceholders, content: ReactNode) => Partial<IHeaderPlaceholders> | ReactNode;
|
||||
|
||||
// items list configuration
|
||||
isReady?: boolean; // show loading indicator while not ready
|
||||
isSelectable?: boolean; // show checkbox in rows for selecting items
|
||||
isSearchable?: boolean; // apply search-filter & add search-input
|
||||
copyClassNameFromHeadCells?: boolean;
|
||||
sortingCallbacks?: { [sortBy: string]: SortingCallback };
|
||||
tableProps?: Partial<TableProps>; // low-level table configuration
|
||||
renderTableHeader: TableCellProps[] | null;
|
||||
renderTableContents: (item: T) => (ReactNode | TableCellProps)[];
|
||||
renderItemMenu?: (item: T, store: ItemStore<T>) => ReactNode;
|
||||
customizeTableRowProps?: (item: T) => Partial<TableRowProps>;
|
||||
addRemoveButtons?: Partial<AddRemoveButtonsProps>;
|
||||
virtual?: boolean;
|
||||
|
||||
// item details view
|
||||
hasDetailsView?: boolean;
|
||||
detailsItem?: T;
|
||||
onDetails?: (item: T) => void;
|
||||
|
||||
// other
|
||||
customizeRemoveDialog?: (selectedItems: T[]) => Partial<IConfirmDialogParams>;
|
||||
renderFooter?: (parent: ItemListLayout) => React.ReactNode;
|
||||
}
|
||||
|
||||
const defaultProps: Partial<ItemListLayoutProps> = {
|
||||
showHeader: true,
|
||||
isSearchable: true,
|
||||
isSelectable: true,
|
||||
copyClassNameFromHeadCells: true,
|
||||
dependentStores: [],
|
||||
filterItems: [],
|
||||
hasDetailsView: true,
|
||||
onDetails: noop,
|
||||
virtual: true
|
||||
};
|
||||
|
||||
interface ItemListLayoutUserSettings {
|
||||
showAppliedFilters?: boolean;
|
||||
}
|
||||
|
||||
@observer
|
||||
export class ItemListLayout extends React.Component<ItemListLayoutProps> {
|
||||
static defaultProps = defaultProps as object;
|
||||
|
||||
@observable isUnmounting = false;
|
||||
|
||||
// default user settings (ui show-hide tweaks mostly)
|
||||
@observable userSettings: ItemListLayoutUserSettings = {
|
||||
showAppliedFilters: false,
|
||||
};
|
||||
|
||||
constructor(props: ItemListLayoutProps) {
|
||||
super(props);
|
||||
|
||||
// keep ui user settings in local storage
|
||||
const defaultUserSettings = toJS(this.userSettings);
|
||||
const storage = createStorage<ItemListLayoutUserSettings>("items_list_layout", defaultUserSettings);
|
||||
Object.assign(this.userSettings, storage.get()); // restore
|
||||
disposeOnUnmount(this, [
|
||||
reaction(() => toJS(this.userSettings), settings => storage.set(settings)),
|
||||
]);
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
const { store, dependentStores, isClusterScoped } = this.props;
|
||||
const stores = [store, ...dependentStores];
|
||||
if (!isClusterScoped) stores.push(namespaceStore);
|
||||
try {
|
||||
await Promise.all(stores.map(store => store.loadAll()));
|
||||
const subscriptions = stores.map(store => store.subscribe());
|
||||
await when(() => this.isUnmounting);
|
||||
subscriptions.forEach(dispose => dispose()); // unsubscribe all
|
||||
} catch(error) {
|
||||
console.log("catched", error)
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.isUnmounting = true;
|
||||
const { store, isSelectable } = this.props;
|
||||
if (isSelectable) store.resetSelection();
|
||||
}
|
||||
|
||||
private filterCallbacks: { [type: string]: ItemsFilter } = {
|
||||
[FilterType.SEARCH]: items => {
|
||||
const { searchFilters, isSearchable } = this.props;
|
||||
const search = pageFilters.getValues(FilterType.SEARCH)[0] || "";
|
||||
if (search && isSearchable && searchFilters) {
|
||||
const normalizeText = (text: string) => String(text).toLowerCase();
|
||||
const searchTexts = [search].map(normalizeText);
|
||||
return items.filter(item => {
|
||||
return searchFilters.some(getTexts => {
|
||||
const sourceTexts: string[] = [getTexts(item)].flat().map(normalizeText);
|
||||
return sourceTexts.some(source => searchTexts.some(search => source.includes(search)));
|
||||
})
|
||||
});
|
||||
}
|
||||
return items;
|
||||
},
|
||||
|
||||
[FilterType.NAMESPACE]: items => {
|
||||
const filterValues = pageFilters.getValues(FilterType.NAMESPACE);
|
||||
if (filterValues.length > 0) {
|
||||
return items.filter(item => filterValues.includes(item.getNs()))
|
||||
}
|
||||
return items;
|
||||
},
|
||||
}
|
||||
|
||||
@computed get isReady() {
|
||||
const { isReady, store } = this.props;
|
||||
return typeof isReady == "boolean" ? isReady : store.isLoaded;
|
||||
}
|
||||
|
||||
@computed get filters() {
|
||||
let { activeFilters } = pageFilters;
|
||||
const { isClusterScoped, isSearchable, searchFilters } = this.props;
|
||||
if (isClusterScoped) {
|
||||
activeFilters = activeFilters.filter(({ type }) => type !== FilterType.NAMESPACE);
|
||||
}
|
||||
if (!(isSearchable && searchFilters)) {
|
||||
activeFilters = activeFilters.filter(({ type }) => type !== FilterType.SEARCH);
|
||||
}
|
||||
return activeFilters;
|
||||
}
|
||||
|
||||
applyFilters<T>(filters: ItemsFilter[], items: T[]): T[] {
|
||||
if (!filters || !filters.length) return items;
|
||||
return filters.reduce((items, filter) => filter(items), items);
|
||||
}
|
||||
|
||||
@computed get allItems() {
|
||||
const { filterItems, store } = this.props;
|
||||
return this.applyFilters(filterItems, store.items);
|
||||
}
|
||||
|
||||
@computed get items() {
|
||||
const { allItems, filters, filterCallbacks } = this;
|
||||
const filterItems: ItemsFilter[] = [];
|
||||
const filterGroups = groupBy<Filter>(filters, ({ type }) => type);
|
||||
|
||||
Object.entries(filterGroups).forEach(([type, filtersGroup]) => {
|
||||
const filterCallback = filterCallbacks[type];
|
||||
if (filterCallback && filtersGroup.length > 0) {
|
||||
filterItems.push(filterCallback);
|
||||
}
|
||||
});
|
||||
return this.applyFilters(filterItems, allItems);
|
||||
}
|
||||
|
||||
@autobind()
|
||||
getRow(uid: string) {
|
||||
const {
|
||||
isSelectable, renderTableHeader, renderTableContents, renderItemMenu,
|
||||
store, hasDetailsView, onDetails,
|
||||
copyClassNameFromHeadCells, customizeTableRowProps, detailsItem,
|
||||
} = this.props;
|
||||
const { isSelected } = store;
|
||||
const item = this.items.find(item => item.getId() == uid);
|
||||
if (!item) return;
|
||||
const itemId = item.getId();
|
||||
return (
|
||||
<TableRow
|
||||
key={itemId}
|
||||
nowrap
|
||||
searchItem={item}
|
||||
sortItem={item}
|
||||
selected={detailsItem && detailsItem.getId() === itemId}
|
||||
onClick={hasDetailsView ? prevDefault(() => onDetails(item)) : undefined}
|
||||
{...(customizeTableRowProps ? 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;
|
||||
if (copyClassNameFromHeadCells && renderTableHeader) {
|
||||
const headCell = renderTableHeader[index];
|
||||
if (headCell) {
|
||||
cellProps.className = cssNames(cellProps.className, headCell.className);
|
||||
}
|
||||
}
|
||||
return <TableCell key={index} {...cellProps}/>
|
||||
})
|
||||
}
|
||||
{renderItemMenu && (
|
||||
<TableCell className="menu" onClick={stopPropagation}>
|
||||
{renderItemMenu(item, store)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
@autobind()
|
||||
removeItemsDialog() {
|
||||
const { customizeRemoveDialog, store } = this.props;
|
||||
const { selectedItems, removeSelectedItems } = store;
|
||||
const visibleMaxNamesCount = 5;
|
||||
const selectedNames = selectedItems.map(ns => ns.getName()).slice(0, visibleMaxNamesCount).join(", ");
|
||||
const dialogCustomProps = customizeRemoveDialog ? customizeRemoveDialog(selectedItems) : {};
|
||||
const selectedCount = selectedItems.length;
|
||||
const tailCount = selectedCount > visibleMaxNamesCount ? selectedCount - visibleMaxNamesCount : 0;
|
||||
const tail = tailCount > 0 ? <Trans>and <b>{tailCount}</b> more</Trans> : null;
|
||||
ConfirmDialog.open({
|
||||
ok: removeSelectedItems,
|
||||
labelOk: <Trans>Remove</Trans>,
|
||||
message: (
|
||||
<Plural
|
||||
value={selectedCount}
|
||||
one={<p>Remove item <b>{selectedNames}</b>?</p>}
|
||||
other={<p>Remove <b>{selectedCount}</b> items <b>{selectedNames}</b> {tail}?</p>}
|
||||
/>
|
||||
),
|
||||
...dialogCustomProps,
|
||||
})
|
||||
}
|
||||
|
||||
renderFilters() {
|
||||
const { hideFilters } = this.props;
|
||||
const { isReady, userSettings, filters } = this;
|
||||
if (!isReady || !filters.length || hideFilters || !userSettings.showAppliedFilters) {
|
||||
return;
|
||||
}
|
||||
return <PageFiltersList filters={filters}/>
|
||||
}
|
||||
|
||||
renderNoItems() {
|
||||
const { allItems, items, filters } = this;
|
||||
const allItemsCount = allItems.length;
|
||||
const itemsCount = items.length;
|
||||
const isFiltered = filters.length > 0 && allItemsCount > itemsCount;
|
||||
if (isFiltered) {
|
||||
return (
|
||||
<NoItems>
|
||||
<Trans>No items found.</Trans>
|
||||
<p>
|
||||
<a onClick={() => pageFilters.reset()} className="contrast">
|
||||
<Trans>Reset filters?</Trans>
|
||||
</a>
|
||||
</p>
|
||||
</NoItems>
|
||||
)
|
||||
}
|
||||
return <NoItems/>
|
||||
}
|
||||
|
||||
renderHeaderContent(placeholders: IHeaderPlaceholders): ReactNode {
|
||||
const { title, filters, search, info } = placeholders;
|
||||
return (
|
||||
<>
|
||||
{title}
|
||||
<div className="info-panel box grow">
|
||||
{this.isReady && info}
|
||||
</div>
|
||||
{filters}
|
||||
{search}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
renderInfo() {
|
||||
const { allItems, items, isReady, userSettings, filters } = this;
|
||||
const allItemsCount = allItems.length;
|
||||
const itemsCount = items.length;
|
||||
const isFiltered = isReady && filters.length > 0;
|
||||
if (isFiltered) {
|
||||
const toggleFilters = () => userSettings.showAppliedFilters = !userSettings.showAppliedFilters;
|
||||
return (
|
||||
<Trans>
|
||||
<a onClick={toggleFilters}>Filtered</a>: {itemsCount} / {allItemsCount}
|
||||
</Trans>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Plural
|
||||
value={allItemsCount}
|
||||
one="# item"
|
||||
other="# items"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderHeader() {
|
||||
const { showHeader, customizeHeader, renderHeaderTitle, headerClassName, isClusterScoped } = this.props;
|
||||
if (!showHeader) return;
|
||||
const title = typeof renderHeaderTitle === "function" ? renderHeaderTitle(this) : renderHeaderTitle;
|
||||
const placeholders: IHeaderPlaceholders = {
|
||||
title: <h5 className="title">{title}</h5>,
|
||||
info: this.renderInfo(),
|
||||
filters: <>
|
||||
{!isClusterScoped && <NamespaceSelectFilter/>}
|
||||
<PageFiltersSelect allowEmpty disableFilters={{
|
||||
[FilterType.NAMESPACE]: true, // namespace-select used instead
|
||||
}}/>
|
||||
</>,
|
||||
search: <SearchInput/>,
|
||||
}
|
||||
let header = this.renderHeaderContent(placeholders);
|
||||
if (customizeHeader) {
|
||||
const modifiedHeader = customizeHeader(placeholders, header);
|
||||
if (isReactNode(modifiedHeader)) {
|
||||
header = modifiedHeader;
|
||||
}
|
||||
else {
|
||||
header = this.renderHeaderContent({
|
||||
...placeholders,
|
||||
...modifiedHeader as IHeaderPlaceholders,
|
||||
});
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div className={cssNames("header flex gaps align-center", headerClassName)}>
|
||||
{header}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderList() {
|
||||
const {
|
||||
isSelectable, tableProps = {}, renderTableHeader, renderItemMenu,
|
||||
store, hasDetailsView, addRemoveButtons = {}, virtual, sortingCallbacks, detailsItem
|
||||
} = this.props;
|
||||
const { isReady, removeItemsDialog, items } = this;
|
||||
const { selectedItems } = store;
|
||||
const selectedItemId = detailsItem && detailsItem.getId();
|
||||
return (
|
||||
<div className="items box grow flex column">
|
||||
{!isReady && (
|
||||
<Spinner center/>
|
||||
)}
|
||||
{isReady && (
|
||||
<Table
|
||||
virtual={virtual}
|
||||
selectable={hasDetailsView}
|
||||
sortable={sortingCallbacks}
|
||||
getTableRow={this.getRow}
|
||||
items={items}
|
||||
selectedItemId={selectedItemId}
|
||||
noItems={this.renderNoItems()}
|
||||
{...({
|
||||
...tableProps,
|
||||
className: cssNames("box grow", tableProps.className, themeStore.activeTheme.type),
|
||||
})}
|
||||
>
|
||||
{renderTableHeader && (
|
||||
<TableHead showTopLine nowrap>
|
||||
{isSelectable && (
|
||||
<TableCell
|
||||
checkbox
|
||||
isChecked={store.isSelectedAll(items)}
|
||||
onClick={prevDefault(() => store.toggleSelectionAll(items))}
|
||||
/>
|
||||
)}
|
||||
{renderTableHeader.map((cellProps, index) => <TableCell key={index} {...cellProps}/>)}
|
||||
{renderItemMenu && <TableCell className="menu"/>}
|
||||
</TableHead>
|
||||
)}
|
||||
{
|
||||
!virtual && items.map(item => this.getRow(item.getId()))
|
||||
}
|
||||
</Table>
|
||||
)}
|
||||
<AddRemoveButtons
|
||||
onRemove={selectedItems.length ? removeItemsDialog : null}
|
||||
removeTooltip={<Trans>Remove selected items ({selectedItems.length})</Trans>}
|
||||
{...addRemoveButtons}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderFooter() {
|
||||
if (this.props.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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
.PageFiltersList {
|
||||
$bgc: $filterAreaBackground;
|
||||
$spacing: $padding * 1.5;
|
||||
|
||||
position: relative;
|
||||
top: 1px;
|
||||
border-top: 1px solid $borderFaintColor;
|
||||
background: $bgc;
|
||||
padding: $spacing $padding * 2;
|
||||
font-size: $font-size-small;
|
||||
|
||||
&:empty {
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&:not(:empty) {
|
||||
border-bottom: 1px solid $borderFaintColor;
|
||||
}
|
||||
|
||||
> .header {
|
||||
--flex-gap: #{$padding};
|
||||
margin-bottom: $padding / 2;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
> .labels {
|
||||
$gap: $padding / 3;
|
||||
margin-left: -$gap;
|
||||
margin-right: -$gap;
|
||||
|
||||
> .Badge {
|
||||
--flex-gap: .25em;
|
||||
display: inline-flex;
|
||||
margin: $gap;
|
||||
padding: $padding /3;
|
||||
vertical-align: middle;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import "./page-filters-list.scss"
|
||||
import React from "react"
|
||||
import { observer } from "mobx-react";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { Badge } from "../badge";
|
||||
import { cssNames } from "../../utils";
|
||||
import { Filter, pageFilters } from "./page-filters.store";
|
||||
import { FilterIcon } from "./filter-icon";
|
||||
import { Icon } from "../icon";
|
||||
|
||||
interface Props {
|
||||
filters?: Filter[];
|
||||
}
|
||||
|
||||
@observer
|
||||
export class PageFiltersList extends React.Component<Props> {
|
||||
static defaultProps: Props = {
|
||||
get filters() {
|
||||
return pageFilters.activeFilters;
|
||||
}
|
||||
}
|
||||
|
||||
reset = () => pageFilters.reset()
|
||||
remove = (filter: Filter) => pageFilters.removeFilter(filter)
|
||||
|
||||
renderContent() {
|
||||
const { filters } = this.props;
|
||||
if (!filters.length) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="header flex gaps">
|
||||
<span><Trans>Currently applied filters:</Trans></span>
|
||||
<a onClick={this.reset} className="reset">
|
||||
<Trans>Reset</Trans>
|
||||
</a>
|
||||
</div>
|
||||
<div className="labels">
|
||||
{filters.map(filter => {
|
||||
const { value, type } = filter;
|
||||
return (
|
||||
<Badge
|
||||
key={`${type}-${value}`}
|
||||
title={type}
|
||||
className={cssNames("flex gaps filter align-center", type)}
|
||||
label={(
|
||||
<>
|
||||
<FilterIcon type={type}/>
|
||||
<span className="value">{value}</span>
|
||||
<Icon
|
||||
small
|
||||
material="close"
|
||||
onClick={() => this.remove(filter)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="PageFiltersList">
|
||||
{this.renderContent()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import React from "react"
|
||||
import { observer } from "mobx-react";
|
||||
import { computed } from "mobx";
|
||||
import { t, Trans } from "@lingui/macro";
|
||||
import { GroupSelectOption, Select, SelectOption, SelectProps } from "../select";
|
||||
import { FilterType, pageFilters } from "./page-filters.store";
|
||||
import { namespaceStore } from "../+namespaces/namespace.store";
|
||||
import { Icon } from "../icon";
|
||||
import { _i18n } from "../../i18n";
|
||||
import { FilterIcon } from "./filter-icon";
|
||||
|
||||
export interface SelectOptionFilter extends SelectOption {
|
||||
type: FilterType;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
interface Props extends SelectProps {
|
||||
allowEmpty?: boolean;
|
||||
disableFilters?: {
|
||||
[filterType: string]: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
@observer
|
||||
export class PageFiltersSelect extends React.Component<Props> {
|
||||
static defaultProps: Props = {
|
||||
allowEmpty: true,
|
||||
disableFilters: {},
|
||||
}
|
||||
|
||||
@computed get groupedOptions() {
|
||||
const options: GroupSelectOption<SelectOptionFilter>[] = [];
|
||||
const { disableFilters } = this.props;
|
||||
if (!disableFilters[FilterType.NAMESPACE]) {
|
||||
const selectedValues = pageFilters.getValues(FilterType.NAMESPACE);
|
||||
options.push({
|
||||
label: <Trans>Namespace</Trans>,
|
||||
options: namespaceStore.items.map(ns => {
|
||||
const name = ns.getName();
|
||||
return {
|
||||
type: FilterType.NAMESPACE,
|
||||
value: name,
|
||||
icon: <Icon small material="layers"/>,
|
||||
selected: selectedValues.includes(name),
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
@computed get options(): SelectOptionFilter[] {
|
||||
return this.groupedOptions.reduce((options, optGroup) => {
|
||||
options.push(...optGroup.options);
|
||||
return options;
|
||||
}, [])
|
||||
}
|
||||
|
||||
private formatLabel = (option: SelectOptionFilter) => {
|
||||
const { label, value, type, selected } = option;
|
||||
return (
|
||||
<div className="flex gaps">
|
||||
<FilterIcon type={type}/>
|
||||
<span>{label || String(value)}</span>
|
||||
{selected && <Icon small material="check" className="box right"/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private onSelect = (option: SelectOptionFilter) => {
|
||||
const { type, value, selected } = option;
|
||||
const { addFilter, removeFilter } = pageFilters;
|
||||
const filter = { type, value };
|
||||
if (!selected) {
|
||||
addFilter(filter);
|
||||
}
|
||||
else {
|
||||
removeFilter(filter);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { groupedOptions, formatLabel, onSelect, options } = this;
|
||||
if (!options.length && this.props.allowEmpty) {
|
||||
return null;
|
||||
}
|
||||
const { allowEmpty, disableFilters, ...selectProps } = this.props;
|
||||
const selectedOptions = options.filter(opt => opt.selected);
|
||||
return (
|
||||
<Select
|
||||
{...selectProps}
|
||||
placeholder={_i18n._(t`Filters (${selectedOptions.length}/${options.length})`)}
|
||||
noOptionsMessage={() => _i18n._(t`No filters available.`)}
|
||||
autoConvertOptions={false}
|
||||
tabSelectsValue={false}
|
||||
controlShouldRenderValue={false}
|
||||
options={groupedOptions}
|
||||
formatOptionLabel={formatLabel}
|
||||
onChange={onSelect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { computed, observable, reaction } from "mobx";
|
||||
import { autobind } from "../../utils";
|
||||
import { getSearch, setSearch } from "../../navigation";
|
||||
import { namespaceStore } from "../+namespaces/namespace.store";
|
||||
|
||||
export enum FilterType {
|
||||
SEARCH = "search",
|
||||
NAMESPACE = "namespace",
|
||||
}
|
||||
|
||||
export interface Filter {
|
||||
type: FilterType;
|
||||
value: string;
|
||||
}
|
||||
|
||||
@autobind()
|
||||
export class PageFiltersStore {
|
||||
protected filters = observable.array<Filter>([], { deep: false });
|
||||
protected isDisabled = observable.map<FilterType, boolean>();
|
||||
|
||||
@computed get activeFilters() {
|
||||
return this.filters.filter(filter => !this.isDisabled.get(filter.type));
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this.syncWithGlobalSearch();
|
||||
this.syncWithContextNamespace();
|
||||
}
|
||||
|
||||
protected syncWithContextNamespace() {
|
||||
const disposers = [
|
||||
reaction(() => this.getValues(FilterType.NAMESPACE), filteredNs => {
|
||||
if (filteredNs.length !== namespaceStore.contextNs.length) {
|
||||
namespaceStore.setContext(filteredNs);
|
||||
}
|
||||
}),
|
||||
reaction(() => namespaceStore.contextNs.toJS(), contextNs => {
|
||||
const filteredNs = this.getValues(FilterType.NAMESPACE);
|
||||
const isChanged = contextNs.length !== filteredNs.length;
|
||||
if (isChanged) {
|
||||
this.filters.replace([
|
||||
...this.filters.filter(({ type }) => type !== FilterType.NAMESPACE),
|
||||
...contextNs.map(ns => ({ type: FilterType.NAMESPACE, value: ns })),
|
||||
]);
|
||||
}
|
||||
}, {
|
||||
fireImmediately: true
|
||||
})
|
||||
];
|
||||
return () => disposers.forEach(dispose => dispose());
|
||||
}
|
||||
|
||||
protected syncWithGlobalSearch() {
|
||||
const disposers = [
|
||||
reaction(() => this.getValues(FilterType.SEARCH)[0], setSearch),
|
||||
reaction(() => getSearch(), search => {
|
||||
const filter = this.getByType(FilterType.SEARCH);
|
||||
if (filter) {
|
||||
this.removeFilter(filter); // search filter might occur once
|
||||
}
|
||||
if (search) {
|
||||
this.addFilter({ type: FilterType.SEARCH, value: search }, true);
|
||||
}
|
||||
}, {
|
||||
fireImmediately: true
|
||||
})
|
||||
];
|
||||
return () => disposers.forEach(dispose => dispose());
|
||||
}
|
||||
|
||||
addFilter(filter: Filter, begin = false) {
|
||||
if (begin) this.filters.unshift(filter);
|
||||
else {
|
||||
this.filters.push(filter);
|
||||
}
|
||||
}
|
||||
|
||||
removeFilter(filter: Filter) {
|
||||
if (!this.filters.remove(filter)) {
|
||||
const filterCopy = this.filters.find(f => f.type === filter.type && f.value === filter.value);
|
||||
if (filterCopy) this.filters.remove(filterCopy);
|
||||
}
|
||||
}
|
||||
|
||||
getByType(type: FilterType, value?: any): Filter {
|
||||
return this.filters.find(filter => filter.type === type && (
|
||||
arguments.length > 1 ? filter.value === value : true
|
||||
))
|
||||
}
|
||||
|
||||
getValues(type: FilterType) {
|
||||
return this.filters
|
||||
.filter(filter => filter.type === type)
|
||||
.map(filter => filter.value);
|
||||
}
|
||||
|
||||
isEnabled(type: FilterType) {
|
||||
return !this.isDisabled.get(type);
|
||||
}
|
||||
|
||||
disable(type: FilterType | FilterType[]) {
|
||||
[type].flat().forEach(type => this.isDisabled.set(type, true));
|
||||
return () => this.enable(type);
|
||||
}
|
||||
|
||||
enable(type: FilterType | FilterType[]) {
|
||||
[type].flat().forEach(type => this.isDisabled.delete(type));
|
||||
return () => this.disable(type);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.filters.length = 0;
|
||||
this.isDisabled.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const pageFilters = new PageFiltersStore();
|
||||
Reference in new issue
Block a user