From 0719293b11945e7ecce2f6ef646ab89ad5c92a6d Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Mon, 13 Feb 2023 09:34:03 +0300 Subject: [PATCH] Namespace details tree view (#7080) * Initial tests for Signed-off-by: Alex Andreev * Introduce Signed-off-by: Alex Andreev * Render namespace children Signed-off-by: Alex Andreev * Render a child subnamespace Signed-off-by: Alex Andreev * Remove unused lodash import Signed-off-by: Alex Andreev * Render subnamespace badge after name Signed-off-by: Alex Andreev * Testing rendering 2 levels deep Signed-off-by: Alex Andreev * Add tree view to namespace details Signed-off-by: Alex Andreev * Expand all nodes by default Signed-off-by: Alex Andreev * Add links to the tree items Signed-off-by: Alex Andreev * Initial label styling Signed-off-by: Alex Andreev * Label and group styling Signed-off-by: Alex Andreev * Remove fontSize attr from SvgIcon Signed-off-by: Alex Andreev * Styling subnamespace badge Signed-off-by: Alex Andreev * Expand and collapse tree nodes Signed-off-by: Alex Andreev * Testing clicking plus icon Signed-off-by: Alex Andreev * Restyling subnamespace badge Signed-off-by: Alex Andreev * Adding tooltip for subnamespace badge Signed-off-by: Alex Andreev * Linter fixes Signed-off-by: Alex Andreev * Fix linter harder Signed-off-by: Alex Andreev * Replace CloseIcon with semi-transparent MinusIcon Signed-off-by: Alex Andreev * Styling TreeView inside scss module Signed-off-by: Alex Andreev * Move isSubnamespace method inside API Signed-off-by: Alex Andreev * Extract nodeId to avoid repeating Signed-off-by: Alex Andreev * Rename Icon components Signed-off-by: Alex Andreev * Clean up tests from boilderplate Signed-off-by: Alex Andreev * Subnamespace badge style fixes Signed-off-by: Alex Andreev * Linter fix Signed-off-by: Alex Andreev * Use font-size: x-small instead of rem units Signed-off-by: Alex Andreev * Move subnamespace badge show check to parent Signed-off-by: Alex Andreev * Use prevDefault util Signed-off-by: Alex Andreev * Refactor: move tree build logic to store Signed-off-by: Alex Andreev * Linter fixes Signed-off-by: Alex Andreev * Refactor hierarchicalNamespacesInjectable Signed-off-by: Alex Andreev * Add tests for getNamespaceTree() function Signed-off-by: Alex Andreev * Use Subnamespace badge in namespace list (#7132) Signed-off-by: Alex Andreev --------- Signed-off-by: Alex Andreev --- .../common/k8s-api/endpoints/namespace.api.ts | 14 + .../namespace-tree-view.test.tsx.snap | 937 ++++++++++++++++++ .../hierarchical-namespaces.injectable.ts | 18 + .../+namespaces/namespace-details.tsx | 9 + .../+namespaces/namespace-store.test.ts | 205 ++++ .../namespace-tree-view.module.scss | 14 + .../+namespaces/namespace-tree-view.test.tsx | 334 +++++++ .../+namespaces/namespace-tree-view.tsx | 106 ++ .../components/+namespaces/namespaces.scss | 4 + .../renderer/components/+namespaces/route.tsx | 8 +- .../renderer/components/+namespaces/store.ts | 16 + .../subnamespace-badge.module.scss | 11 + .../+namespaces/subnamespace-badge.tsx | 31 + 13 files changed, 1706 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/renderer/components/+namespaces/__snapshots__/namespace-tree-view.test.tsx.snap create mode 100644 packages/core/src/renderer/components/+namespaces/hierarchical-namespaces.injectable.ts create mode 100644 packages/core/src/renderer/components/+namespaces/namespace-store.test.ts create mode 100644 packages/core/src/renderer/components/+namespaces/namespace-tree-view.module.scss create mode 100644 packages/core/src/renderer/components/+namespaces/namespace-tree-view.test.tsx create mode 100644 packages/core/src/renderer/components/+namespaces/namespace-tree-view.tsx create mode 100644 packages/core/src/renderer/components/+namespaces/subnamespace-badge.module.scss create mode 100644 packages/core/src/renderer/components/+namespaces/subnamespace-badge.tsx diff --git a/packages/core/src/common/k8s-api/endpoints/namespace.api.ts b/packages/core/src/common/k8s-api/endpoints/namespace.api.ts index 3de24d3df9..04f4501313 100644 --- a/packages/core/src/common/k8s-api/endpoints/namespace.api.ts +++ b/packages/core/src/common/k8s-api/endpoints/namespace.api.ts @@ -33,6 +33,20 @@ export class Namespace extends KubeObject< getStatus() { return this.status?.phase ?? "-"; } + + isSubnamespace() { + return this.getAnnotations().find(annotation => annotation.includes("hnc.x-k8s.io/subnamespace-of")); + } + + isChildOf(parentName: string) { + return this.getLabels().find(label => label === `${parentName}.tree.hnc.x-k8s.io/depth=1`); + } + + isControlledByHNC() { + const hierarchicalNamesaceControllerLabel = "hnc.x-k8s.io/included-namespace=true"; + + return this.getLabels().find(label => label === hierarchicalNamesaceControllerLabel); + } } export class NamespaceApi extends KubeApi { diff --git a/packages/core/src/renderer/components/+namespaces/__snapshots__/namespace-tree-view.test.tsx.snap b/packages/core/src/renderer/components/+namespaces/__snapshots__/namespace-tree-view.test.tsx.snap new file mode 100644 index 0000000000..206c68cd27 --- /dev/null +++ b/packages/core/src/renderer/components/+namespaces/__snapshots__/namespace-tree-view.test.tsx.snap @@ -0,0 +1,937 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` collapses item by clicking minus button 1`] = ` + +
+
+
+ Tree View +
+
    +
  • +
    +
    + +
    +
    + levels-deep + +
    +
    +
      +
      +
      +
    • +
      +
      +
      + +
      +
      +
      + level-deep-child-a + +
      +
      +
    • + +
      +
      +
    +
  • +
+
+
+ +`; + +exports[` expands item by clicking plus button 1`] = ` + +
+
+
+ Tree View +
+
    +
  • +
    +
    + +
    +
    + levels-deep + +
    +
    +
      +
      +
      +
    • +
      +
      +
      + +
      +
      +
      + level-deep-child-a + +
      +
      +
    • +
    • +
      +
      + +
      +
      + level-deep-child-b + +
      +
      +
        +
        +
        +
      • +
        +
        +
        + +
        +
        +
        + level-deep-subchild-a + +
        +
        +
      • +
        +
        +
      +
    • +
      +
      +
    +
  • +
+
+
+ +`; + +exports[` renders 2 levels deep 1`] = ` + +
+
+
+ Tree View +
+
    +
  • +
    +
    + +
    +
    + levels-deep + +
    +
    +
      +
      +
      +
    • +
      +
      +
      + +
      +
      +
      + level-deep-child-a + +
      +
      +
    • +
    • +
      +
      + +
      +
      + level-deep-child-b + +
      +
      +
        +
        +
        +
      • +
        +
        +
        + +
        +
        +
        + level-deep-subchild-a + +
        +
        +
      • +
        +
        +
      +
    • +
      +
      +
    +
  • +
+
+
+ +`; + +exports[` renders namespace with 2 children namespaces 1`] = ` + +
+
+
+ Tree View +
+
    +
  • +
    +
    + +
    +
    + acme-org + +
    +
    +
      +
      +
      +
    • +
      +
      +
      + +
      +
      +
      + team-a + +
      +
      +
    • +
    • +
      +
      +
      + +
      +
      +
      + team-b + +
      +
      +
    • +
      +
      +
    +
  • +
+
+
+ +`; + +exports[` renders namespace with children namespaces and a subnamespace 1`] = ` + +
+
+
+ Tree View +
+
    +
  • +
    +
    + +
    +
    + org-a + +
    +
    +
      +
      +
      +
    • +
      +
      +
      + +
      +
      +
      + team-c + +
      +
      +
    • +
    • +
      +
      +
      + +
      +
      +
      + service-1 + + + S + +
      +
      +
    • +
      +
      +
    +
  • +
+
+
+ +`; + +exports[` renders one namespace without children 1`] = ` + +
+
+
+ Tree View +
+
    +
  • +
    +
    +
    + +
    +
    +
    + single-root + +
    +
    +
  • +
+
+
+ +`; diff --git a/packages/core/src/renderer/components/+namespaces/hierarchical-namespaces.injectable.ts b/packages/core/src/renderer/components/+namespaces/hierarchical-namespaces.injectable.ts new file mode 100644 index 0000000000..5c473f29dc --- /dev/null +++ b/packages/core/src/renderer/components/+namespaces/hierarchical-namespaces.injectable.ts @@ -0,0 +1,18 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import { getInjectable } from "@ogre-tools/injectable"; +import namespaceStoreInjectable from "./store.injectable"; + +const hierarchicalNamespacesInjectable = getInjectable({ + id: "hierarchical-namespaces", + + instantiate: (di) => { + const namespaceStore = di.inject(namespaceStoreInjectable); + + return namespaceStore.items.filter(item => item.isControlledByHNC()); + }, +}); + +export default hierarchicalNamespacesInjectable; diff --git a/packages/core/src/renderer/components/+namespaces/namespace-details.tsx b/packages/core/src/renderer/components/+namespaces/namespace-details.tsx index 33712e9b2b..d38cab94ba 100644 --- a/packages/core/src/renderer/components/+namespaces/namespace-details.tsx +++ b/packages/core/src/renderer/components/+namespaces/namespace-details.tsx @@ -26,6 +26,9 @@ import limitRangeStoreInjectable from "../+config-limit-ranges/store.injectable" import resourceQuotaStoreInjectable from "../+config-resource-quotas/store.injectable"; import type { Logger } from "../../../common/logger"; import loggerInjectable from "../../../common/logger.injectable"; +import { NamespaceTreeView } from "./namespace-tree-view"; +import namespaceStoreInjectable from "./store.injectable"; +import type { NamespaceStore } from "./store"; export interface NamespaceDetailsProps extends KubeObjectDetailsProps { } @@ -35,6 +38,7 @@ interface Dependencies { getDetailsUrl: GetDetailsUrl; resourceQuotaStore: ResourceQuotaStore; limitRangeStore: LimitRangeStore; + namespaceStore: NamespaceStore; logger: Logger; } @@ -103,6 +107,10 @@ class NonInjectedNamespaceDetails extends React.Component ))} + + {namespace.isControlledByHNC() && ( + + )} ); } @@ -115,6 +123,7 @@ export const NamespaceDetails = withInjectables, annotations?: Record): Namespace { + return new Namespace({ + apiVersion: "v1", + kind: "Namespace", + metadata: { + name, + resourceVersion: "1", + selfLink: `/api/v1/namespaces/${name}`, + uid: `${name}`, + labels: { + ...labels, + }, + annotations: { + ...annotations, + }, + }, + }); +} + +const singleRoot = createNamespace("single-root", { + "hnc.x-k8s.io/included-namespace": "true", +}); + +const acmeGroup = createNamespace("acme-org", { + "hnc.x-k8s.io/included-namespace": "true", +}); + +const orgA = createNamespace("org-a", { + "hnc.x-k8s.io/included-namespace": "true", +}); + +const teamA = createNamespace("team-a", { + "hnc.x-k8s.io/included-namespace": "true", + "acme-org.tree.hnc.x-k8s.io/depth": "1", + "kubernetes.io/metadata.name": "team-a", + "team-a.tree.hnc.x-k8s.io/depth": "0", +}); + +const teamB = createNamespace("team-b", { + "hnc.x-k8s.io/included-namespace": "true", + "acme-org.tree.hnc.x-k8s.io/depth": "1", + "kubernetes.io/metadata.name": "team-b", + "team-b.tree.hnc.x-k8s.io/depth": "0", +}); + +const teamC = createNamespace("team-c", { + "hnc.x-k8s.io/included-namespace": "true", + "org-a.tree.hnc.x-k8s.io/depth": "1", + "kubernetes.io/metadata.name": "team-c", + "team-c.tree.hnc.x-k8s.io/depth": "0", +}); + +const service1 = createNamespace("service-1", { + "hnc.x-k8s.io/included-namespace": "true", + "org-a.tree.hnc.x-k8s.io/depth": "1", + "kubernetes.io/metadata.name": "team-c", + "service-1.tree.hnc.x-k8s.io/depth": "0", +}, { + "hnc.x-k8s.io/subnamespace-of": "org-a", +}); + +const levelsDeep = createNamespace("levels-deep", { + "hnc.x-k8s.io/included-namespace": "true", +}); + +const levelDeepChildA = createNamespace("level-deep-child-a", { + "hnc.x-k8s.io/included-namespace": "true", + "levels-deep.tree.hnc.x-k8s.io/depth": "1", + "level-deep-child-a.tree.hnc.x-k8s.io/depth": "0", +}); + +const levelDeepChildB = createNamespace("level-deep-child-b", { + "hnc.x-k8s.io/included-namespace": "true", + "levels-deep.tree.hnc.x-k8s.io/depth": "1", + "level-deep-child-b.tree.hnc.x-k8s.io/depth": "0", +}); + +const levelDeepSubChildA = createNamespace("level-deep-subchild-a", { + "hnc.x-k8s.io/included-namespace": "true", + "levels-deep.tree.hnc.x-k8s.io/depth": "2", + "level-deep-child-b.tree.hnc.x-k8s.io/depth": "1", + "level-deep-subchild-a.tree.hnc.x-k8s.io/depth": "0", +}); + + +describe("NamespaceStore", () => { + let di: DiContainer; + let namespaceStore: NamespaceStore; + + beforeEach(async () => { + di = getDiForUnitTesting({ doGeneralOverrides: true }); + + di.override(directoryForUserDataInjectable, () => "/some-user-store-path"); + di.override(directoryForKubeConfigsInjectable, () => "/some-kube-configs"); + di.override(storesAndApisCanBeCreatedInjectable, () => true); + + const createCluster = di.inject(createClusterInjectable); + + di.override(hostedClusterInjectable, () => createCluster({ + contextName: "some-context-name", + id: "some-cluster-id", + kubeConfigPath: "/some-path-to-a-kubeconfig", + }, { + clusterServerUrl: "https://localhost:8080", + })); + + namespaceStore = di.inject(namespaceStoreInjectable); + + namespaceStore.items = observable.array([ + acmeGroup, + orgA, + teamA, + teamB, + teamC, + service1, + levelsDeep, + levelDeepChildA, + levelDeepChildB, + levelDeepSubChildA, + ]); + }); + + it("returns tree for single node", () => { + const tree = namespaceStore.getNamespaceTree(service1); + + expect(tree).toEqual({ + id: "service-1", + namespace: service1, + children: [], + }); + }); + + it("returns tree for namespace not listed in store", () => { + const tree = namespaceStore.getNamespaceTree(singleRoot); + + expect(tree).toEqual({ + id: "single-root", + namespace: singleRoot, + children: [], + }); + }); + + it("return tree for namespace with children", () => { + const tree = namespaceStore.getNamespaceTree(acmeGroup); + + expect(tree).toEqual({ + id: "acme-org", + namespace: acmeGroup, + children: [ + { + id: "team-a", + namespace: teamA, + children: [], + }, + { + id: "team-b", + namespace: teamB, + children: [], + }, + ], + }); + }); + + it("return tree for namespace with deep nested children", () => { + const tree = namespaceStore.getNamespaceTree(levelsDeep); + + expect(tree).toEqual({ + id: "levels-deep", + namespace: levelsDeep, + children: [ + { + id: "level-deep-child-a", + namespace: levelDeepChildA, + children: [], + }, + { + id: "level-deep-child-b", + namespace: levelDeepChildB, + children: [{ + id: "level-deep-subchild-a", + namespace: levelDeepSubChildA, + children: [], + }], + }, + ], + }); + }); +}); diff --git a/packages/core/src/renderer/components/+namespaces/namespace-tree-view.module.scss b/packages/core/src/renderer/components/+namespaces/namespace-tree-view.module.scss new file mode 100644 index 0000000000..b5bc104c03 --- /dev/null +++ b/packages/core/src/renderer/components/+namespaces/namespace-tree-view.module.scss @@ -0,0 +1,14 @@ +.TreeView { + .group { + margin-inline-start: var(--margin); + padding-inline-start: calc(var(--padding) * 2); + border-inline-start: 1px dashed var(--borderColor); + } + + .label { + font-size: inherit; + line-height: 1.8; + cursor: default; + background-color: transparent!important; + } +} \ No newline at end of file diff --git a/packages/core/src/renderer/components/+namespaces/namespace-tree-view.test.tsx b/packages/core/src/renderer/components/+namespaces/namespace-tree-view.test.tsx new file mode 100644 index 0000000000..783466ffe3 --- /dev/null +++ b/packages/core/src/renderer/components/+namespaces/namespace-tree-view.test.tsx @@ -0,0 +1,334 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import type { DiContainer } from "@ogre-tools/injectable"; +import { fireEvent } from "@testing-library/react"; +import React from "react"; +import { Namespace } from "../../../common/k8s-api/endpoints"; +import { getDiForUnitTesting } from "../../getDiForUnitTesting"; +import type { DiRender } from "../test-utils/renderFor"; +import { renderFor } from "../test-utils/renderFor"; +import hierarchicalNamespacesInjectable from "./hierarchical-namespaces.injectable"; +import { NamespaceTreeView } from "./namespace-tree-view"; +import type { NamespaceTree } from "./store"; + +jest.mock("react-router-dom", () => ({ + Link: ({ children }: { children: React.ReactNode }) => children, +})); + +function createNamespace(name: string, labels?: Record, annotations?: Record): Namespace { + return new Namespace({ + apiVersion: "v1", + kind: "Namespace", + metadata: { + name, + resourceVersion: "1", + selfLink: `/api/v1/namespaces/${name}`, + uid: `${name}`, + labels: { + ...labels, + }, + annotations: { + ...annotations, + }, + }, + }); +} + +const singleRoot = createNamespace("single-root", { + "hnc.x-k8s.io/included-namespace": "true", +}); + +const acmeGroup = createNamespace("acme-org", { + "hnc.x-k8s.io/included-namespace": "true", +}); + +const orgA = createNamespace("org-a", { + "hnc.x-k8s.io/included-namespace": "true", +}); + +const teamA = createNamespace("team-a", { + "hnc.x-k8s.io/included-namespace": "true", + "acme-org.tree.hnc.x-k8s.io/depth": "1", + "kubernetes.io/metadata.name": "team-a", + "team-a.tree.hnc.x-k8s.io/depth": "0", +}); + +const teamB = createNamespace("team-b", { + "hnc.x-k8s.io/included-namespace": "true", + "acme-org.tree.hnc.x-k8s.io/depth": "1", + "kubernetes.io/metadata.name": "team-b", + "team-b.tree.hnc.x-k8s.io/depth": "0", +}); + +const teamC = createNamespace("team-c", { + "hnc.x-k8s.io/included-namespace": "true", + "org-a.tree.hnc.x-k8s.io/depth": "1", + "kubernetes.io/metadata.name": "team-c", + "team-c.tree.hnc.x-k8s.io/depth": "0", +}); + +const service1 = createNamespace("service-1", { + "hnc.x-k8s.io/included-namespace": "true", + "org-a.tree.hnc.x-k8s.io/depth": "1", + "kubernetes.io/metadata.name": "team-c", + "service-1.tree.hnc.x-k8s.io/depth": "0", +}, { + "hnc.x-k8s.io/subnamespace-of": "org-a", +}); + +const levelsDeep = createNamespace("levels-deep", { + "hnc.x-k8s.io/included-namespace": "true", +}); + +const levelDeepChildA = createNamespace("level-deep-child-a", { + "hnc.x-k8s.io/included-namespace": "true", + "levels-deep.tree.hnc.x-k8s.io/depth": "1", + "level-deep-child-a.tree.hnc.x-k8s.io/depth": "0", +}); + +const levelDeepChildB = createNamespace("level-deep-child-b", { + "hnc.x-k8s.io/included-namespace": "true", + "levels-deep.tree.hnc.x-k8s.io/depth": "1", + "level-deep-child-b.tree.hnc.x-k8s.io/depth": "0", +}); + +const levelDeepSubChildA = createNamespace("level-deep-subchild-a", { + "hnc.x-k8s.io/included-namespace": "true", + "levels-deep.tree.hnc.x-k8s.io/depth": "2", + "level-deep-child-b.tree.hnc.x-k8s.io/depth": "1", + "level-deep-subchild-a.tree.hnc.x-k8s.io/depth": "0", +}); + +describe("", () => { + let di: DiContainer; + let render: DiRender; + + beforeEach(async () => { + di = getDiForUnitTesting({ doGeneralOverrides: true }); + + di.override(hierarchicalNamespacesInjectable, () => [ + acmeGroup, + orgA, + teamA, + teamB, + teamC, + service1, + levelsDeep, + levelDeepChildA, + levelDeepChildB, + levelDeepSubChildA, + ]); + + render = renderFor(di); + }); + + it("renders one namespace without children", () => { + const tree: NamespaceTree = { + id: "single-root", + namespace: singleRoot, + }; + + const result = render(); + + expect(result.baseElement).toMatchSnapshot(); + }); + + it("renders namespace with 2 children namespaces", () => { + const tree: NamespaceTree = { + id: "acme-org", + namespace: acmeGroup, + children: [ + { + id: "team-a", + namespace: teamA, + }, + { + id: "team-b", + namespace: teamB, + }, + ], + }; + + const result = render(); + + expect(result.baseElement).toMatchSnapshot(); + }); + + it("renders namespace with children namespaces and a subnamespace", () => { + const tree: NamespaceTree = { + id: "org-a", + namespace: orgA, + children: [ + { + id: "team-c", + namespace: teamC, + }, + { + id: "service-1", + namespace: service1, + }, + ], + }; + const result = render(); + + expect(result.baseElement).toMatchSnapshot(); + }); + + it("renders an indicator badge for the subnamespace", () => { + const tree: NamespaceTree = { + id: "org-a", + namespace: orgA, + children: [ + { + id: "team-c", + namespace: teamC, + }, + { + id: "service-1", + namespace: service1, + }, + ], + }; + const result = render(); + + expect(result.getByTestId("namespace-details-badge-for-service-1")).toBeInTheDocument(); + }); + + it("does not render an indicator badge for the true namespace", () => { + const tree: NamespaceTree = { + id: "org-a", + namespace: orgA, + children: [ + { + id: "team-c", + namespace: teamC, + }, + { + id: "service-1", + namespace: service1, + }, + ], + }; + const result = render(); + const trueNamespace = result.getByTestId("namespace-team-c"); + + expect(trueNamespace.querySelector("[data-testid='namespace-details-badge-for-team-c']")).toBeNull(); + }); + + it("renders 2 levels deep", () => { + const tree: NamespaceTree = { + id: "levels-deep", + namespace: levelsDeep, + children: [ + { + id: "level-deep-child-a", + namespace: levelDeepChildA, + }, + { + id: "level-deep-child-b", + namespace: levelDeepChildB, + children: [{ + id: "level-deep-subchild-a", + namespace: levelDeepSubChildA, + }], + }, + ], + }; + const result = render(); + + expect(result.baseElement).toMatchSnapshot(); + }); + + it("expands children items by default", () => { + const tree: NamespaceTree = { + id: "levels-deep", + namespace: levelsDeep, + children: [ + { + id: "level-deep-child-a", + namespace: levelDeepChildA, + }, + { + id: "level-deep-child-b", + namespace: levelDeepChildB, + children: [{ + id: "level-deep-subchild-a", + namespace: levelDeepSubChildA, + }], + }, + ], + }; + const result = render(); + const deepest = result.getByTestId("namespace-level-deep-child-b"); + + expect(deepest).toHaveAttribute("aria-expanded", "true"); + }); + + it("collapses item by clicking minus button", () => { + const tree: NamespaceTree = { + id: "levels-deep", + namespace: levelsDeep, + children: [ + { + id: "level-deep-child-a", + namespace: levelDeepChildA, + }, + { + id: "level-deep-child-b", + namespace: levelDeepChildB, + children: [{ + id: "level-deep-subchild-a", + namespace: levelDeepSubChildA, + }], + }, + ], + }; + const result = render(); + const levelB = result.getByTestId("namespace-level-deep-child-b"); + const minusButton = levelB.querySelector("[data-testid='minus-square']"); + + if (minusButton) { + fireEvent.click(minusButton); + } + + expect(result.baseElement).toMatchSnapshot(); + }); + + it("expands item by clicking plus button", () => { + const tree: NamespaceTree = { + id: "levels-deep", + namespace: levelsDeep, + children: [ + { + id: "level-deep-child-a", + namespace: levelDeepChildA, + }, + { + id: "level-deep-child-b", + namespace: levelDeepChildB, + children: [{ + id: "level-deep-subchild-a", + namespace: levelDeepSubChildA, + }], + }, + ], + }; + const result = render(); + const levelB = result.getByTestId("namespace-level-deep-child-b"); + const minusButton = levelB.querySelector("[data-testid='minus-square']"); + + if (minusButton) { + fireEvent.click(minusButton); + } + + const plusButton = levelB.querySelector("[data-testid='plus-square']"); + + if (plusButton) { + fireEvent.click(plusButton); + } + + expect(result.baseElement).toMatchSnapshot(); + }); +}); diff --git a/packages/core/src/renderer/components/+namespaces/namespace-tree-view.tsx b/packages/core/src/renderer/components/+namespaces/namespace-tree-view.tsx new file mode 100644 index 0000000000..8d69e1aba7 --- /dev/null +++ b/packages/core/src/renderer/components/+namespaces/namespace-tree-view.tsx @@ -0,0 +1,106 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ + +import styles from "./namespace-tree-view.module.scss"; + +import { SvgIcon } from "@material-ui/core"; +import { TreeItem, TreeView } from "@material-ui/lab"; +import { withInjectables } from "@ogre-tools/injectable-react"; +import React from "react"; +import { Link } from "react-router-dom"; +import type { Namespace } from "../../../common/k8s-api/endpoints"; +import { DrawerTitle } from "../drawer"; +import type { GetDetailsUrl } from "../kube-detail-params/get-details-url.injectable"; +import getDetailsUrlInjectable from "../kube-detail-params/get-details-url.injectable"; +import { SubnamespaceBadge } from "./subnamespace-badge"; +import hierarchicalNamespacesInjectable from "./hierarchical-namespaces.injectable"; +import { prevDefault } from "../../utils"; +import type { NamespaceTree } from "./store"; + +interface NamespaceTreeViewProps { + tree: NamespaceTree; +} + +interface Dependencies { + namespaces: Namespace[]; + getDetailsUrl: GetDetailsUrl; +} + +function NonInjectableNamespaceTreeView({ tree, namespaces, getDetailsUrl }: Dependencies & NamespaceTreeViewProps) { + const [expandedItems, setExpandedItems] = React.useState(namespaces.map(ns => ns.getId())); + const classes = { group: styles.group, label: styles.label }; + + function renderTree(nodes: NamespaceTree) { + return ( + toggleNode(nodes.id))} + label={( + <> + + {nodes.namespace.getName()} + + {" "} + {nodes.namespace.isSubnamespace() && ( + + )} + + )} + > + {Array.isArray(nodes.children) ? nodes.children.map((node) => renderTree(node)) : null} + + ); + } + + function toggleNode(id: string) { + if (expandedItems.includes(id)) { + setExpandedItems(expandedItems.filter(item => item !== id)); + } else { + setExpandedItems([...expandedItems, id]); + } + } + + return ( +
+ Tree View + } + defaultExpandIcon={} + defaultEndIcon={(
)} + expanded={expandedItems} + > + {renderTree(tree)} +
+
+ ); +} + +function MinusSquareIcon() { + return ( + + + + ); +} + +function PlusSquareIcon() { + return ( + + + + ); +} + +export const NamespaceTreeView = withInjectables(NonInjectableNamespaceTreeView, { + getProps: (di, props) => ({ + namespaces: di.inject(hierarchicalNamespacesInjectable), + getDetailsUrl: di.inject(getDetailsUrlInjectable), + ...props, + }), +}); diff --git a/packages/core/src/renderer/components/+namespaces/namespaces.scss b/packages/core/src/renderer/components/+namespaces/namespaces.scss index 8636d2f5ad..8aabb06a4a 100644 --- a/packages/core/src/renderer/components/+namespaces/namespaces.scss +++ b/packages/core/src/renderer/components/+namespaces/namespaces.scss @@ -22,4 +22,8 @@ @include namespaceStatus; } } + + .subnamespaceBadge { + margin-inline-start: var(--margin); + } } diff --git a/packages/core/src/renderer/components/+namespaces/route.tsx b/packages/core/src/renderer/components/+namespaces/route.tsx index 7efbfb738e..11e6d97864 100644 --- a/packages/core/src/renderer/components/+namespaces/route.tsx +++ b/packages/core/src/renderer/components/+namespaces/route.tsx @@ -16,6 +16,7 @@ import { withInjectables } from "@ogre-tools/injectable-react"; import namespaceStoreInjectable from "./store.injectable"; import { KubeObjectAge } from "../kube-object/age"; import openAddNamepaceDialogInjectable from "./add-dialog/open.injectable"; +import { SubnamespaceBadge } from "./subnamespace-badge"; enum columnId { name = "name", @@ -55,7 +56,12 @@ const NonInjectedNamespacesRoute = ({ namespaceStore, openAddNamespaceDialog }: { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, ]} renderTableContents={namespace => [ - namespace.getName(), + <> + {namespace.getName()} + {namespace.isSubnamespace() && ( + + )} + , , namespace.getLabels().map(label => ( ; readonly clusterConfiguredAccessibleNamespaces: IComputedValue; @@ -202,6 +208,16 @@ export class NamespaceStore extends KubeObjectStore { this.selectAll(); } + getNamespaceTree(root: Namespace): NamespaceTree { + const children = this.items.filter(namespace => namespace.isChildOf(root.getName())); + + return { + id: root.getId(), + namespace: root, + children: children.map(this.getNamespaceTree), + }; + } + @action async remove(item: Namespace) { await super.remove(item); diff --git a/packages/core/src/renderer/components/+namespaces/subnamespace-badge.module.scss b/packages/core/src/renderer/components/+namespaces/subnamespace-badge.module.scss new file mode 100644 index 0000000000..a557fdbba2 --- /dev/null +++ b/packages/core/src/renderer/components/+namespaces/subnamespace-badge.module.scss @@ -0,0 +1,11 @@ +.subnamespaceBadge { + border-radius: 3px; + padding: 0px 4px; + border: 1px solid cadetblue; + color: cadetblue; + display: inline-flex; + font-size: x-small; + font-weight: bold; + height: 16px; + align-items: center; +} \ No newline at end of file diff --git a/packages/core/src/renderer/components/+namespaces/subnamespace-badge.tsx b/packages/core/src/renderer/components/+namespaces/subnamespace-badge.tsx new file mode 100644 index 0000000000..4ae652acc6 --- /dev/null +++ b/packages/core/src/renderer/components/+namespaces/subnamespace-badge.tsx @@ -0,0 +1,31 @@ +/** + * Copyright (c) OpenLens Authors. All rights reserved. + * Licensed under MIT License. See LICENSE in root directory for more information. + */ +import styles from "./subnamespace-badge.module.scss"; + +import React from "react"; +import { Tooltip } from "../tooltip"; +import { cssNames } from "../../utils"; + +interface SubnamespaceBadgeProps extends React.HTMLAttributes { + id: string; +} + +export function SubnamespaceBadge({ id, className, ...other }: SubnamespaceBadgeProps) { + return ( + <> + + S + + + Subnamespace + + + ); +}