mirror of
https://github.com/lensapp/lens.git
synced 2024-09-20 13:57:23 +03:00
Make customizing ItemListLayout header explicitly reducable (#2956)
- Add search placeholder text for helm releases Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
parent
757fed47fa
commit
ae2fa15b20
@ -30,7 +30,6 @@ import type { HelmChart } from "../../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 { SearchInputUrl } from "../input";
|
||||
|
||||
enum columnId {
|
||||
name = "name",
|
||||
@ -92,9 +91,12 @@ export class HelmCharts extends Component<Props> {
|
||||
(chart: HelmChart) => chart.getAppVersion(),
|
||||
(chart: HelmChart) => chart.getKeywords(),
|
||||
]}
|
||||
customizeHeader={() => (
|
||||
<SearchInputUrl placeholder="Search Helm Charts" />
|
||||
)}
|
||||
customizeHeader={({ searchProps }) => ({
|
||||
searchProps: {
|
||||
...searchProps,
|
||||
placeholder: "Search Helm Charts...",
|
||||
},
|
||||
})}
|
||||
renderTableHeader={[
|
||||
{ className: "icon", showWithColumn: columnId.name },
|
||||
{ title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
|
||||
|
@ -117,16 +117,20 @@ export class HelmReleases extends Component<Props> {
|
||||
(release: HelmRelease) => release.getStatus(),
|
||||
(release: HelmRelease) => release.getVersion(),
|
||||
]}
|
||||
renderHeaderTitle="Releases"
|
||||
customizeHeader={({ filters, ...headerPlaceholders }) => ({
|
||||
customizeHeader={({ filters, searchProps, ...headerPlaceholders }) => ({
|
||||
filters: (
|
||||
<>
|
||||
{filters}
|
||||
<NamespaceSelectFilter />
|
||||
</>
|
||||
),
|
||||
searchProps: {
|
||||
...searchProps,
|
||||
placeholder: "Search Releases...",
|
||||
},
|
||||
...headerPlaceholders,
|
||||
})}
|
||||
renderHeaderTitle="Releases"
|
||||
renderTableHeader={[
|
||||
{ title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
|
||||
{ title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace },
|
||||
|
@ -205,7 +205,6 @@ export class Catalog extends React.Component<Props> {
|
||||
return (
|
||||
<ItemListLayout
|
||||
renderHeaderTitle={this.catalogEntityStore.activeCategory?.metadata.name ?? "Browse All"}
|
||||
isSearchable={true}
|
||||
isSelectable={false}
|
||||
className="CatalogItemList"
|
||||
store={this.catalogEntityStore}
|
||||
@ -242,7 +241,6 @@ export class Catalog extends React.Component<Props> {
|
||||
return (
|
||||
<ItemListLayout
|
||||
renderHeaderTitle={this.catalogEntityStore.activeCategory?.metadata.name ?? "Browse All"}
|
||||
isSearchable={true}
|
||||
isSelectable={false}
|
||||
className="CatalogItemList"
|
||||
store={this.catalogEntityStore}
|
||||
|
@ -42,4 +42,8 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.SearchInput {
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
|
@ -95,7 +95,7 @@ export class CrdList extends React.Component {
|
||||
sortingCallbacks={sortingCallbacks}
|
||||
searchFilters={Object.values(sortingCallbacks)}
|
||||
renderHeaderTitle="Custom Resources"
|
||||
customizeHeader={() => {
|
||||
customizeHeader={({ filters, ...headerPlaceholders }) => {
|
||||
let placeholder = <>All groups</>;
|
||||
|
||||
if (selectedGroups.length == 1) placeholder = <>Group: {selectedGroups[0]}</>;
|
||||
@ -104,26 +104,30 @@ export class CrdList extends React.Component {
|
||||
return {
|
||||
// todo: move to global filters
|
||||
filters: (
|
||||
<Select
|
||||
className="group-select"
|
||||
placeholder={placeholder}
|
||||
options={Object.keys(crdStore.groups)}
|
||||
onChange={({ value: group }: SelectOption) => this.toggleSelection(group)}
|
||||
closeMenuOnSelect={false}
|
||||
controlShouldRenderValue={false}
|
||||
formatOptionLabel={({ value: group }: SelectOption) => {
|
||||
const isSelected = selectedGroups.includes(group);
|
||||
|
||||
return (
|
||||
<div className="flex gaps align-center">
|
||||
<Icon small material="folder"/>
|
||||
<span>{group}</span>
|
||||
{isSelected && <Icon small material="check" className="box right"/>}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
<>
|
||||
{filters}
|
||||
<Select
|
||||
className="group-select"
|
||||
placeholder={placeholder}
|
||||
options={Object.keys(crdStore.groups)}
|
||||
onChange={({ value: group }: SelectOption) => this.toggleSelection(group)}
|
||||
closeMenuOnSelect={false}
|
||||
controlShouldRenderValue={false}
|
||||
formatOptionLabel={({ value: group }: SelectOption) => {
|
||||
const isSelected = selectedGroups.includes(group);
|
||||
|
||||
return (
|
||||
<div className="flex gaps align-center">
|
||||
<Icon small material="folder"/>
|
||||
<span>{group}</span>
|
||||
{isSelected && <Icon small material="check" className="box right"/>}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
...headerPlaceholders,
|
||||
};
|
||||
}}
|
||||
renderTableHeader={[
|
||||
|
@ -101,6 +101,13 @@ export class CrdResources extends React.Component<Props> {
|
||||
(item: KubeObject) => item.getSearchFields(),
|
||||
]}
|
||||
renderHeaderTitle={crd.getResourceTitle()}
|
||||
customizeHeader={({ searchProps, ...headerPlaceholders }) => ({
|
||||
searchProps: {
|
||||
...searchProps,
|
||||
placeholder: `Search ${crd.getResourceTitle()}...`,
|
||||
},
|
||||
...headerPlaceholders
|
||||
})}
|
||||
renderTableHeader={[
|
||||
{ title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
|
||||
isNamespaced && { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace },
|
||||
|
@ -30,7 +30,7 @@ import { EventStore, eventStore } from "./event.store";
|
||||
import { getDetailsUrl, KubeObjectListLayout, KubeObjectListLayoutProps } from "../kube-object";
|
||||
import type { KubeEvent } from "../../api/endpoints/events.api";
|
||||
import type { TableSortCallbacks, TableSortParams, TableProps } from "../table";
|
||||
import type { IHeaderPlaceholders } from "../item-object-list";
|
||||
import type { HeaderCustomizer } from "../item-object-list";
|
||||
import { Tooltip } from "../tooltip";
|
||||
import { Link } from "react-router-dom";
|
||||
import { cssNames, IClassName, stopPropagation } from "../../utils";
|
||||
@ -112,19 +112,21 @@ export class Events extends React.Component<Props> {
|
||||
return this.items;
|
||||
}
|
||||
|
||||
customizeHeader = ({ info, title }: IHeaderPlaceholders) => {
|
||||
customizeHeader: HeaderCustomizer = ({ info, title, ...headerPlaceholders }) => {
|
||||
const { compact } = this.props;
|
||||
const { store, items, visibleItems } = this;
|
||||
const allEventsAreShown = visibleItems.length === items.length;
|
||||
|
||||
// handle "compact"-mode header
|
||||
if (compact) {
|
||||
if (allEventsAreShown) return title; // title == "Events"
|
||||
if (allEventsAreShown) {
|
||||
return { title };
|
||||
}
|
||||
|
||||
return <>
|
||||
{title}
|
||||
<span> ({visibleItems.length} of <Link to={eventsURL()}>{items.length}</Link>)</span>
|
||||
</>;
|
||||
return {
|
||||
title,
|
||||
info: <span> ({visibleItems.length} of <Link to={eventsURL()}>{items.length}</Link>)</span>,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
@ -136,7 +138,9 @@ export class Events extends React.Component<Props> {
|
||||
className="help-icon"
|
||||
tooltip={`Limited to ${store.limit}`}
|
||||
/>
|
||||
</>
|
||||
</>,
|
||||
title,
|
||||
...headerPlaceholders
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -32,12 +32,12 @@ export const searchUrlParam = createPageParam({
|
||||
defaultValue: "",
|
||||
});
|
||||
|
||||
interface Props extends InputProps {
|
||||
export interface SearchInputUrlProps extends InputProps {
|
||||
compact?: boolean; // show only search-icon when not focused
|
||||
}
|
||||
|
||||
@observer
|
||||
export class SearchInputUrl extends React.Component<Props> {
|
||||
export class SearchInputUrl extends React.Component<SearchInputUrlProps> {
|
||||
@observable inputVal = ""; // fix: use empty string on init to avoid react warnings
|
||||
|
||||
@disposeOnUnmount
|
||||
@ -62,7 +62,7 @@ export class SearchInputUrl extends React.Component<Props> {
|
||||
}
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
constructor(props: SearchInputUrlProps) {
|
||||
super(props);
|
||||
makeObservable(this);
|
||||
}
|
||||
|
@ -32,7 +32,7 @@ import { AddRemoveButtons, AddRemoveButtonsProps } from "../add-remove-buttons";
|
||||
import { NoItems } from "../no-items";
|
||||
import { Spinner } from "../spinner";
|
||||
import type { ItemObject, ItemStore } from "../../item.store";
|
||||
import { SearchInputUrl } from "../input";
|
||||
import { SearchInputUrlProps, SearchInputUrl } from "../input";
|
||||
import { Filter, FilterType, pageFilters } from "./page-filters.store";
|
||||
import { PageFiltersList } from "./page-filters-list";
|
||||
import { ThemeStore } from "../../theme.store";
|
||||
@ -41,21 +41,21 @@ import { MenuItem } from "../menu";
|
||||
import { Checkbox } from "../checkbox";
|
||||
import { UserStore } from "../../../common/user-store";
|
||||
import { namespaceStore } from "../+namespaces/namespace.store";
|
||||
import { KubeObjectStore } from "../../kube-object.store";
|
||||
import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter";
|
||||
|
||||
// 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[];
|
||||
|
||||
export interface IHeaderPlaceholders {
|
||||
title: ReactNode;
|
||||
search: ReactNode;
|
||||
filters: ReactNode;
|
||||
info: ReactNode;
|
||||
export interface HeaderPlaceholders {
|
||||
title?: ReactNode;
|
||||
searchProps?: SearchInputUrlProps;
|
||||
filters?: ReactNode;
|
||||
info?: ReactNode;
|
||||
}
|
||||
|
||||
export type HeaderCustomizer = (placeholders: HeaderPlaceholders) => HeaderPlaceholders;
|
||||
|
||||
export interface ItemListLayoutProps<T extends ItemObject = ItemObject> {
|
||||
tableId?: string;
|
||||
className: IClassName;
|
||||
@ -72,12 +72,11 @@ export interface ItemListLayoutProps<T extends ItemObject = ItemObject> {
|
||||
showHeader?: boolean;
|
||||
headerClassName?: IClassName;
|
||||
renderHeaderTitle?: ReactNode | ((parent: ItemListLayout) => ReactNode);
|
||||
customizeHeader?: (placeholders: IHeaderPlaceholders, content: ReactNode) => Partial<IHeaderPlaceholders> | ReactNode;
|
||||
customizeHeader?: HeaderCustomizer | HeaderCustomizer[];
|
||||
|
||||
// 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
|
||||
isConfigurable?: boolean;
|
||||
copyClassNameFromHeadCells?: boolean;
|
||||
sortingCallbacks?: { [sortBy: string]: TableSortCallback };
|
||||
@ -101,12 +100,13 @@ export interface ItemListLayoutProps<T extends ItemObject = ItemObject> {
|
||||
|
||||
const defaultProps: Partial<ItemListLayoutProps> = {
|
||||
showHeader: true,
|
||||
isSearchable: true,
|
||||
isSelectable: true,
|
||||
isConfigurable: false,
|
||||
copyClassNameFromHeadCells: true,
|
||||
preloadStores: true,
|
||||
dependentStores: [],
|
||||
searchFilters: [],
|
||||
customizeHeader: [],
|
||||
filterItems: [],
|
||||
hasDetailsView: true,
|
||||
onDetails: noop,
|
||||
@ -160,10 +160,10 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
|
||||
|
||||
private filterCallbacks: { [type: string]: ItemsFilter } = {
|
||||
[FilterType.SEARCH]: items => {
|
||||
const { searchFilters, isSearchable } = this.props;
|
||||
const { searchFilters } = this.props;
|
||||
const search = pageFilters.getValues(FilterType.SEARCH)[0] || "";
|
||||
|
||||
if (search && isSearchable && searchFilters) {
|
||||
if (search && searchFilters.length) {
|
||||
const normalizeText = (text: string) => String(text).toLowerCase();
|
||||
const searchTexts = [search].map(normalizeText);
|
||||
|
||||
@ -190,9 +190,9 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
|
||||
|
||||
@computed get filters() {
|
||||
let { activeFilters } = pageFilters;
|
||||
const { isSearchable, searchFilters } = this.props;
|
||||
const { searchFilters } = this.props;
|
||||
|
||||
if (!(isSearchable && searchFilters)) {
|
||||
if (searchFilters.length === 0) {
|
||||
activeFilters = activeFilters.filter(({ type }) => type !== FilterType.SEARCH);
|
||||
}
|
||||
|
||||
@ -348,18 +348,22 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
|
||||
return this.items.map(item => this.getRow(item.getId()));
|
||||
}
|
||||
|
||||
renderHeaderContent(placeholders: IHeaderPlaceholders): ReactNode {
|
||||
const { isSearchable, searchFilters } = this.props;
|
||||
const { title, filters, search, info } = placeholders;
|
||||
renderHeaderContent(placeholders: HeaderPlaceholders): ReactNode {
|
||||
const { searchFilters } = this.props;
|
||||
const { title, filters, searchProps, info } = placeholders;
|
||||
|
||||
return (
|
||||
<>
|
||||
{title}
|
||||
<div className="info-panel box grow">
|
||||
{info}
|
||||
</div>
|
||||
{
|
||||
info && (
|
||||
<div className="info-panel box grow">
|
||||
{info}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{filters}
|
||||
{isSearchable && searchFilters && search}
|
||||
{searchFilters.length > 0 && searchProps && <SearchInputUrl {...searchProps} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -385,28 +389,15 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
|
||||
return null;
|
||||
}
|
||||
|
||||
const showNamespaceSelectFilter = this.props.store instanceof KubeObjectStore && this.props.store.api.isNamespaced;
|
||||
const title = typeof renderHeaderTitle === "function" ? renderHeaderTitle(this) : renderHeaderTitle;
|
||||
const placeholders: IHeaderPlaceholders = {
|
||||
const customizeHeaders = [customizeHeader].flat().filter(Boolean);
|
||||
const initialPlaceholders: HeaderPlaceholders = {
|
||||
title: <h5 className="title">{title}</h5>,
|
||||
info: this.renderInfo(),
|
||||
filters: showNamespaceSelectFilter && <NamespaceSelectFilter />,
|
||||
search: <SearchInputUrl />,
|
||||
searchProps: {},
|
||||
};
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
const headerPlaceholders = customizeHeaders.reduce((prevPlaceholders, customizer) => customizer(prevPlaceholders), initialPlaceholders);
|
||||
const header = this.renderHeaderContent(headerPlaceholders);
|
||||
|
||||
return (
|
||||
<div className={cssNames("header flex gaps align-center", headerClassName)}>
|
||||
|
@ -30,6 +30,8 @@ import { KubeObjectMenu } from "./kube-object-menu";
|
||||
import { kubeSelectedUrlParam, showDetails } from "./kube-object-details";
|
||||
import { kubeWatchApi } from "../../api/kube-watch-api";
|
||||
import { clusterContext } from "../context";
|
||||
import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter";
|
||||
import { ResourceKindMap, ResourceNames } from "../../utils/rbac";
|
||||
|
||||
export interface KubeObjectListLayoutProps extends ItemListLayoutProps {
|
||||
store: KubeObjectStore;
|
||||
@ -66,7 +68,8 @@ export class KubeObjectListLayout extends React.Component<KubeObjectListLayoutPr
|
||||
}
|
||||
|
||||
render() {
|
||||
const { className, store, items = store.contextItems, ...layoutProps } = this.props;
|
||||
const { className, customizeHeader, store, items = store.contextItems, ...layoutProps } = this.props;
|
||||
const placeholderString = ResourceNames[ResourceKindMap[store.api.kind]] || store.api.kind;
|
||||
|
||||
return (
|
||||
<ItemListLayout
|
||||
@ -76,6 +79,22 @@ export class KubeObjectListLayout extends React.Component<KubeObjectListLayoutPr
|
||||
items={items}
|
||||
preloadStores={false} // loading handled in kubeWatchApi.subscribeStores()
|
||||
detailsItem={this.selectedItem}
|
||||
customizeHeader={[
|
||||
({ filters, searchProps, ...headerPlaceHolders }) => ({
|
||||
filters: (
|
||||
<>
|
||||
{filters}
|
||||
{store.api.isNamespaced && <NamespaceSelectFilter />}
|
||||
</>
|
||||
),
|
||||
searchProps: {
|
||||
...searchProps,
|
||||
placeholder: `Search ${placeholderString}...`,
|
||||
},
|
||||
...headerPlaceHolders,
|
||||
}),
|
||||
...[customizeHeader].flat(),
|
||||
]}
|
||||
renderItemMenu={(item: KubeObject) => <KubeObjectMenu object={item} />} // safe because we are dealing with KubeObjects here
|
||||
/>
|
||||
);
|
||||
|
@ -19,7 +19,7 @@
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import type { KubeResource } from "../../common/rbac";
|
||||
import { apiResourceRecord, KubeResource } from "../../common/rbac";
|
||||
|
||||
export const ResourceNames: Record<KubeResource, string> = {
|
||||
"namespaces": "Namespaces",
|
||||
@ -53,3 +53,8 @@ export const ResourceNames: Record<KubeResource, string> = {
|
||||
"clusterroles": "Cluster Roles",
|
||||
"serviceaccounts": "Service Accounts"
|
||||
};
|
||||
|
||||
export const ResourceKindMap: Record<string, KubeResource> = Object.fromEntries(
|
||||
Object.entries(apiResourceRecord)
|
||||
.map(([resource, { kind }]) => [kind, resource as KubeResource])
|
||||
);
|
||||
|
Loading…
Reference in New Issue
Block a user