1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

fix namespace filter to not jump after clicking

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2020-09-17 10:10:31 -04:00
parent 13b99afa21
commit 39cdb9e4ff
18 changed files with 191 additions and 118 deletions

View File

@ -10,7 +10,7 @@ import { namespaceStore } from "../+namespaces/namespace.store";
@observer
export class Apps extends React.Component {
static get tabRoutes(): TabRoute[] {
const query = namespaceStore.getContextParams();
const query = namespaceStore.contextParams;
return [
{
title: <Trans>Charts</Trans>,
@ -32,8 +32,8 @@ export class Apps extends React.Component {
return (
<TabLayout className="Apps" tabs={tabRoutes}>
<Switch>
{tabRoutes.map((route, index) => <Route key={index} {...route}/>)}
<Redirect to={tabRoutes[0].url}/>
{tabRoutes.map((route, index) => <Route key={index} {...route} />)}
<Redirect to={tabRoutes[0].url} />
</Switch>
</TabLayout>
)

View File

@ -20,7 +20,7 @@ export const clusterIssuersURL = buildURL("/clusterissuers");
@observer
export class Config extends React.Component {
static get tabRoutes(): TabRoute[] {
const query = namespaceStore.getContextParams()
const query = namespaceStore.contextParams
const routes: TabRoute[] = []
if (isAllowedResource("configmaps")) {
routes.push({
@ -70,8 +70,8 @@ export class Config extends React.Component {
return (
<TabLayout className="Config" tabs={tabRoutes}>
<Switch>
{tabRoutes.map((route, index) => <Route key={index} {...route}/>)}
<Redirect to={configURL({ query: namespaceStore.getContextParams() })}/>
{tabRoutes.map((route, index) => <Route key={index} {...route} />)}
<Redirect to={configURL({ query: namespaceStore.contextParams })} />
</Switch>
</TabLayout>
)

View File

@ -14,9 +14,22 @@
text-overflow: ellipsis;
overflow: hidden;
}
&__option {
&--is-selected {
.Icon {
visibility: visible!important;
}
}
.Icon {
visibility: hidden;
}
}
}
}
.NamespaceSelectMenu {
@include namespaceSelectCommon;
}
}

View File

@ -5,18 +5,16 @@ import { computed } from "mobx";
import { observer } from "mobx-react";
import { t, Trans } from "@lingui/macro";
import { Select, SelectOption, SelectProps } from "../select";
import { cssNames, noop } from "../../utils";
import ReactSelect, { ActionMeta, components, OptionTypeBase, ValueType } from "react-select"
import { autobind, cssNames, noop } from "../../utils";
import { Icon } from "../icon";
import { namespaceStore } from "./namespace.store";
import { _i18n } from "../../i18n";
import { FilterIcon } from "../item-object-list/filter-icon";
import { FilterType } from "../item-object-list/page-filters.store";
interface Props extends SelectProps {
showIcons?: boolean;
showClusterOption?: boolean; // show cluster option on the top (default: false)
clusterOptionLabel?: React.ReactNode; // label for cluster option (default: "Cluster")
customizeOptions?(nsOptions: SelectOption[]): SelectOption[];
}
const defaultProps: Partial<Props> = {
@ -44,12 +42,15 @@ export class NamespaceSelect extends React.Component<Props> {
}
@computed get options(): SelectOption[] {
const { customizeOptions, showClusterOption, clusterOptionLabel } = this.props;
let options: SelectOption[] = namespaceStore.items.map(ns => ({ value: ns.getName() }));
options = customizeOptions ? customizeOptions(options) : options;
const options: SelectOption[] = namespaceStore.items
.map(ns => ({ value: ns.getName() }))
.map(opt => ({ ...opt, label: this.formatOptionLabel(opt) }))
const { showClusterOption, clusterOptionLabel } = this.props;
if (showClusterOption) {
options.unshift({ value: null, label: clusterOptionLabel });
}
return options;
}
@ -58,7 +59,7 @@ export class NamespaceSelect extends React.Component<Props> {
const { value, label } = option;
return label || (
<>
{showIcons && <Icon small material="layers"/>}
{showIcons && <Icon small material="layers" />}
{value}
</>
);
@ -70,7 +71,7 @@ export class NamespaceSelect extends React.Component<Props> {
<Select
className={cssNames("NamespaceSelect", className)}
menuClass="NamespaceSelectMenu"
formatOptionLabel={this.formatOptionLabel}
autoConvertOptions={false}
options={this.options}
{...selectProps}
/>
@ -78,30 +79,53 @@ export class NamespaceSelect extends React.Component<Props> {
}
}
interface BasicNS {
label: React.ReactElement;
value: string;
}
@observer
export class NamespaceSelectFilter extends React.Component {
async componentDidMount() {
if (!namespaceStore.isLoaded) {
await namespaceStore.loadAll();
namespaceStore.contextNs.clear();
}
}
@autobind()
onChange(value: BasicNS, actionMeta: ActionMeta<BasicNS>) {
switch (actionMeta.action) {
case "select-option":
namespaceStore.contextNs.add(actionMeta.option.value)
break
case "clear":
namespaceStore.contextNs.clear()
break
case "deselect-option":
namespaceStore.contextNs.delete(actionMeta.option.value)
break
}
}
render() {
const { contextNs, hasContext, toggleContext } = namespaceStore;
let placeholder = <Trans>All namespaces</Trans>;
if (contextNs.length == 1) placeholder = <Trans>Namespace: {contextNs[0]}</Trans>
if (contextNs.length >= 2) placeholder = <Trans>Namespaces: {contextNs.join(", ")}</Trans>
return (
<NamespaceSelect
placeholder={placeholder}
<ReactSelect
placeholder={<Trans>Filter by namespace...</Trans>}
isMulti
closeMenuOnSelect={false}
isOptionSelected={() => false}
controlShouldRenderValue={false}
onChange={({ value: namespace }: SelectOption) => toggleContext(namespace)}
formatOptionLabel={({ value: namespace }: SelectOption) => {
const isSelected = hasContext(namespace);
return (
<div className="flex gaps align-center">
<FilterIcon type={FilterType.NAMESPACE}/>
<span>{namespace}</span>
{isSelected && <Icon small material="check" className="box right"/>}
</div>
)
hideSelectedOptions={false}
className={cssNames("Select", "NamespaceSelect", "theme-dark")}
classNamePrefix="Select"
components={{
Menu(props) {
return <components.Menu {...props} className="NamespaceSelectMenu" />
}
}}
onChange={this.onChange}
options={namespaceStore.Options}
value={namespaceStore.SelectedValues}
controlShouldRenderValue={false}
/>
)
}

View File

@ -1,45 +1,50 @@
import { action, observable, reaction } from "mobx";
import React from "react";
import { action, computed, observable, ObservableSet, reaction } from "mobx";
import { autobind, createStorage } from "../../utils";
import { KubeObjectStore } from "../../kube-object.store";
import { Namespace, namespacesApi } from "../../api/endpoints";
import { IQueryParams, navigation, setQueryParams } from "../../navigation";
import { apiManager } from "../../api/api-manager";
import { isAllowedResource } from "../../../common/rbac";
import { Icon } from "../icon";
@autobind()
export class NamespaceStore extends KubeObjectStore<Namespace> {
api = namespacesApi;
contextNs = observable.array<string>();
contextNs = observable.set<string>();
protected storage = createStorage<string[]>("context_ns", this.contextNs);
get initNamespaces() {
const fromUrl = navigation.searchParams.getAsArray("namespaces");
return fromUrl.length ? fromUrl : this.storage.get();
}
protected storage = createStorage<Set<string>>("context_ns", this.contextNs, {
parse(from: string) {
return new Set(JSON.parse(from))
},
stringify(from: Set<string>) {
return JSON.stringify([...from.values()])
}
});
constructor() {
super();
// restore context namespaces
const { initNamespaces: namespaces } = this;
this.setContext(namespaces);
this.updateUrl(namespaces);
const fromUrl = navigation.searchParams.getAsArray("namespaces")
const namespaces = fromUrl.length ? fromUrl : this.storage.get();
this.context = [...namespaces];
this.updateUrl(...namespaces);
// sync with local-storage & url-search-params
reaction(() => this.contextNs.toJS(), namespaces => {
this.storage.set(namespaces);
this.updateUrl(namespaces);
this.updateUrl(...namespaces.values());
});
}
getContextParams(): Partial<IQueryParams> {
return {
namespaces: this.contextNs
}
@computed
get contextParams(): IQueryParams {
const namespaces = [...this.contextNs.values()]
return { namespaces }
}
protected updateUrl(namespaces: string[]) {
protected updateUrl(...namespaces: string[]) {
setQueryParams({ namespaces }, { replace: true })
}
@ -68,18 +73,42 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
})
}
setContext(namespaces: string[]) {
@action
set context(namespaces: string[]) {
this.contextNs.replace(namespaces);
}
@action
hasContext(namespace: string | string[]) {
const context = Array.isArray(namespace) ? namespace : [namespace];
return context.every(namespace => this.contextNs.includes(namespace));
return context.every(namespace => this.contextNs.has(namespace));
}
@action
toggleContext(namespace: string) {
if (this.hasContext(namespace)) this.contextNs.remove(namespace);
else this.contextNs.push(namespace);
if (this.contextNs.has(namespace)) {
this.contextNs.delete(namespace)
} else {
this.contextNs.add(namespace)
}
}
@computed
get Options() {
return this.items.map(namespace => ({
value: namespace.getName(),
label: (
<div className="flex gaps align-center">
<span>{namespace.getName()}</span>
<Icon small material="check" className="box right" />
</div>
),
}))
}
@computed
get SelectedValues() {
return this.Options.filter(({ value }) => this.contextNs.has(value))
}
@action

View File

@ -20,7 +20,7 @@ interface Props extends RouteComponentProps<{}> {
@observer
export class Network extends React.Component<Props> {
static get tabRoutes(): TabRoute[] {
const query = namespaceStore.getContextParams()
const query = namespaceStore.contextParams
const routes: TabRoute[] = [];
if (isAllowedResource("services")) {
routes.push({
@ -62,8 +62,8 @@ export class Network extends React.Component<Props> {
return (
<TabLayout className="Network" tabs={tabRoutes}>
<Switch>
{tabRoutes.map((route, index) => <Route key={index} {...route}/>)}
<Redirect to={networkURL({ query: namespaceStore.getContextParams() })}/>
{tabRoutes.map((route, index) => <Route key={index} {...route} />)}
<Redirect to={networkURL({ query: namespaceStore.contextParams })} />
</Switch>
</TabLayout>
)

View File

@ -20,7 +20,7 @@ interface Props extends RouteComponentProps<{}> {
export class Storage extends React.Component<Props> {
static get tabRoutes() {
const tabRoutes: TabRoute[] = [];
const query = namespaceStore.getContextParams()
const query = namespaceStore.contextParams
tabRoutes.push({
title: <Trans>Persistent Volume Claims</Trans>,
@ -54,8 +54,8 @@ export class Storage extends React.Component<Props> {
return (
<TabLayout className="Storage" tabs={tabRoutes}>
<Switch>
{tabRoutes.map((route, index) => <Route key={index} {...route}/>)}
<Redirect to={storageURL({ query: namespaceStore.getContextParams() })}/>
{tabRoutes.map((route, index) => <Route key={index} {...route} />)}
<Redirect to={storageURL({ query: namespaceStore.contextParams })} />
</Switch>
</TabLayout>
)

View File

@ -20,7 +20,7 @@ interface Props extends RouteComponentProps<{}> {
export class UserManagement extends React.Component<Props> {
static get tabRoutes() {
const tabRoutes: TabRoute[] = [];
const query = namespaceStore.getContextParams()
const query = namespaceStore.contextParams
tabRoutes.push(
{
title: <Trans>Service Accounts</Trans>,
@ -57,8 +57,8 @@ export class UserManagement extends React.Component<Props> {
return (
<TabLayout className="UserManagement" tabs={tabRoutes}>
<Switch>
{tabRoutes.map((route, index) => <Route key={index} {...route}/>)}
<Redirect to={usersManagementURL({ query: namespaceStore.getContextParams() })}/>
{tabRoutes.map((route, index) => <Route key={index} {...route} />)}
<Redirect to={usersManagementURL({ query: namespaceStore.contextParams })} />
</Switch>
</TabLayout>
)

View File

@ -21,55 +21,55 @@ import { isAllowedResource } from "../../../common/rbac";
export class OverviewStatuses extends React.Component {
render() {
const { contextNs } = namespaceStore;
const pods = isAllowedResource("pods") ? podsStore.getAllByNs(contextNs) : [];
const deployments = isAllowedResource("deployments") ? deploymentStore.getAllByNs(contextNs) : [];
const statefulSets = isAllowedResource("statefulsets") ? statefulSetStore.getAllByNs(contextNs) : [];
const daemonSets = isAllowedResource("daemonsets") ? daemonSetStore.getAllByNs(contextNs) : [];
const jobs = isAllowedResource("jobs") ? jobStore.getAllByNs(contextNs) : [];
const cronJobs = isAllowedResource("cronjobs") ? cronJobStore.getAllByNs(contextNs) : [];
const pods = isAllowedResource("pods") ? podsStore.getAllByNs([...contextNs]) : [];
const deployments = isAllowedResource("deployments") ? deploymentStore.getAllByNs([...contextNs]) : [];
const statefulSets = isAllowedResource("statefulsets") ? statefulSetStore.getAllByNs([...contextNs]) : [];
const daemonSets = isAllowedResource("daemonsets") ? daemonSetStore.getAllByNs([...contextNs]) : [];
const jobs = isAllowedResource("jobs") ? jobStore.getAllByNs([...contextNs]) : [];
const cronJobs = isAllowedResource("cronjobs") ? cronJobStore.getAllByNs([...contextNs]) : [];
return (
<div className="OverviewStatuses">
<div className="header flex gaps align-center">
<h5 className="box grow"><Trans>Overview</Trans></h5>
<NamespaceSelectFilter/>
<NamespaceSelectFilter />
</div>
<PageFiltersList/>
<PageFiltersList />
<div className="workloads">
{isAllowedResource("pods") &&
<div className="workload">
<div className="title"><Link to={podsURL()}><Trans>Pods</Trans> ({pods.length})</Link></div>
<OverviewWorkloadStatus status={podsStore.getStatuses(pods)}/>
</div>
<div className="workload">
<div className="title"><Link to={podsURL()}><Trans>Pods</Trans> ({pods.length})</Link></div>
<OverviewWorkloadStatus status={podsStore.getStatuses(pods)} />
</div>
}
{isAllowedResource("deployments") &&
<div className="workload">
<div className="title"><Link to={deploymentsURL()}><Trans>Deployments</Trans> ({deployments.length})</Link></div>
<OverviewWorkloadStatus status={deploymentStore.getStatuses(deployments)}/>
</div>
<div className="workload">
<div className="title"><Link to={deploymentsURL()}><Trans>Deployments</Trans> ({deployments.length})</Link></div>
<OverviewWorkloadStatus status={deploymentStore.getStatuses(deployments)} />
</div>
}
{isAllowedResource("statefulsets") &&
<div className="workload">
<div className="title"><Link to={statefulSetsURL()}><Trans>StatefulSets</Trans> ({statefulSets.length})</Link></div>
<OverviewWorkloadStatus status={statefulSetStore.getStatuses(statefulSets)}/>
</div>
<div className="workload">
<div className="title"><Link to={statefulSetsURL()}><Trans>StatefulSets</Trans> ({statefulSets.length})</Link></div>
<OverviewWorkloadStatus status={statefulSetStore.getStatuses(statefulSets)} />
</div>
}
{isAllowedResource("daemonsets") &&
<div className="workload">
<div className="title"><Link to={daemonSetsURL()}><Trans>DaemonSets</Trans> ({daemonSets.length})</Link></div>
<OverviewWorkloadStatus status={daemonSetStore.getStatuses(daemonSets)}/>
</div>
<div className="workload">
<div className="title"><Link to={daemonSetsURL()}><Trans>DaemonSets</Trans> ({daemonSets.length})</Link></div>
<OverviewWorkloadStatus status={daemonSetStore.getStatuses(daemonSets)} />
</div>
}
{isAllowedResource("jobs") &&
<div className="workload">
<div className="title"><Link to={jobsURL()}><Trans>Jobs</Trans> ({jobs.length})</Link></div>
<OverviewWorkloadStatus status={jobStore.getStatuses(jobs)}/>
</div>
<div className="workload">
<div className="title"><Link to={jobsURL()}><Trans>Jobs</Trans> ({jobs.length})</Link></div>
<OverviewWorkloadStatus status={jobStore.getStatuses(jobs)} />
</div>
}
{isAllowedResource("cronjobs") &&
<div className="workload">
<div className="title"><Link to={cronJobsURL()}><Trans>CronJobs</Trans> ({cronJobs.length})</Link></div>
<OverviewWorkloadStatus status={cronJobStore.getStatuses(cronJobs)}/>
</div>
<div className="workload">
<div className="title"><Link to={cronJobsURL()}><Trans>CronJobs</Trans> ({cronJobs.length})</Link></div>
<OverviewWorkloadStatus status={cronJobStore.getStatuses(cronJobs)} />
</div>
}
</div>
</div>

View File

@ -23,7 +23,7 @@ interface Props extends RouteComponentProps {
@observer
export class Workloads extends React.Component<Props> {
static get tabRoutes(): TabRoute[] {
const query = namespaceStore.getContextParams();
const query = namespaceStore.contextParams;
const routes: TabRoute[] = [
{
title: <Trans>Overview</Trans>,
@ -88,8 +88,8 @@ export class Workloads extends React.Component<Props> {
return (
<TabLayout className="Workloads" tabs={tabRoutes}>
<Switch>
{tabRoutes.map((route, index) => <Route key={index} {...route}/>)}
<Redirect to={workloadsURL({ query: namespaceStore.getContextParams() })}/>
{tabRoutes.map((route, index) => <Route key={index} {...route} />)}
<Redirect to={workloadsURL({ query: namespaceStore.contextParams })} />
</Switch>
</TabLayout>
)

View File

@ -30,17 +30,17 @@ export class PageFiltersStore {
protected syncWithContextNamespace() {
const disposers = [
reaction(() => this.getValues(FilterType.NAMESPACE), filteredNs => {
if (filteredNs.length !== namespaceStore.contextNs.length) {
namespaceStore.setContext(filteredNs);
if (filteredNs.length !== namespaceStore.contextNs.size) {
namespaceStore.context = filteredNs;
}
}),
reaction(() => namespaceStore.contextNs.toJS(), contextNs => {
const filteredNs = this.getValues(FilterType.NAMESPACE);
const isChanged = contextNs.length !== filteredNs.length;
const isChanged = contextNs.size !== filteredNs.length;
if (isChanged) {
this.filters.replace([
...this.filters.filter(({ type }) => type !== FilterType.NAMESPACE),
...contextNs.map(ns => ({ type: FilterType.NAMESPACE, value: ns })),
...[...contextNs.values()].map(ns => ({ type: FilterType.NAMESPACE, value: ns })),
]);
}
}, {

View File

@ -73,7 +73,7 @@ export class Sidebar extends React.Component<Props> {
render() {
const { toggle, isPinned, className } = this.props;
const query = namespaceStore.getContextParams();
const query = namespaceStore.contextParams;
return (
<SidebarContext.Provider value={{ pinned: isPinned }}>
<div className={cssNames("Sidebar flex column", className, { pinned: isPinned })}>

View File

@ -3,7 +3,7 @@
html {
$menuBackgroundColor: $contentColor;
$menuSelectedOptionBgc: $layoutBackground;
$menuSelectedOptionBgc: $selectedBackground;
--select-menu-bgc: #{$menuBackgroundColor};
--select-menu-border-color: #{$halfGray};

View File

@ -1,7 +1,7 @@
import { useState } from "react";
import { createStorage, IStorageHelperOptions } from "../utils";
import { createStorage, StorageHelperOptions } from "../utils";
export function useStorage<T>(key: string, initialValue?: T, options?: IStorageHelperOptions) {
export function useStorage<T>(key: string, initialValue?: T, options?: StorageHelperOptions<T>) {
const storage = createStorage(key, initialValue, options);
const [storageValue, setStorageValue] = useState(storage.get());
const setValue = (value: T) => {
@ -9,4 +9,4 @@ export function useStorage<T>(key: string, initialValue?: T, options?: IStorageH
storage.set(value);
};
return [storageValue, setValue] as [T, (value: T) => void];
}
}

View File

@ -16,6 +16,7 @@
"borderFaintColor": "#373a3e",
"mainBackground": "#1e2124",
"contentColor": "#262b2f",
"selectedBackground": "#444444",
"layoutBackground": "#2e3136",
"layoutTabsBackground": "#252729",
"layoutTabsActiveColor": "#ffffff",

View File

@ -16,6 +16,7 @@
"borderFaintColor": "#dfdfdf",
"mainBackground": "#f1f1f1",
"contentColor": "#ffffff",
"selectedBackground": "#cccccc",
"layoutBackground": "#e8e8e8",
"layoutTabsBackground": "#f8f8f8",
"layoutTabsActiveColor": "#333333",
@ -52,7 +53,7 @@
"helmDescriptionPreColor": "#555555",
"colorSuccess": "#206923",
"colorOk": "#399c3d",
"colorInfo": "#2d71a4",
"colorInfo": "#deebff",
"colorError": "#ce3933",
"colorSoftError": "#e85555",
"colorWarning": "#ff9800",
@ -91,7 +92,7 @@
"drawerSubtitleBackground": "#f1f1f1",
"drawerItemNameColor": "#727272",
"drawerItemValueColor": "#555555",
"clusterMenuBackground": "#e8e8e8",
"clusterMenuBackground": "#cccccc",
"clusterMenuBorderColor": "#c9cfd3",
"clusterSettingsBackground": "#ffffff",
"addClusterIconColor": "#8d8d8d",

View File

@ -123,6 +123,7 @@ $iconActiveBackground: var(--iconActiveBackground);
$filterAreaBackground: var(--filterAreaBackground);
$selectOptionHoveredColor: var(--selectOptionHoveredColor);
$selectedBackground: var(--selectedBackground);
$lineProgressBackground: var(--lineProgressBackground);
$radioActiveBackground: var(--radioActiveBackground);
$menuActiveBackground: var(--menuActiveBackground);
$menuActiveBackground: var(--menuActiveBackground);

View File

@ -1,23 +1,27 @@
// Helper to work with browser's local/session storage api
export interface IStorageHelperOptions {
export interface StorageHelperOptions<T> {
addKeyPrefix?: boolean;
useSession?: boolean; // use `sessionStorage` instead of `localStorage`
parse?(from: string): T;
stringify?(from: T): string;
}
export function createStorage<T>(key: string, defaultValue?: T, options?: IStorageHelperOptions) {
export function createStorage<T>(key: string, defaultValue?: T, options?: StorageHelperOptions<T>) {
return new StorageHelper(key, defaultValue, options);
}
export class StorageHelper<T> {
static keyPrefix = "lens_";
static defaultOptions: IStorageHelperOptions = {
static defaultOptions: StorageHelperOptions<any> = {
addKeyPrefix: true,
useSession: false,
parse: JSON.parse,
stringify: JSON.stringify,
}
constructor(protected key: string, protected defaultValue?: T, protected options?: IStorageHelperOptions) {
constructor(protected key: string, protected defaultValue?: T, protected options?: StorageHelperOptions<T>) {
this.options = Object.assign({}, StorageHelper.defaultOptions, options);
if (this.options.addKeyPrefix) {
@ -34,16 +38,16 @@ export class StorageHelper<T> {
const strValue = this.storage.getItem(this.key);
if (strValue != null) {
try {
return JSON.parse(strValue);
return this.options.parse(strValue)
} catch (e) {
console.error(`Parsing json failed for pair: ${this.key}=${strValue}`)
console.error(`Parsing failed for pair: ${this.key}=${strValue}`)
}
}
return this.defaultValue;
}
set(value: T) {
this.storage.setItem(this.key, JSON.stringify(value));
this.storage.setItem(this.key, this.options.stringify(value));
return this;
}