From 6fdb2f0b58a0f6f1453393372da32d1912fe1d7a Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 31 Aug 2021 09:00:56 -0400 Subject: [PATCH] Support filtering catalog entities (#3647) * Support filtering catalog entities - This allows extensions and OpenLens to restrict which of the entities the sources have provied are "visible" to the rest of Lens Signed-off-by: Sebastian Malton * Fix lint Signed-off-by: Sebastian Malton * switch to filtering only for catalog view Signed-off-by: Sebastian Malton * Fix test Signed-off-by: Panu Horsmalahti * Add test to iter reduce Signed-off-by: Panu Horsmalahti Co-authored-by: Panu Horsmalahti --- src/common/utils/__tests__/iter.test.ts | 34 ++++++++++ src/common/utils/iter.ts | 19 ++++++ src/extensions/lens-renderer-extension.ts | 17 ++++- src/main/catalog/catalog-entity-registry.ts | 20 +++--- .../__tests__/catalog-entity-registry.test.ts | 67 ++++++++++++++++++- src/renderer/api/catalog-entity-registry.ts | 56 +++++++++++++--- .../+catalog/catalog-entity.store.tsx | 4 +- .../components/hotbar/hotbar-menu.tsx | 2 +- 8 files changed, 192 insertions(+), 27 deletions(-) create mode 100644 src/common/utils/__tests__/iter.test.ts diff --git a/src/common/utils/__tests__/iter.test.ts b/src/common/utils/__tests__/iter.test.ts new file mode 100644 index 0000000000..fd4677df0d --- /dev/null +++ b/src/common/utils/__tests__/iter.test.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { reduce } from "../iter"; + +describe("iter", () => { + describe("reduce", () => { + it("can reduce a value", () => { + expect(reduce([1, 2, 3], (acc: number[], current: number) => [current, ...acc], [0])).toEqual([3, 2, 1, 0]); + }); + + it("can reduce an empty iterable", () => { + expect(reduce([], (acc: number[], current: number) => [acc[0] + current], [])).toEqual([]); + }); + }); +}); diff --git a/src/common/utils/iter.ts b/src/common/utils/iter.ts index 59fce41829..65b0937726 100644 --- a/src/common/utils/iter.ts +++ b/src/common/utils/iter.ts @@ -156,3 +156,22 @@ export function find(src: Iterable, match: (i: T) => any): T | undefined { return void 0; } + +/** + * Iterate over `src` calling `reducer` with the previous produced value and the current + * yielded value until `src` is exausted. Then return the final value. + * @param src The value to iterate over + * @param reducer A function for producing the next item from an accumilation and the current item + * @param initial The initial value for the iteration + */ +export function reduce(src: Iterable, reducer: (acc: Iterable, cur: T) => Iterable, initial: Iterable): Iterable; + +export function reduce(src: Iterable, reducer: (acc: R, cur: T) => R, initial: R): R { + let acc = initial; + + for (const item of src) { + acc = reducer(acc, item); + } + + return acc; +} diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index 0ae8a44323..b56ffb3d7e 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -21,9 +21,11 @@ import type * as registries from "./registries"; import type { Cluster } from "../main/cluster"; -import { LensExtension } from "./lens-extension"; +import { Disposers, LensExtension } from "./lens-extension"; import { getExtensionPageUrl } from "./registries/page-registry"; import type { CatalogEntity } from "../common/catalog"; +import type { Disposer } from "../common/utils"; +import { catalogEntityRegistry, EntityFilter } from "../renderer/api/catalog-entity-registry"; export class LensRendererExtension extends LensExtension { globalPages: registries.PageRegistration[] = []; @@ -59,4 +61,17 @@ export class LensRendererExtension extends LensExtension { async isEnabledForCluster(cluster: Cluster): Promise { return (void cluster) || true; } + + /** + * Add a filtering function for the catalog. This will be removed if the extension is disabled. + * @param fn The function which should return a truthy value for those entities which should be kepted + * @returns A function to clean up the filter + */ + addCatalogFilter(fn: EntityFilter): Disposer { + const dispose = catalogEntityRegistry.addCatalogFilter(fn); + + this[Disposers].push(dispose); + + return dispose; + } } diff --git a/src/main/catalog/catalog-entity-registry.ts b/src/main/catalog/catalog-entity-registry.ts index f2188132eb..e9e441ae2b 100644 --- a/src/main/catalog/catalog-entity-registry.ts +++ b/src/main/catalog/catalog-entity-registry.ts @@ -43,24 +43,20 @@ export class CatalogEntityRegistry { } @computed get items(): CatalogEntity[] { - const allItems = Array.from(iter.flatMap(this.sources.values(), source => source.get())); - - return allItems.filter((entity) => this.categoryRegistry.getCategoryForEntity(entity) !== undefined); + return Array.from( + iter.filter( + iter.flatMap(this.sources.values(), source => source.get()), + entity => this.categoryRegistry.getCategoryForEntity(entity) + ) + ); } getById(id: string): T | undefined { - const item = this.items.find((entity) => entity.metadata.uid === id); - - if (item) return item as T; - - - return undefined; + return this.items.find((entity) => entity.metadata.uid === id) as T | undefined; } getItemsForApiKind(apiVersion: string, kind: string): T[] { - const items = this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind); - - return items as T[]; + return this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind) as T[]; } getItemsByEntityClass({ apiVersion, kind }: CatalogEntityKindData): T[] { diff --git a/src/renderer/api/__tests__/catalog-entity-registry.test.ts b/src/renderer/api/__tests__/catalog-entity-registry.test.ts index 4c3bcb48c3..527cb4d328 100644 --- a/src/renderer/api/__tests__/catalog-entity-registry.test.ts +++ b/src/renderer/api/__tests__/catalog-entity-registry.test.ts @@ -23,7 +23,8 @@ import { CatalogEntityRegistry } from "../catalog-entity-registry"; import "../../../common/catalog-entities"; import { catalogCategoryRegistry } from "../../../common/catalog/catalog-category-registry"; import { CatalogCategory, CatalogEntityData, CatalogEntityKindData } from "../catalog-entity"; -import { WebLink } from "../../../common/catalog-entities"; +import { KubernetesCluster, WebLink } from "../../../common/catalog-entities"; +import { observable } from "mobx"; class TestCatalogEntityRegistry extends CatalogEntityRegistry { replaceItems(items: Array) { @@ -51,6 +52,49 @@ class FooBarCategory extends CatalogCategory { } }; } +const entity = new WebLink({ + metadata: { + uid: "test", + name: "test-link", + source: "test", + labels: {} + }, + spec: { + url: "https://k8slens.dev" + }, + status: { + phase: "available" + } +}); +const entity2 = new WebLink({ + metadata: { + uid: "test2", + name: "test-link", + source: "test", + labels: {} + }, + spec: { + url: "https://k8slens.dev" + }, + status: { + phase: "available" + } +}); +const entitykc = new KubernetesCluster({ + metadata: { + uid: "test3", + name: "test-link", + source: "test", + labels: {} + }, + spec: { + kubeconfigPath: "", + kubeconfigContext: "", + }, + status: { + phase: "connected" + } +}); describe("CatalogEntityRegistry", () => { describe("updateItems", () => { @@ -250,4 +294,25 @@ describe("CatalogEntityRegistry", () => { catalogCategoryRegistry.add(new FooBarCategory()); expect(catalog.items.length).toBe(1); }); + + it("does not return items that are filtered out", () => { + const source = observable.array([entity, entity2, entitykc]); + const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry); + + catalog.replaceItems(source); + + expect(catalog.items.length).toBe(3); + expect(catalog.filteredItems.length).toBe(3); + + const d = catalog.addCatalogFilter(entity => entity.kind === KubernetesCluster.kind); + + expect(catalog.items.length).toBe(3); + expect(catalog.filteredItems.length).toBe(1); + + // Remove filter + d(); + + expect(catalog.items.length).toBe(3); + expect(catalog.filteredItems.length).toBe(3); + }); }); diff --git a/src/renderer/api/catalog-entity-registry.ts b/src/renderer/api/catalog-entity-registry.ts index a067b520d7..7b09ff273b 100644 --- a/src/renderer/api/catalog-entity-registry.ts +++ b/src/renderer/api/catalog-entity-registry.ts @@ -25,10 +25,17 @@ import { CatalogCategory, CatalogEntity, CatalogEntityData, catalogCategoryRegis import "../../common/catalog-entities"; import type { Cluster } from "../../main/cluster"; import { ClusterStore } from "../../common/cluster-store"; +import { Disposer, iter } from "../utils"; +import { once } from "lodash"; + +export type EntityFilter = (entity: CatalogEntity) => any; export class CatalogEntityRegistry { @observable.ref activeEntity: CatalogEntity; protected _entities = observable.map([], { deep: true }); + protected filters = observable.set([], { + deep: false, + }); /** * Buffer for keeping entities that don't yet have CatalogCategory synced @@ -95,27 +102,56 @@ export class CatalogEntityRegistry { return Array.from(this._entities.values()); } - @computed get entities(): Map { - this.processRawEntities(); + @computed get filteredItems() { + return Array.from( + iter.reduce( + this.filters, + iter.filter, + this.items, + ) + ); + } - return this._entities; + @computed get entities(): Map { + return new Map( + this.items.map(entity => [entity.getId(), entity]) + ); + } + + @computed get filteredEntities(): Map { + return new Map( + this.filteredItems.map(entity => [entity.getId(), entity]) + ); } getById(id: string) { return this.entities.get(id) as T; } - getItemsForApiKind(apiVersion: string, kind: string): T[] { - const items = this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind); + getItemsForApiKind(apiVersion: string, kind: string, { filtered = false } = {}): T[] { + const byApiKind = (item: CatalogEntity) => item.apiVersion === apiVersion && item.kind === kind; + const entities = filtered ? this.filteredItems : this.items; - return items as T[]; + return entities.filter(byApiKind) as T[]; } - getItemsForCategory(category: CatalogCategory): T[] { - const supportedVersions = category.spec.versions.map((v) => `${category.spec.group}/${v.name}`); - const items = this.items.filter((item) => supportedVersions.includes(item.apiVersion) && item.kind === category.spec.names.kind); + getItemsForCategory(category: CatalogCategory, { filtered = false } = {}): T[] { + const supportedVersions = new Set(category.spec.versions.map((v) => `${category.spec.group}/${v.name}`)); + const byApiVersionKind = (item: CatalogEntity) => supportedVersions.has(item.apiVersion) && item.kind === category.spec.names.kind; + const entities = filtered ? this.filteredItems : this.items; - return items as T[]; + return entities.filter(byApiVersionKind) as T[]; + } + + /** + * Add a new filter to the set of item filters + * @param fn The function that should return a truthy value if that entity should be sent currently "active" + * @returns A function to remove that filter + */ + addCatalogFilter(fn: EntityFilter): Disposer { + this.filters.add(fn); + + return once(() => void this.filters.delete(fn)); } } diff --git a/src/renderer/components/+catalog/catalog-entity.store.tsx b/src/renderer/components/+catalog/catalog-entity.store.tsx index c5bb96e5f2..05f317b7b1 100644 --- a/src/renderer/components/+catalog/catalog-entity.store.tsx +++ b/src/renderer/components/+catalog/catalog-entity.store.tsx @@ -129,10 +129,10 @@ export class CatalogEntityStore extends ItemStore new CatalogEntityItem(entity)); + return catalogEntityRegistry.filteredItems.map(entity => new CatalogEntityItem(entity)); } - return catalogEntityRegistry.getItemsForCategory(this.activeCategory).map(entity => new CatalogEntityItem(entity)); + return catalogEntityRegistry.getItemsForCategory(this.activeCategory, { filtered: true }).map(entity => new CatalogEntityItem(entity)); } @computed get selectedItem() { diff --git a/src/renderer/components/hotbar/hotbar-menu.tsx b/src/renderer/components/hotbar/hotbar-menu.tsx index 5df1f48049..cc2c1903c0 100644 --- a/src/renderer/components/hotbar/hotbar-menu.tsx +++ b/src/renderer/components/hotbar/hotbar-menu.tsx @@ -52,7 +52,7 @@ export class HotbarMenu extends React.Component { return null; } - return item ? catalogEntityRegistry.items.find((entity) => entity.metadata.uid === item.entity.uid) : null; + return catalogEntityRegistry.getById(item?.entity.uid) ?? null; } onDragEnd(result: DropResult) {