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

Namespace details tree view (#7080)

* Initial tests for <NamespaceTreeView />

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Introduce <NamespaceTreeView/>

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Render namespace children

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Render a child subnamespace

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Remove unused lodash import

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Render subnamespace badge after name

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Testing rendering 2 levels deep

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Add tree view to namespace details

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Expand all nodes by default

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Add links to the tree items

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Initial label styling

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Label and group styling

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Remove fontSize attr from SvgIcon

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Styling subnamespace badge

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Expand and collapse tree nodes

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Testing clicking plus icon

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Restyling subnamespace badge

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Adding tooltip for subnamespace badge

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Linter fixes

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Fix linter harder

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Replace CloseIcon with semi-transparent MinusIcon

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Styling TreeView inside scss module

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Move isSubnamespace method inside API

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Extract nodeId to avoid repeating

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Rename Icon components

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Clean up tests from boilderplate

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Subnamespace badge style fixes

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Linter fix

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Use font-size: x-small instead of rem units

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Move subnamespace badge show check to parent

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Use prevDefault util

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Refactor: move tree build logic to store

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Linter fixes

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Refactor hierarchicalNamespacesInjectable

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Add tests for getNamespaceTree() function

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Use Subnamespace badge in namespace list (#7132)

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

---------

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>
This commit is contained in:
Alex Andreev 2023-02-13 09:34:03 +03:00 committed by GitHub
parent 5d21db9fc2
commit 0719293b11
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 1706 additions and 1 deletions

View File

@ -33,6 +33,20 @@ export class Namespace extends KubeObject<
getStatus() { getStatus() {
return this.status?.phase ?? "-"; 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<Namespace> { export class NamespaceApi extends KubeApi<Namespace> {

View File

@ -0,0 +1,937 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<NamespaceTreeView /> collapses item by clicking minus button 1`] = `
<body>
<div>
<div
class="TreeView"
data-testid="namespace-tree-view"
>
<div
class="DrawerTitle title"
>
Tree View
</div>
<ul
aria-multiselectable="false"
class="MuiTreeView-root"
role="tree"
>
<li
aria-expanded="true"
class="MuiTreeItem-root Mui-expanded"
data-testid="namespace-levels-deep"
role="treeitem"
tabindex="0"
>
<div
class="MuiTreeItem-content"
>
<div
class="MuiTreeItem-iconContainer"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root"
data-testid="minus-square"
focusable="false"
style="width: 14px; height: 14px;"
viewBox="0 0 24 24"
>
<path
d="M22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0zM17.873 11.023h-11.826q-.375 0-.669.281t-.294.682v0q0 .401.294 .682t.669.281h11.826q.375 0 .669-.281t.294-.682v0q0-.401-.294-.682t-.669-.281z"
/>
</svg>
</div>
<div
class="MuiTypography-root MuiTreeItem-label label MuiTypography-body1"
>
levels-deep
</div>
</div>
<ul
class="MuiCollapse-root MuiTreeItem-group group MuiCollapse-entered"
role="group"
style="min-height: 0px;"
>
<div
class="MuiCollapse-wrapper"
>
<div
class="MuiCollapse-wrapperInner"
>
<li
class="MuiTreeItem-root Mui-expanded"
data-testid="namespace-level-deep-child-a"
role="treeitem"
tabindex="-1"
>
<div
class="MuiTreeItem-content"
>
<div
class="MuiTreeItem-iconContainer"
>
<div
style="opacity: 0.3;"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root"
data-testid="minus-square"
focusable="false"
style="width: 14px; height: 14px;"
viewBox="0 0 24 24"
>
<path
d="M22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0zM17.873 11.023h-11.826q-.375 0-.669.281t-.294.682v0q0 .401.294 .682t.669.281h11.826q.375 0 .669-.281t.294-.682v0q0-.401-.294-.682t-.669-.281z"
/>
</svg>
</div>
</div>
<div
class="MuiTypography-root MuiTreeItem-label label MuiTypography-body1"
>
level-deep-child-a
</div>
</div>
</li>
<li
aria-expanded="false"
class="MuiTreeItem-root"
data-testid="namespace-level-deep-child-b"
role="treeitem"
tabindex="-1"
>
<div
class="MuiTreeItem-content"
>
<div
class="MuiTreeItem-iconContainer"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root"
data-testid="plus-square"
focusable="false"
style="width: 14px; height: 14px;"
viewBox="0 0 24 24"
>
<path
d="M22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0zM17.873 12.977h-4.923v4.896q0 .401-.281.682t-.682.281v0q-.375 0-.669-.281t-.294-.682v-4.896h-4.923q-.401 0-.682-.294t-.281-.669v0q0-.401.281-.682t.682-.281h4.923v-4.896q0-.401.294-.682t.669-.281v0q.401 0 .682.281t.281.682v4.896h4.923q.401 0 .682.281t.281.682v0q0 .375-.281.669t-.682.294z"
/>
</svg>
</div>
<div
class="MuiTypography-root MuiTreeItem-label label MuiTypography-body1"
>
level-deep-child-b
</div>
</div>
<ul
class="MuiCollapse-root MuiTreeItem-group group"
role="group"
style="min-height: 0px; height: 0px; transition-duration: 300ms;"
>
<div
class="MuiCollapse-wrapper"
>
<div
class="MuiCollapse-wrapperInner"
>
<li
class="MuiTreeItem-root Mui-expanded"
data-testid="namespace-level-deep-subchild-a"
role="treeitem"
tabindex="-1"
>
<div
class="MuiTreeItem-content"
>
<div
class="MuiTreeItem-iconContainer"
>
<div
style="opacity: 0.3;"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root"
data-testid="minus-square"
focusable="false"
style="width: 14px; height: 14px;"
viewBox="0 0 24 24"
>
<path
d="M22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0zM17.873 11.023h-11.826q-.375 0-.669.281t-.294.682v0q0 .401.294 .682t.669.281h11.826q.375 0 .669-.281t.294-.682v0q0-.401-.294-.682t-.669-.281z"
/>
</svg>
</div>
</div>
<div
class="MuiTypography-root MuiTreeItem-label label MuiTypography-body1"
>
level-deep-subchild-a
</div>
</div>
</li>
</div>
</div>
</ul>
</li>
</div>
</div>
</ul>
</li>
</ul>
</div>
</div>
</body>
`;
exports[`<NamespaceTreeView /> expands item by clicking plus button 1`] = `
<body>
<div>
<div
class="TreeView"
data-testid="namespace-tree-view"
>
<div
class="DrawerTitle title"
>
Tree View
</div>
<ul
aria-multiselectable="false"
class="MuiTreeView-root"
role="tree"
>
<li
aria-expanded="true"
class="MuiTreeItem-root Mui-expanded"
data-testid="namespace-levels-deep"
role="treeitem"
tabindex="0"
>
<div
class="MuiTreeItem-content"
>
<div
class="MuiTreeItem-iconContainer"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root"
data-testid="minus-square"
focusable="false"
style="width: 14px; height: 14px;"
viewBox="0 0 24 24"
>
<path
d="M22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0zM17.873 11.023h-11.826q-.375 0-.669.281t-.294.682v0q0 .401.294 .682t.669.281h11.826q.375 0 .669-.281t.294-.682v0q0-.401-.294-.682t-.669-.281z"
/>
</svg>
</div>
<div
class="MuiTypography-root MuiTreeItem-label label MuiTypography-body1"
>
levels-deep
</div>
</div>
<ul
class="MuiCollapse-root MuiTreeItem-group group MuiCollapse-entered"
role="group"
style="min-height: 0px;"
>
<div
class="MuiCollapse-wrapper"
>
<div
class="MuiCollapse-wrapperInner"
>
<li
class="MuiTreeItem-root Mui-expanded"
data-testid="namespace-level-deep-child-a"
role="treeitem"
tabindex="-1"
>
<div
class="MuiTreeItem-content"
>
<div
class="MuiTreeItem-iconContainer"
>
<div
style="opacity: 0.3;"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root"
data-testid="minus-square"
focusable="false"
style="width: 14px; height: 14px;"
viewBox="0 0 24 24"
>
<path
d="M22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0zM17.873 11.023h-11.826q-.375 0-.669.281t-.294.682v0q0 .401.294 .682t.669.281h11.826q.375 0 .669-.281t.294-.682v0q0-.401-.294-.682t-.669-.281z"
/>
</svg>
</div>
</div>
<div
class="MuiTypography-root MuiTreeItem-label label MuiTypography-body1"
>
level-deep-child-a
</div>
</div>
</li>
<li
aria-expanded="true"
class="MuiTreeItem-root Mui-expanded"
data-testid="namespace-level-deep-child-b"
role="treeitem"
tabindex="-1"
>
<div
class="MuiTreeItem-content"
>
<div
class="MuiTreeItem-iconContainer"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root"
data-testid="minus-square"
focusable="false"
style="width: 14px; height: 14px;"
viewBox="0 0 24 24"
>
<path
d="M22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0zM17.873 11.023h-11.826q-.375 0-.669.281t-.294.682v0q0 .401.294 .682t.669.281h11.826q.375 0 .669-.281t.294-.682v0q0-.401-.294-.682t-.669-.281z"
/>
</svg>
</div>
<div
class="MuiTypography-root MuiTreeItem-label label MuiTypography-body1"
>
level-deep-child-b
</div>
</div>
<ul
class="MuiCollapse-root MuiTreeItem-group group"
role="group"
style="min-height: 0px; height: 0px; transition-duration: 300ms;"
>
<div
class="MuiCollapse-wrapper"
>
<div
class="MuiCollapse-wrapperInner"
>
<li
class="MuiTreeItem-root Mui-expanded"
data-testid="namespace-level-deep-subchild-a"
role="treeitem"
tabindex="-1"
>
<div
class="MuiTreeItem-content"
>
<div
class="MuiTreeItem-iconContainer"
>
<div
style="opacity: 0.3;"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root"
data-testid="minus-square"
focusable="false"
style="width: 14px; height: 14px;"
viewBox="0 0 24 24"
>
<path
d="M22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0zM17.873 11.023h-11.826q-.375 0-.669.281t-.294.682v0q0 .401.294 .682t.669.281h11.826q.375 0 .669-.281t.294-.682v0q0-.401-.294-.682t-.669-.281z"
/>
</svg>
</div>
</div>
<div
class="MuiTypography-root MuiTreeItem-label label MuiTypography-body1"
>
level-deep-subchild-a
</div>
</div>
</li>
</div>
</div>
</ul>
</li>
</div>
</div>
</ul>
</li>
</ul>
</div>
</div>
</body>
`;
exports[`<NamespaceTreeView /> renders 2 levels deep 1`] = `
<body>
<div>
<div
class="TreeView"
data-testid="namespace-tree-view"
>
<div
class="DrawerTitle title"
>
Tree View
</div>
<ul
aria-multiselectable="false"
class="MuiTreeView-root"
role="tree"
>
<li
aria-expanded="true"
class="MuiTreeItem-root Mui-expanded"
data-testid="namespace-levels-deep"
role="treeitem"
tabindex="0"
>
<div
class="MuiTreeItem-content"
>
<div
class="MuiTreeItem-iconContainer"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root"
data-testid="minus-square"
focusable="false"
style="width: 14px; height: 14px;"
viewBox="0 0 24 24"
>
<path
d="M22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0zM17.873 11.023h-11.826q-.375 0-.669.281t-.294.682v0q0 .401.294 .682t.669.281h11.826q.375 0 .669-.281t.294-.682v0q0-.401-.294-.682t-.669-.281z"
/>
</svg>
</div>
<div
class="MuiTypography-root MuiTreeItem-label label MuiTypography-body1"
>
levels-deep
</div>
</div>
<ul
class="MuiCollapse-root MuiTreeItem-group group MuiCollapse-entered"
role="group"
style="min-height: 0px;"
>
<div
class="MuiCollapse-wrapper"
>
<div
class="MuiCollapse-wrapperInner"
>
<li
class="MuiTreeItem-root Mui-expanded"
data-testid="namespace-level-deep-child-a"
role="treeitem"
tabindex="-1"
>
<div
class="MuiTreeItem-content"
>
<div
class="MuiTreeItem-iconContainer"
>
<div
style="opacity: 0.3;"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root"
data-testid="minus-square"
focusable="false"
style="width: 14px; height: 14px;"
viewBox="0 0 24 24"
>
<path
d="M22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0zM17.873 11.023h-11.826q-.375 0-.669.281t-.294.682v0q0 .401.294 .682t.669.281h11.826q.375 0 .669-.281t.294-.682v0q0-.401-.294-.682t-.669-.281z"
/>
</svg>
</div>
</div>
<div
class="MuiTypography-root MuiTreeItem-label label MuiTypography-body1"
>
level-deep-child-a
</div>
</div>
</li>
<li
aria-expanded="true"
class="MuiTreeItem-root Mui-expanded"
data-testid="namespace-level-deep-child-b"
role="treeitem"
tabindex="-1"
>
<div
class="MuiTreeItem-content"
>
<div
class="MuiTreeItem-iconContainer"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root"
data-testid="minus-square"
focusable="false"
style="width: 14px; height: 14px;"
viewBox="0 0 24 24"
>
<path
d="M22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0zM17.873 11.023h-11.826q-.375 0-.669.281t-.294.682v0q0 .401.294 .682t.669.281h11.826q.375 0 .669-.281t.294-.682v0q0-.401-.294-.682t-.669-.281z"
/>
</svg>
</div>
<div
class="MuiTypography-root MuiTreeItem-label label MuiTypography-body1"
>
level-deep-child-b
</div>
</div>
<ul
class="MuiCollapse-root MuiTreeItem-group group MuiCollapse-entered"
role="group"
style="min-height: 0px;"
>
<div
class="MuiCollapse-wrapper"
>
<div
class="MuiCollapse-wrapperInner"
>
<li
class="MuiTreeItem-root Mui-expanded"
data-testid="namespace-level-deep-subchild-a"
role="treeitem"
tabindex="-1"
>
<div
class="MuiTreeItem-content"
>
<div
class="MuiTreeItem-iconContainer"
>
<div
style="opacity: 0.3;"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root"
data-testid="minus-square"
focusable="false"
style="width: 14px; height: 14px;"
viewBox="0 0 24 24"
>
<path
d="M22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0zM17.873 11.023h-11.826q-.375 0-.669.281t-.294.682v0q0 .401.294 .682t.669.281h11.826q.375 0 .669-.281t.294-.682v0q0-.401-.294-.682t-.669-.281z"
/>
</svg>
</div>
</div>
<div
class="MuiTypography-root MuiTreeItem-label label MuiTypography-body1"
>
level-deep-subchild-a
</div>
</div>
</li>
</div>
</div>
</ul>
</li>
</div>
</div>
</ul>
</li>
</ul>
</div>
</div>
</body>
`;
exports[`<NamespaceTreeView /> renders namespace with 2 children namespaces 1`] = `
<body>
<div>
<div
class="TreeView"
data-testid="namespace-tree-view"
>
<div
class="DrawerTitle title"
>
Tree View
</div>
<ul
aria-multiselectable="false"
class="MuiTreeView-root"
role="tree"
>
<li
aria-expanded="true"
class="MuiTreeItem-root Mui-expanded"
data-testid="namespace-acme-org"
role="treeitem"
tabindex="0"
>
<div
class="MuiTreeItem-content"
>
<div
class="MuiTreeItem-iconContainer"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root"
data-testid="minus-square"
focusable="false"
style="width: 14px; height: 14px;"
viewBox="0 0 24 24"
>
<path
d="M22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0zM17.873 11.023h-11.826q-.375 0-.669.281t-.294.682v0q0 .401.294 .682t.669.281h11.826q.375 0 .669-.281t.294-.682v0q0-.401-.294-.682t-.669-.281z"
/>
</svg>
</div>
<div
class="MuiTypography-root MuiTreeItem-label label MuiTypography-body1"
>
acme-org
</div>
</div>
<ul
class="MuiCollapse-root MuiTreeItem-group group MuiCollapse-entered"
role="group"
style="min-height: 0px;"
>
<div
class="MuiCollapse-wrapper"
>
<div
class="MuiCollapse-wrapperInner"
>
<li
class="MuiTreeItem-root Mui-expanded"
data-testid="namespace-team-a"
role="treeitem"
tabindex="-1"
>
<div
class="MuiTreeItem-content"
>
<div
class="MuiTreeItem-iconContainer"
>
<div
style="opacity: 0.3;"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root"
data-testid="minus-square"
focusable="false"
style="width: 14px; height: 14px;"
viewBox="0 0 24 24"
>
<path
d="M22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0zM17.873 11.023h-11.826q-.375 0-.669.281t-.294.682v0q0 .401.294 .682t.669.281h11.826q.375 0 .669-.281t.294-.682v0q0-.401-.294-.682t-.669-.281z"
/>
</svg>
</div>
</div>
<div
class="MuiTypography-root MuiTreeItem-label label MuiTypography-body1"
>
team-a
</div>
</div>
</li>
<li
class="MuiTreeItem-root Mui-expanded"
data-testid="namespace-team-b"
role="treeitem"
tabindex="-1"
>
<div
class="MuiTreeItem-content"
>
<div
class="MuiTreeItem-iconContainer"
>
<div
style="opacity: 0.3;"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root"
data-testid="minus-square"
focusable="false"
style="width: 14px; height: 14px;"
viewBox="0 0 24 24"
>
<path
d="M22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0zM17.873 11.023h-11.826q-.375 0-.669.281t-.294.682v0q0 .401.294 .682t.669.281h11.826q.375 0 .669-.281t.294-.682v0q0-.401-.294-.682t-.669-.281z"
/>
</svg>
</div>
</div>
<div
class="MuiTypography-root MuiTreeItem-label label MuiTypography-body1"
>
team-b
</div>
</div>
</li>
</div>
</div>
</ul>
</li>
</ul>
</div>
</div>
</body>
`;
exports[`<NamespaceTreeView /> renders namespace with children namespaces and a subnamespace 1`] = `
<body>
<div>
<div
class="TreeView"
data-testid="namespace-tree-view"
>
<div
class="DrawerTitle title"
>
Tree View
</div>
<ul
aria-multiselectable="false"
class="MuiTreeView-root"
role="tree"
>
<li
aria-expanded="true"
class="MuiTreeItem-root Mui-expanded"
data-testid="namespace-org-a"
role="treeitem"
tabindex="0"
>
<div
class="MuiTreeItem-content"
>
<div
class="MuiTreeItem-iconContainer"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root"
data-testid="minus-square"
focusable="false"
style="width: 14px; height: 14px;"
viewBox="0 0 24 24"
>
<path
d="M22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0zM17.873 11.023h-11.826q-.375 0-.669.281t-.294.682v0q0 .401.294 .682t.669.281h11.826q.375 0 .669-.281t.294-.682v0q0-.401-.294-.682t-.669-.281z"
/>
</svg>
</div>
<div
class="MuiTypography-root MuiTreeItem-label label MuiTypography-body1"
>
org-a
</div>
</div>
<ul
class="MuiCollapse-root MuiTreeItem-group group MuiCollapse-entered"
role="group"
style="min-height: 0px;"
>
<div
class="MuiCollapse-wrapper"
>
<div
class="MuiCollapse-wrapperInner"
>
<li
class="MuiTreeItem-root Mui-expanded"
data-testid="namespace-team-c"
role="treeitem"
tabindex="-1"
>
<div
class="MuiTreeItem-content"
>
<div
class="MuiTreeItem-iconContainer"
>
<div
style="opacity: 0.3;"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root"
data-testid="minus-square"
focusable="false"
style="width: 14px; height: 14px;"
viewBox="0 0 24 24"
>
<path
d="M22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0zM17.873 11.023h-11.826q-.375 0-.669.281t-.294.682v0q0 .401.294 .682t.669.281h11.826q.375 0 .669-.281t.294-.682v0q0-.401-.294-.682t-.669-.281z"
/>
</svg>
</div>
</div>
<div
class="MuiTypography-root MuiTreeItem-label label MuiTypography-body1"
>
team-c
</div>
</div>
</li>
<li
class="MuiTreeItem-root Mui-expanded"
data-testid="namespace-service-1"
role="treeitem"
tabindex="-1"
>
<div
class="MuiTreeItem-content"
>
<div
class="MuiTreeItem-iconContainer"
>
<div
style="opacity: 0.3;"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root"
data-testid="minus-square"
focusable="false"
style="width: 14px; height: 14px;"
viewBox="0 0 24 24"
>
<path
d="M22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0zM17.873 11.023h-11.826q-.375 0-.669.281t-.294.682v0q0 .401.294 .682t.669.281h11.826q.375 0 .669-.281t.294-.682v0q0-.401-.294-.682t-.669-.281z"
/>
</svg>
</div>
</div>
<div
class="MuiTypography-root MuiTreeItem-label label MuiTypography-body1"
>
service-1
<span
class="subnamespaceBadge"
data-testid="namespace-details-badge-for-service-1"
id="namespace-details-badge-for-service-1"
>
S
</span>
</div>
</div>
</li>
</div>
</div>
</ul>
</li>
</ul>
</div>
</div>
</body>
`;
exports[`<NamespaceTreeView /> renders one namespace without children 1`] = `
<body>
<div>
<div
class="TreeView"
data-testid="namespace-tree-view"
>
<div
class="DrawerTitle title"
>
Tree View
</div>
<ul
aria-multiselectable="false"
class="MuiTreeView-root"
role="tree"
>
<li
class="MuiTreeItem-root"
data-testid="namespace-single-root"
role="treeitem"
tabindex="0"
>
<div
class="MuiTreeItem-content"
>
<div
class="MuiTreeItem-iconContainer"
>
<div
style="opacity: 0.3;"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root"
data-testid="minus-square"
focusable="false"
style="width: 14px; height: 14px;"
viewBox="0 0 24 24"
>
<path
d="M22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0zM17.873 11.023h-11.826q-.375 0-.669.281t-.294.682v0q0 .401.294 .682t.669.281h11.826q.375 0 .669-.281t.294-.682v0q0-.401-.294-.682t-.669-.281z"
/>
</svg>
</div>
</div>
<div
class="MuiTypography-root MuiTreeItem-label label MuiTypography-body1"
>
single-root
</div>
</div>
</li>
</ul>
</div>
</div>
</body>
`;

View File

@ -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;

View File

@ -26,6 +26,9 @@ import limitRangeStoreInjectable from "../+config-limit-ranges/store.injectable"
import resourceQuotaStoreInjectable from "../+config-resource-quotas/store.injectable"; import resourceQuotaStoreInjectable from "../+config-resource-quotas/store.injectable";
import type { Logger } from "../../../common/logger"; import type { Logger } from "../../../common/logger";
import loggerInjectable from "../../../common/logger.injectable"; 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<Namespace> { export interface NamespaceDetailsProps extends KubeObjectDetailsProps<Namespace> {
} }
@ -35,6 +38,7 @@ interface Dependencies {
getDetailsUrl: GetDetailsUrl; getDetailsUrl: GetDetailsUrl;
resourceQuotaStore: ResourceQuotaStore; resourceQuotaStore: ResourceQuotaStore;
limitRangeStore: LimitRangeStore; limitRangeStore: LimitRangeStore;
namespaceStore: NamespaceStore;
logger: Logger; logger: Logger;
} }
@ -103,6 +107,10 @@ class NonInjectedNamespaceDetails extends React.Component<NamespaceDetailsProps
</Link> </Link>
))} ))}
</DrawerItem> </DrawerItem>
{namespace.isControlledByHNC() && (
<NamespaceTreeView tree={this.props.namespaceStore.getNamespaceTree(namespace)}/>
)}
</div> </div>
); );
} }
@ -115,6 +123,7 @@ export const NamespaceDetails = withInjectables<Dependencies, NamespaceDetailsPr
getDetailsUrl: di.inject(getDetailsUrlInjectable), getDetailsUrl: di.inject(getDetailsUrlInjectable),
limitRangeStore: di.inject(limitRangeStoreInjectable), limitRangeStore: di.inject(limitRangeStoreInjectable),
resourceQuotaStore: di.inject(resourceQuotaStoreInjectable), resourceQuotaStore: di.inject(resourceQuotaStoreInjectable),
namespaceStore: di.inject(namespaceStoreInjectable),
logger: di.inject(loggerInjectable), logger: di.inject(loggerInjectable),
}), }),
}); });

View File

@ -0,0 +1,205 @@
/**
* 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 { observable } from "mobx";
import directoryForKubeConfigsInjectable from "../../../common/app-paths/directory-for-kube-configs/directory-for-kube-configs.injectable";
import directoryForUserDataInjectable from "../../../common/app-paths/directory-for-user-data/directory-for-user-data.injectable";
import { Namespace } from "../../../common/k8s-api/endpoints";
import hostedClusterInjectable from "../../cluster-frame-context/hosted-cluster.injectable";
import createClusterInjectable from "../../cluster/create-cluster.injectable";
import { getDiForUnitTesting } from "../../getDiForUnitTesting";
import storesAndApisCanBeCreatedInjectable from "../../stores-apis-can-be-created.injectable";
import type { NamespaceStore } from "./store";
import namespaceStoreInjectable from "./store.injectable";
function createNamespace(name: string, labels?: Record<string, string>, annotations?: Record<string, string>): 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: [],
}],
},
],
});
});
});

View File

@ -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;
}
}

View File

@ -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<string, string>, annotations?: Record<string, string>): 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("<NamespaceTreeView />", () => {
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(<NamespaceTreeView tree={tree} />);
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(<NamespaceTreeView tree={tree} />);
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(<NamespaceTreeView tree={tree} />);
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(<NamespaceTreeView tree={tree} />);
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(<NamespaceTreeView tree={tree} />);
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(<NamespaceTreeView tree={tree} />);
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(<NamespaceTreeView tree={tree} />);
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(<NamespaceTreeView tree={tree} />);
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(<NamespaceTreeView tree={tree} />);
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();
});
});

View File

@ -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<string[]>(namespaces.map(ns => ns.getId()));
const classes = { group: styles.group, label: styles.label };
function renderTree(nodes: NamespaceTree) {
return (
<TreeItem
key={nodes.id}
nodeId={nodes.id}
data-testid={`namespace-${nodes.id}`}
classes={classes}
onIconClick={prevDefault(() => toggleNode(nodes.id))}
label={(
<>
<Link key={nodes.namespace.getId()} to={getDetailsUrl(nodes.namespace.selfLink)}>
{nodes.namespace.getName()}
</Link>
{" "}
{nodes.namespace.isSubnamespace() && (
<SubnamespaceBadge id={`namespace-details-badge-for-${nodes.namespace.getId()}`} />
)}
</>
)}
>
{Array.isArray(nodes.children) ? nodes.children.map((node) => renderTree(node)) : null}
</TreeItem>
);
}
function toggleNode(id: string) {
if (expandedItems.includes(id)) {
setExpandedItems(expandedItems.filter(item => item !== id));
} else {
setExpandedItems([...expandedItems, id]);
}
}
return (
<div data-testid="namespace-tree-view" className={styles.TreeView}>
<DrawerTitle>Tree View</DrawerTitle>
<TreeView
defaultExpanded={[tree.id]}
defaultCollapseIcon={<MinusSquareIcon />}
defaultExpandIcon={<PlusSquareIcon />}
defaultEndIcon={(<div style={{ opacity: 0.3 }}><MinusSquareIcon /></div>)}
expanded={expandedItems}
>
{renderTree(tree)}
</TreeView>
</div>
);
}
function MinusSquareIcon() {
return (
<SvgIcon style={{ width: 14, height: 14 }} data-testid="minus-square">
<path d="M22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0zM17.873 11.023h-11.826q-.375 0-.669.281t-.294.682v0q0 .401.294 .682t.669.281h11.826q.375 0 .669-.281t.294-.682v0q0-.401-.294-.682t-.669-.281z" />
</SvgIcon>
);
}
function PlusSquareIcon() {
return (
<SvgIcon style={{ width: 14, height: 14 }} data-testid="plus-square">
<path d="M22.047 22.074v0 0-20.147 0h-20.12v0 20.147 0h20.12zM22.047 24h-20.12q-.803 0-1.365-.562t-.562-1.365v-20.147q0-.776.562-1.351t1.365-.575h20.147q.776 0 1.351.575t.575 1.351v20.147q0 .803-.575 1.365t-1.378.562v0zM17.873 12.977h-4.923v4.896q0 .401-.281.682t-.682.281v0q-.375 0-.669-.281t-.294-.682v-4.896h-4.923q-.401 0-.682-.294t-.281-.669v0q0-.401.281-.682t.682-.281h4.923v-4.896q0-.401.294-.682t.669-.281v0q.401 0 .682.281t.281.682v4.896h4.923q.401 0 .682.281t.281.682v0q0 .375-.281.669t-.682.294z" />
</SvgIcon>
);
}
export const NamespaceTreeView = withInjectables<Dependencies, NamespaceTreeViewProps>(NonInjectableNamespaceTreeView, {
getProps: (di, props) => ({
namespaces: di.inject(hierarchicalNamespacesInjectable),
getDetailsUrl: di.inject(getDetailsUrlInjectable),
...props,
}),
});

View File

@ -22,4 +22,8 @@
@include namespaceStatus; @include namespaceStatus;
} }
} }
.subnamespaceBadge {
margin-inline-start: var(--margin);
}
} }

View File

@ -16,6 +16,7 @@ import { withInjectables } from "@ogre-tools/injectable-react";
import namespaceStoreInjectable from "./store.injectable"; import namespaceStoreInjectable from "./store.injectable";
import { KubeObjectAge } from "../kube-object/age"; import { KubeObjectAge } from "../kube-object/age";
import openAddNamepaceDialogInjectable from "./add-dialog/open.injectable"; import openAddNamepaceDialogInjectable from "./add-dialog/open.injectable";
import { SubnamespaceBadge } from "./subnamespace-badge";
enum columnId { enum columnId {
name = "name", name = "name",
@ -55,7 +56,12 @@ const NonInjectedNamespacesRoute = ({ namespaceStore, openAddNamespaceDialog }:
{ title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status },
]} ]}
renderTableContents={namespace => [ renderTableContents={namespace => [
namespace.getName(), <>
{namespace.getName()}
{namespace.isSubnamespace() && (
<SubnamespaceBadge className="subnamespaceBadge" id={`namespace-list-badge-for-${namespace.getId()}`} />
)}
</>,
<KubeObjectStatusIcon key="icon" object={namespace} />, <KubeObjectStatusIcon key="icon" object={namespace} />,
namespace.getLabels().map(label => ( namespace.getLabels().map(label => (
<Badge <Badge

View File

@ -12,6 +12,12 @@ import { KubeObjectStore } from "../../../common/k8s-api/kube-object.store";
import type { NamespaceApi } from "../../../common/k8s-api/endpoints/namespace.api"; import type { NamespaceApi } from "../../../common/k8s-api/endpoints/namespace.api";
import { Namespace } from "../../../common/k8s-api/endpoints/namespace.api"; import { Namespace } from "../../../common/k8s-api/endpoints/namespace.api";
export interface NamespaceTree {
id: string;
namespace: Namespace;
children?: NamespaceTree[];
}
interface Dependencies extends KubeObjectStoreDependencies { interface Dependencies extends KubeObjectStoreDependencies {
readonly storage: StorageLayer<string[] | undefined>; readonly storage: StorageLayer<string[] | undefined>;
readonly clusterConfiguredAccessibleNamespaces: IComputedValue<string[]>; readonly clusterConfiguredAccessibleNamespaces: IComputedValue<string[]>;
@ -202,6 +208,16 @@ export class NamespaceStore extends KubeObjectStore<Namespace, NamespaceApi> {
this.selectAll(); 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 @action
async remove(item: Namespace) { async remove(item: Namespace) {
await super.remove(item); await super.remove(item);

View File

@ -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;
}

View File

@ -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<HTMLSpanElement> {
id: string;
}
export function SubnamespaceBadge({ id, className, ...other }: SubnamespaceBadgeProps) {
return (
<>
<span
className={cssNames(styles.subnamespaceBadge, className)}
data-testid={id}
id={id}
{...other}
>
S
</span>
<Tooltip targetId={id}>
Subnamespace
</Tooltip>
</>
);
}