mirror of https://github.com/grafana/grafana
Scopes: Global scopes search in command palette (#105597)
* Add pills in search bar for context * Add scope actions * Add selection functionality * Show selected scope on secondary row * Fix selected scope titles * Add some basic tests * Test for toggle by name * Remove unnecessary mocking * Small cleanups * Lint fixes * Fix test * Update public/app/features/scopes/selector/ScopesSelectorService.ts Co-authored-by: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> * Bump input and breadcrumbs test * Change breadcrumbs color * Makes the breacrumb spacing consistent * Add basic global search * Change scope selector data structures * Fix scope selector functionality * Fix errors in selector and cmdk actions * Fix cmdk actions * Fix global search in cmdk * Fix some merge edits * merge diffs * Small merge fixes * Fix ScopesSelectorService.test.ts * Fix tests * Remove unrelated lint fixes * Move ScopesTreeItemList.tsx into separate file * Simplify if condition * Use node.title in the scopesRow * Use better dependency array for actions * Make recentScopes more robust * Fix beterrer * Update betterer file * Add test for changeScopes early return * Fix input tooltip title access --------- Co-authored-by: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com>gamab/apis-proxy/authn
parent
6c9fd45837
commit
1aaf8adee4
@ -1,97 +1,126 @@ |
||||
import { Dictionary, groupBy } from 'lodash'; |
||||
import { useMemo } from 'react'; |
||||
import { css } from '@emotion/css'; |
||||
import Skeleton from 'react-loading-skeleton'; |
||||
|
||||
import { GrafanaTheme2, Scope } from '@grafana/data'; |
||||
import { useStyles2 } from '@grafana/ui'; |
||||
|
||||
import { RecentScopes } from './RecentScopes'; |
||||
import { ScopesTreeHeadline } from './ScopesTreeHeadline'; |
||||
import { ScopesTreeItem } from './ScopesTreeItem'; |
||||
import { ScopesTreeLoading } from './ScopesTreeLoading'; |
||||
import { ScopesTreeItemList } from './ScopesTreeItemList'; |
||||
import { ScopesTreeSearch } from './ScopesTreeSearch'; |
||||
import { Node, NodeReason, NodesMap, OnNodeSelectToggle, OnNodeUpdate, TreeScope, SelectedScope } from './types'; |
||||
import { NodesMap, SelectedScope, TreeNode } from './types'; |
||||
|
||||
export interface ScopesTreeProps { |
||||
nodes: NodesMap; |
||||
nodePath: string[]; |
||||
tree: TreeNode; |
||||
loadingNodeName: string | undefined; |
||||
scopes: TreeScope[]; |
||||
onNodeUpdate: OnNodeUpdate; |
||||
onNodeSelectToggle: OnNodeSelectToggle; |
||||
selectedScopes: SelectedScope[]; |
||||
scopeNodes: NodesMap; |
||||
|
||||
onNodeUpdate: (scopeNodeId: string, expanded: boolean, query: string) => void; |
||||
|
||||
selectScope: (scopeNodeId: string) => void; |
||||
deselectScope: (scopeNodeId: string) => void; |
||||
|
||||
// Recent scopes are only shown at the root node
|
||||
recentScopes?: SelectedScope[][]; |
||||
onRecentScopesSelect?: (recentScopeSet: SelectedScope[]) => void; |
||||
recentScopes?: Scope[][]; |
||||
onRecentScopesSelect?: (scopeIds: string[]) => void; |
||||
} |
||||
|
||||
export function ScopesTree({ |
||||
nodes, |
||||
nodePath, |
||||
tree, |
||||
loadingNodeName, |
||||
scopes, |
||||
selectedScopes, |
||||
recentScopes, |
||||
onRecentScopesSelect, |
||||
onNodeUpdate, |
||||
onNodeSelectToggle, |
||||
scopeNodes, |
||||
selectScope, |
||||
deselectScope, |
||||
}: ScopesTreeProps) { |
||||
const nodeId = nodePath[nodePath.length - 1]; |
||||
const node = nodes[nodeId]; |
||||
const childNodes = Object.values(node.nodes); |
||||
const nodeLoading = loadingNodeName === nodeId; |
||||
const scopeNames = scopes.map(({ scopeName }) => scopeName); |
||||
const anyChildExpanded = childNodes.some(({ expanded }) => expanded); |
||||
const groupedNodes: Dictionary<Node[]> = useMemo(() => groupBy(childNodes, 'reason'), [childNodes]); |
||||
const lastExpandedNode = !anyChildExpanded && node.expanded; |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
const nodeLoading = loadingNodeName === tree.scopeNodeId; |
||||
|
||||
const children = tree.children; |
||||
let childrenArray = Object.values(children || {}); |
||||
const anyChildExpanded = childrenArray.some(({ expanded }) => expanded); |
||||
|
||||
// Nodes that are already selected (not applied) are always shown if we are in their category, even if they are
|
||||
// filtered out by query filter
|
||||
let selectedNodesToShow: TreeNode[] = []; |
||||
if (selectedScopes.length > 0 && selectedScopes[0].scopeNodeId) { |
||||
if (tree.scopeNodeId === scopeNodes[selectedScopes[0].scopeNodeId]?.spec.parentName) { |
||||
selectedNodesToShow = selectedScopes |
||||
// We filter out those which are still shown in the normal list of results
|
||||
.filter((s) => !childrenArray.map((c) => c.scopeNodeId).includes(s.scopeNodeId!)) |
||||
.map((s) => ({ |
||||
// Because we had to check the parent with the use of scopeNodeId we know we have it. (we may not have it
|
||||
// if the selected scopes are from url persistence, in which case we don't show them)
|
||||
scopeNodeId: s.scopeNodeId!, |
||||
query: '', |
||||
expanded: false, |
||||
})); |
||||
} |
||||
} |
||||
|
||||
const lastExpandedNode = !anyChildExpanded && tree.expanded; |
||||
|
||||
return ( |
||||
<> |
||||
<ScopesTreeSearch |
||||
anyChildExpanded={anyChildExpanded} |
||||
nodePath={nodePath} |
||||
query={node.query} |
||||
onNodeUpdate={onNodeUpdate} |
||||
/> |
||||
{nodePath.length === 1 && |
||||
nodePath[0] === '' && |
||||
<ScopesTreeSearch anyChildExpanded={anyChildExpanded} onNodeUpdate={onNodeUpdate} treeNode={tree} /> |
||||
{tree.scopeNodeId === '' && |
||||
!anyChildExpanded && |
||||
recentScopes && |
||||
recentScopes.length > 0 && |
||||
onRecentScopesSelect && |
||||
!node.query && <RecentScopes recentScopes={recentScopes} onSelect={onRecentScopesSelect} />} |
||||
|
||||
<ScopesTreeLoading nodeLoading={nodeLoading}> |
||||
<ScopesTreeItem |
||||
anyChildExpanded={anyChildExpanded} |
||||
groupedNodes={groupedNodes} |
||||
lastExpandedNode={lastExpandedNode} |
||||
loadingNodeName={loadingNodeName} |
||||
node={node} |
||||
nodePath={nodePath} |
||||
nodeReason={NodeReason.Persisted} |
||||
scopes={scopes} |
||||
scopeNames={scopeNames} |
||||
type="persisted" |
||||
onNodeSelectToggle={onNodeSelectToggle} |
||||
onNodeUpdate={onNodeUpdate} |
||||
/> |
||||
|
||||
<ScopesTreeHeadline |
||||
anyChildExpanded={anyChildExpanded} |
||||
query={node.query} |
||||
resultsNodes={groupedNodes[NodeReason.Result] ?? []} |
||||
/> |
||||
|
||||
<ScopesTreeItem |
||||
anyChildExpanded={anyChildExpanded} |
||||
groupedNodes={groupedNodes} |
||||
lastExpandedNode={lastExpandedNode} |
||||
loadingNodeName={loadingNodeName} |
||||
node={node} |
||||
nodePath={nodePath} |
||||
nodeReason={NodeReason.Result} |
||||
scopes={scopes} |
||||
scopeNames={scopeNames} |
||||
type="result" |
||||
onNodeSelectToggle={onNodeSelectToggle} |
||||
onNodeUpdate={onNodeUpdate} |
||||
/> |
||||
</ScopesTreeLoading> |
||||
!tree.query && <RecentScopes recentScopes={recentScopes} onSelect={onRecentScopesSelect} />} |
||||
|
||||
{nodeLoading ? ( |
||||
<Skeleton count={5} className={styles.loader} /> |
||||
) : ( |
||||
<> |
||||
<ScopesTreeItemList |
||||
items={selectedNodesToShow} |
||||
anyChildExpanded={anyChildExpanded} |
||||
lastExpandedNode={lastExpandedNode} |
||||
loadingNodeName={loadingNodeName} |
||||
onNodeUpdate={onNodeUpdate} |
||||
selectedScopes={selectedScopes} |
||||
scopeNodes={scopeNodes} |
||||
selectScope={selectScope} |
||||
deselectScope={deselectScope} |
||||
maxHeight={`${Math.min(5, selectedNodesToShow.length) * 30}px`} |
||||
/> |
||||
|
||||
<ScopesTreeHeadline |
||||
anyChildExpanded={anyChildExpanded} |
||||
query={tree.query} |
||||
resultsNodes={childrenArray} |
||||
scopeNodes={scopeNodes} |
||||
/> |
||||
|
||||
<ScopesTreeItemList |
||||
items={childrenArray} |
||||
anyChildExpanded={anyChildExpanded} |
||||
lastExpandedNode={lastExpandedNode} |
||||
loadingNodeName={loadingNodeName} |
||||
onNodeUpdate={onNodeUpdate} |
||||
selectedScopes={selectedScopes} |
||||
scopeNodes={scopeNodes} |
||||
selectScope={selectScope} |
||||
deselectScope={deselectScope} |
||||
maxHeight={'100%'} |
||||
/> |
||||
</> |
||||
)} |
||||
</> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
loader: css({ |
||||
margin: theme.spacing(0.5, 0), |
||||
}), |
||||
}; |
||||
}; |
||||
|
@ -0,0 +1,92 @@ |
||||
import { css } from '@emotion/css'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { ScrollContainer, useStyles2 } from '@grafana/ui'; |
||||
|
||||
import { ScopesTreeItem } from './ScopesTreeItem'; |
||||
import { isNodeSelectable } from './scopesTreeUtils'; |
||||
import { NodesMap, SelectedScope, TreeNode } from './types'; |
||||
|
||||
type Props = { |
||||
anyChildExpanded: boolean; |
||||
lastExpandedNode: boolean; |
||||
loadingNodeName: string | undefined; |
||||
items: TreeNode[]; |
||||
maxHeight: string; |
||||
selectedScopes: SelectedScope[]; |
||||
scopeNodes: NodesMap; |
||||
onNodeUpdate: (scopeNodeId: string, expanded: boolean, query: string) => void; |
||||
selectScope: (scopeNodeId: string) => void; |
||||
deselectScope: (scopeNodeId: string) => void; |
||||
}; |
||||
|
||||
export function ScopesTreeItemList({ |
||||
items, |
||||
anyChildExpanded, |
||||
lastExpandedNode, |
||||
maxHeight, |
||||
selectedScopes, |
||||
scopeNodes, |
||||
loadingNodeName, |
||||
onNodeUpdate, |
||||
selectScope, |
||||
deselectScope, |
||||
}: Props) { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
if (items.length === 0) { |
||||
return null; |
||||
} |
||||
|
||||
const children = ( |
||||
<div role="tree" className={anyChildExpanded ? styles.expandedContainer : undefined}> |
||||
{items.map((childNode) => { |
||||
const selected = |
||||
isNodeSelectable(scopeNodes[childNode.scopeNodeId]) && |
||||
selectedScopes.some((s) => { |
||||
if (s.scopeNodeId) { |
||||
// If we have scopeNodeId we only match based on that so even if the actual scope is the same we don't
|
||||
// mark different scopeNode as selected.
|
||||
return s.scopeNodeId === childNode.scopeNodeId; |
||||
} else { |
||||
return s.scopeId === scopeNodes[childNode.scopeNodeId]?.spec.linkId; |
||||
} |
||||
}); |
||||
return ( |
||||
<ScopesTreeItem |
||||
key={childNode.scopeNodeId} |
||||
treeNode={childNode} |
||||
selected={selected} |
||||
selectedScopes={selectedScopes} |
||||
scopeNodes={scopeNodes} |
||||
loadingNodeName={loadingNodeName} |
||||
anyChildExpanded={anyChildExpanded} |
||||
onNodeUpdate={onNodeUpdate} |
||||
selectScope={selectScope} |
||||
deselectScope={deselectScope} |
||||
/> |
||||
); |
||||
})} |
||||
</div> |
||||
); |
||||
|
||||
if (lastExpandedNode) { |
||||
return ( |
||||
<ScrollContainer minHeight={`${Math.min(5, items.length) * 30}px`} maxHeight={maxHeight}> |
||||
{children} |
||||
</ScrollContainer> |
||||
); |
||||
} |
||||
|
||||
return children; |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
expandedContainer: css({ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
maxHeight: '100%', |
||||
}), |
||||
}; |
||||
}; |
@ -1,29 +0,0 @@ |
||||
import { css } from '@emotion/css'; |
||||
import { ReactNode } from 'react'; |
||||
import Skeleton from 'react-loading-skeleton'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { useStyles2 } from '@grafana/ui'; |
||||
|
||||
export interface ScopesTreeLoadingProps { |
||||
children: ReactNode; |
||||
nodeLoading: boolean; |
||||
} |
||||
|
||||
export function ScopesTreeLoading({ children, nodeLoading }: ScopesTreeLoadingProps) { |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
if (nodeLoading) { |
||||
return <Skeleton count={5} className={styles.loader} />; |
||||
} |
||||
|
||||
return children; |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => { |
||||
return { |
||||
loader: css({ |
||||
margin: theme.spacing(0.5, 0), |
||||
}), |
||||
}; |
||||
}; |
@ -0,0 +1,206 @@ |
||||
import { ScopeNode } from '@grafana/data'; |
||||
|
||||
import { |
||||
closeNodes, |
||||
expandNodes, |
||||
isNodeExpandable, |
||||
isNodeSelectable, |
||||
getPathOfNode, |
||||
modifyTreeNodeAtPath, |
||||
treeNodeAtPath, |
||||
} from './scopesTreeUtils'; |
||||
import { TreeNode, NodesMap } from './types'; |
||||
|
||||
describe('scopesTreeUtils', () => { |
||||
describe('closeNodes', () => { |
||||
it('should create a deep copy with all nodes closed', () => { |
||||
const tree: TreeNode = { |
||||
expanded: true, |
||||
scopeNodeId: 'root', |
||||
query: '', |
||||
children: { |
||||
child1: { |
||||
expanded: true, |
||||
scopeNodeId: 'child1', |
||||
query: '', |
||||
children: { |
||||
grandchild1: { |
||||
expanded: true, |
||||
scopeNodeId: 'grandchild1', |
||||
query: '', |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
const result = closeNodes(tree); |
||||
|
||||
expect(result.expanded).toBe(false); |
||||
expect(result.children?.child1.expanded).toBe(false); |
||||
expect(result.children?.child1.children?.grandchild1.expanded).toBe(false); |
||||
// Verify it's a deep copy
|
||||
expect(result).not.toBe(tree); |
||||
expect(result.children).not.toBe(tree.children); |
||||
}); |
||||
}); |
||||
|
||||
describe('expandNodes', () => { |
||||
it('should expand nodes along the specified path', () => { |
||||
const tree: TreeNode = { |
||||
expanded: false, |
||||
scopeNodeId: 'root', |
||||
query: '', |
||||
children: { |
||||
child1: { |
||||
expanded: false, |
||||
scopeNodeId: 'child1', |
||||
query: '', |
||||
children: { |
||||
grandchild1: { |
||||
expanded: false, |
||||
scopeNodeId: 'grandchild1', |
||||
query: '', |
||||
}, |
||||
|
||||
grandchild2: { |
||||
expanded: false, |
||||
scopeNodeId: 'grandchild2', |
||||
query: '', |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
const path = ['', 'child1', 'grandchild1']; |
||||
const result = expandNodes(tree, path); |
||||
|
||||
expect(result.expanded).toBe(true); |
||||
expect(result.children?.child1.expanded).toBe(true); |
||||
expect(result.children?.child1.children?.grandchild1.expanded).toBe(true); |
||||
// Other nodes don't get expanded
|
||||
expect(result.children?.child1.children?.grandchild2.expanded).toBe(false); |
||||
}); |
||||
|
||||
it('should throw error when path contains non-existent node', () => { |
||||
const tree: TreeNode = { |
||||
expanded: false, |
||||
scopeNodeId: 'root', |
||||
query: '', |
||||
children: {}, |
||||
}; |
||||
|
||||
expect(() => expandNodes(tree, ['', 'nonexistent'])).toThrow('Node nonexistent not found in tree'); |
||||
}); |
||||
}); |
||||
|
||||
describe('isNodeExpandable', () => { |
||||
it('should return true for container nodes', () => { |
||||
const node = { spec: { nodeType: 'container' } } as ScopeNode; |
||||
expect(isNodeExpandable(node)).toBe(true); |
||||
}); |
||||
|
||||
it('should return false for non-container nodes', () => { |
||||
const node = { spec: { nodeType: 'leaf' } } as ScopeNode; |
||||
expect(isNodeExpandable(node)).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe('isNodeSelectable', () => { |
||||
it('should return true for scope nodes', () => { |
||||
const node = { spec: { linkType: 'scope' } } as ScopeNode; |
||||
expect(isNodeSelectable(node)).toBe(true); |
||||
}); |
||||
|
||||
it('should return false for non-scope nodes', () => { |
||||
const node = { spec: { linkType: undefined } } as ScopeNode; |
||||
expect(isNodeSelectable(node)).toBe(false); |
||||
}); |
||||
}); |
||||
|
||||
describe('getPathOfNode', () => { |
||||
it('should return correct path for nested node', () => { |
||||
const nodes: NodesMap = { |
||||
root: { spec: { parentName: '' } } as ScopeNode, |
||||
child: { spec: { parentName: 'root' } } as ScopeNode, |
||||
grandchild: { spec: { parentName: 'child' } } as ScopeNode, |
||||
}; |
||||
|
||||
const path = getPathOfNode('grandchild', nodes); |
||||
expect(path).toEqual(['', 'root', 'child', 'grandchild']); |
||||
}); |
||||
}); |
||||
|
||||
describe('modifyTreeNodeAtPath', () => { |
||||
it('should modify node at specified path', () => { |
||||
const tree: TreeNode = { |
||||
expanded: false, |
||||
scopeNodeId: 'root', |
||||
query: '', |
||||
children: { |
||||
child1: { |
||||
expanded: false, |
||||
scopeNodeId: 'child1', |
||||
query: '', |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
const result = modifyTreeNodeAtPath(tree, ['', 'child1'], (node) => { |
||||
node.expanded = true; |
||||
node.query = 'test'; |
||||
}); |
||||
|
||||
expect(result.children?.child1.expanded).toBe(true); |
||||
expect(result.children?.child1.query).toBe('test'); |
||||
}); |
||||
|
||||
it('should return original tree if path is invalid', () => { |
||||
const tree: TreeNode = { |
||||
expanded: false, |
||||
scopeNodeId: 'root', |
||||
query: '', |
||||
children: {}, |
||||
}; |
||||
|
||||
const result = modifyTreeNodeAtPath(tree, ['', 'nonexistent'], (node) => { |
||||
node.expanded = true; |
||||
}); |
||||
|
||||
expect(result).toEqual(tree); |
||||
}); |
||||
}); |
||||
|
||||
describe('treeNodeAtPath', () => { |
||||
it('should return node at specified path', () => { |
||||
const tree: TreeNode = { |
||||
expanded: false, |
||||
scopeNodeId: 'root', |
||||
query: '', |
||||
children: { |
||||
child1: { |
||||
expanded: false, |
||||
scopeNodeId: 'child1', |
||||
query: '', |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
const result = treeNodeAtPath(tree, ['', 'child1']); |
||||
expect(result).toBe(tree.children?.child1); |
||||
}); |
||||
|
||||
it('should return undefined for invalid path', () => { |
||||
const tree: TreeNode = { |
||||
expanded: false, |
||||
scopeNodeId: 'root', |
||||
query: '', |
||||
children: {}, |
||||
}; |
||||
|
||||
const result = treeNodeAtPath(tree, ['', 'nonexistent']); |
||||
expect(result).toBeUndefined(); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,111 @@ |
||||
import { ScopeNode } from '@grafana/data'; |
||||
|
||||
import { NodesMap, TreeNode } from './types'; |
||||
|
||||
/** |
||||
* Creates a deep copy of the node tree with expanded prop set to false. |
||||
*/ |
||||
export function closeNodes(tree: TreeNode): TreeNode { |
||||
const node = { ...tree }; |
||||
node.expanded = false; |
||||
if (node.children) { |
||||
node.children = { ...node.children }; |
||||
for (const key of Object.keys(node.children)) { |
||||
node.children[key] = closeNodes(node.children[key]); |
||||
} |
||||
} |
||||
return node; |
||||
} |
||||
|
||||
export function expandNodes(tree: TreeNode, path: string[]): TreeNode { |
||||
let newTree = { ...tree }; |
||||
let currentTree = newTree; |
||||
currentTree.expanded = true; |
||||
// Remove the root segment
|
||||
const newPath = path.slice(1); |
||||
|
||||
for (const segment of newPath) { |
||||
const node = currentTree.children?.[segment]; |
||||
if (!node) { |
||||
throw new Error(`Node ${segment} not found in tree`); |
||||
} |
||||
|
||||
const newNode = { ...node }; |
||||
currentTree.children = { ...currentTree.children }; |
||||
currentTree.children[segment] = newNode; |
||||
newNode.expanded = true; |
||||
currentTree = newNode; |
||||
} |
||||
|
||||
return newTree; |
||||
} |
||||
|
||||
export function isNodeExpandable(node: ScopeNode) { |
||||
return node.spec.nodeType === 'container'; |
||||
} |
||||
|
||||
export function isNodeSelectable(node: ScopeNode) { |
||||
return node.spec.linkType === 'scope'; |
||||
} |
||||
|
||||
export function getPathOfNode(scopeNodeId: string, nodes: NodesMap): string[] { |
||||
if (scopeNodeId === '') { |
||||
return ['']; |
||||
} |
||||
const path = [scopeNodeId]; |
||||
let parent = nodes[scopeNodeId]?.spec.parentName; |
||||
while (parent) { |
||||
path.unshift(parent); |
||||
parent = nodes[parent]?.spec.parentName; |
||||
} |
||||
path.unshift(''); |
||||
return path; |
||||
} |
||||
|
||||
export function modifyTreeNodeAtPath(tree: TreeNode, path: string[], modifier: (treeNode: TreeNode) => void) { |
||||
if (path.length < 1) { |
||||
return tree; |
||||
} |
||||
|
||||
const newTree = { ...tree }; |
||||
let currentNode = newTree; |
||||
|
||||
if (path.length === 1 && path[0] === '') { |
||||
modifier(currentNode); |
||||
return newTree; |
||||
} |
||||
|
||||
for (const section of path.slice(1)) { |
||||
if (!currentNode.children?.[section]) { |
||||
return newTree; |
||||
} |
||||
|
||||
currentNode.children = { ...currentNode.children }; |
||||
currentNode.children[section] = { ...currentNode.children[section] }; |
||||
currentNode = currentNode.children[section]; |
||||
} |
||||
|
||||
modifier(currentNode); |
||||
return newTree; |
||||
} |
||||
|
||||
export function treeNodeAtPath(tree: TreeNode, path: string[]) { |
||||
if (path.length < 1) { |
||||
return undefined; |
||||
} |
||||
|
||||
if (path.length === 1 && path[0] === '') { |
||||
return tree; |
||||
} |
||||
|
||||
let treeNode: TreeNode | undefined = tree; |
||||
|
||||
for (const section of path.slice(1)) { |
||||
treeNode = treeNode.children?.[section]; |
||||
if (!treeNode) { |
||||
return undefined; |
||||
} |
||||
} |
||||
|
||||
return treeNode; |
||||
} |
Loading…
Reference in new issue