diff --git a/packages/trace-viewer/src/ui/actionList.tsx b/packages/trace-viewer/src/ui/actionList.tsx index a948377c30..dd394cbc3a 100644 --- a/packages/trace-viewer/src/ui/actionList.tsx +++ b/packages/trace-viewer/src/ui/actionList.tsx @@ -14,7 +14,7 @@ limitations under the License. */ -import type { ActionTraceEvent } from '@trace/trace'; +import { ActionTraceEvent } from '@trace/trace'; import { msToString } from '@web/uiUtils'; import { ListView } from '@web/components/listView'; import * as React from 'react'; @@ -32,6 +32,8 @@ export interface ActionListProps { revealConsole: () => void, } +const ActionListView = ListView; + export const ActionList: React.FC = ({ actions = [], selectedAction, @@ -40,16 +42,16 @@ export const ActionList: React.FC = ({ onHighlighted = () => {}, revealConsole = () => {}, }) => { - return action.callId} selectedItem={selectedAction} - onSelected={(action: ActionTraceEvent) => onSelected(action)} - onHighlighted={(action: ActionTraceEvent) => onHighlighted(action)} - itemKey={(action: ActionTraceEvent) => action.callId} - itemType={(action: ActionTraceEvent) => action.error?.message ? 'error' : undefined} - itemRender={(action: ActionTraceEvent) => renderAction(action, sdkLanguage, revealConsole)} + onSelected={onSelected} + onHighlighted={onHighlighted} + isError={action => !!action.error?.message} + render={action => renderAction(action, sdkLanguage, revealConsole)} noItemsMessage='No actions' - >; + />; }; const renderAction = ( diff --git a/packages/trace-viewer/src/ui/stackTrace.tsx b/packages/trace-viewer/src/ui/stackTrace.tsx index ffcc97bcd6..2792e75301 100644 --- a/packages/trace-viewer/src/ui/stackTrace.tsx +++ b/packages/trace-viewer/src/ui/stackTrace.tsx @@ -18,6 +18,10 @@ import * as React from 'react'; import './stackTrace.css'; import type { ActionTraceEvent } from '@trace/trace'; import { ListView } from '@web/components/listView'; +// eslint-disable-next-line @typescript-eslint/consistent-type-imports +import type { StackFrame } from '@protocol/channels'; + +const StackFrameListView = ListView; export const StackTraceView: React.FunctionComponent<{ action: ActionTraceEvent | undefined, @@ -25,11 +29,11 @@ export const StackTraceView: React.FunctionComponent<{ setSelectedFrame: (index: number) => void }> = ({ action, setSelectedFrame, selectedFrame }) => { const frames = action?.stack || []; - return { + render={frame => { const pathSep = frame.file[1] === ':' ? '\\' : '/'; return <> diff --git a/packages/trace-viewer/src/ui/watchMode.tsx b/packages/trace-viewer/src/ui/watchMode.tsx index c43bcff34f..95c3c0d484 100644 --- a/packages/trace-viewer/src/ui/watchMode.tsx +++ b/packages/trace-viewer/src/ui/watchMode.tsx @@ -18,7 +18,8 @@ import '@web/third_party/vscode/codicon.css'; import { Workbench } from './workbench'; import '@web/common.css'; import React from 'react'; -import { ListView } from '@web/components/listView'; +import { TreeView } from '@web/components/treeView'; +import type { TreeState } from '@web/components/treeView'; import { TeleReporterReceiver } from '../../../playwright-test/src/isomorphic/teleReceiver'; import type { FullConfig, Suite, TestCase, TestResult, TestStep, Location } from '../../../playwright-test/types/testReporter'; import { SplitView } from '@web/components/splitView'; @@ -50,7 +51,7 @@ export const WatchModeView: React.FC<{}> = ({ const [rootSuite, setRootSuite] = React.useState<{ value: Suite | undefined }>({ value: undefined }); const [isRunningTest, setIsRunningTest] = React.useState(false); const [progress, setProgress] = React.useState({ total: 0, passed: 0, failed: 0 }); - const [selectedTestItem, setSelectedTestItem] = React.useState(undefined); + const [selectedTest, setSelectedTest] = React.useState(undefined); const [settingsVisible, setSettingsVisible] = React.useState(false); const [isWatchingFiles, setIsWatchingFiles] = React.useState(true); @@ -84,7 +85,7 @@ export const WatchModeView: React.FC<{}> = ({ return
- +
setSettingsVisible(false)}>Tests
@@ -101,7 +102,7 @@ export const WatchModeView: React.FC<{}> = ({ isRunningTest={isRunningTest} isWatchingFiles={isWatchingFiles} runTests={runTests} - onTestItemSelected={setSelectedTestItem} + onTestSelected={setSelectedTest} isVisible={!settingsVisible} /> {settingsVisible && setSettingsVisible(false)}>}
@@ -112,6 +113,8 @@ export const WatchModeView: React.FC<{}> = ({
; }; +const TreeListView = TreeView; + export const TestList: React.FC<{ projects: Map, rootSuite: { value: Suite | undefined }, @@ -119,11 +122,11 @@ export const TestList: React.FC<{ isRunningTest: boolean, isWatchingFiles: boolean, isVisible: boolean - onTestItemSelected: (test: TestItem | undefined) => void, -}> = ({ projects, rootSuite, runTests, isRunningTest, isWatchingFiles, isVisible, onTestItemSelected }) => { + onTestSelected: (test: TestCase | undefined) => void, +}> = ({ projects, rootSuite, runTests, isRunningTest, isWatchingFiles, isVisible, onTestSelected }) => { + const [treeState, setTreeState] = React.useState({ expandedItems: new Map() }); const [filterText, setFilterText] = React.useState(''); const [selectedTreeItemId, setSelectedTreeItemId] = React.useState(); - const [expandedItems, setExpandedItems] = React.useState>(new Map()); const inputRef = React.useRef(null); React.useEffect(() => { @@ -131,10 +134,9 @@ export const TestList: React.FC<{ refreshRootSuite(true); }, []); - const { filteredItems, treeItemMap, visibleTestIds } = React.useMemo(() => { - const treeItems = createTree(rootSuite.value, projects); - const filteredItems = filterTree(treeItems, filterText); - + const { rootItem, treeItemMap, visibleTestIds } = React.useMemo(() => { + const rootItem = createTree(rootSuite.value, projects); + filterTree(rootItem, filterText); const treeItemMap = new Map(); const visibleTestIds = new Set(); const visit = (treeItem: TreeItem) => { @@ -143,35 +145,30 @@ export const TestList: React.FC<{ treeItem.children?.forEach(visit); treeItemMap.set(treeItem.id, treeItem); }; - filteredItems.forEach(visit); - return { treeItemMap, visibleTestIds, filteredItems }; + visit(rootItem); + hideOnlyTests(rootItem); + return { rootItem, treeItemMap, visibleTestIds }; }, [filterText, rootSuite, projects]); runVisibleTests = () => runTests([...visibleTestIds]); - const { listItems } = React.useMemo(() => { - const listItems = flattenTree(filteredItems, expandedItems, !!filterText.trim()); - return { listItems }; - }, [filteredItems, filterText, expandedItems]); - - const { selectedTreeItem, selectedTestItem } = React.useMemo(() => { + const { selectedTreeItem } = React.useMemo(() => { const selectedTreeItem = selectedTreeItemId ? treeItemMap.get(selectedTreeItemId) : undefined; - let selectedTestItem: TestItem | undefined; + let selectedTest: TestCase | undefined; if (selectedTreeItem?.kind === 'test') - selectedTestItem = selectedTreeItem; - else if (selectedTreeItem?.kind === 'case' && selectedTreeItem.children?.length === 1) - selectedTestItem = selectedTreeItem.children[0]! as TestItem; - return { selectedTreeItem, selectedTestItem }; - }, [selectedTreeItemId, treeItemMap]); + selectedTest = selectedTreeItem.test; + else if (selectedTreeItem?.kind === 'case' && selectedTreeItem.tests.length === 1) + selectedTest = selectedTreeItem.tests[0]; + onTestSelected(selectedTest); + return { selectedTreeItem }; + }, [onTestSelected, selectedTreeItemId, treeItemMap]); React.useEffect(() => { sendMessageNoReply('watch', { fileName: isWatchingFiles ? fileName(selectedTreeItem) : undefined }); }, [selectedTreeItem, isWatchingFiles]); - onTestItemSelected(selectedTestItem); - const runTreeItem = (treeItem: TreeItem) => { - expandedItems.set(treeItem.id, true); + // expandedItems.set(treeItem.id, true); setSelectedTreeItemId(treeItem.id); runTests(collectTestIds(treeItem)); }; @@ -194,19 +191,35 @@ export const TestList: React.FC<{ runVisibleTests(); }}> - treeItem.id } - itemRender={(treeItem: TreeItem) => { + { return
{treeItem.title}
runTreeItem(treeItem)} disabled={isRunningTest}> sendMessageNoReply('open', { location: locationToOpen(treeItem) })}>
; }} - itemIcon={(treeItem: TreeItem) => { - if (treeItem.kind === 'case' && treeItem.children?.length === 1) - treeItem = treeItem.children[0]; + icon={treeItem => { + if (treeItem.kind === 'case') { + let allOk = true; + let hasFailed = false; + let hasRunning = false; + for (const test of treeItem.tests) { + allOk = allOk && test.outcome() === 'expected'; + hasFailed = hasFailed || (!!test.results.length && test.outcome() !== 'expected'); + hasRunning = hasRunning || test.results.some(r => r.duration === -1); + } + if (hasRunning) + return 'codicon-loading'; + if (allOk) + return 'codicon-check'; + if (hasFailed) + return 'codicon-error'; + } + if (treeItem.kind === 'test') { const ok = treeItem.test.outcome() === 'expected'; const failed = treeItem.test.results.length && treeItem.test.outcome() !== 'expected'; @@ -217,39 +230,14 @@ export const TestList: React.FC<{ return 'codicon-check'; if (failed) return 'codicon-error'; - } else { - return treeItem.expanded ? 'codicon-chevron-down' : 'codicon-chevron-right'; } + return 'codicon-circle-outline'; }} - itemIndent={(treeItem: TreeItem) => treeItem.kind === 'file' ? 0 : treeItem.kind === 'case' ? 1 : 2} selectedItem={selectedTreeItem} onAccepted={runTreeItem} - onLeftArrow={(treeItem: TreeItem) => { - if (treeItem.children && treeItem.expanded) { - expandedItems.set(treeItem.id, false); - setExpandedItems(new Map(expandedItems)); - } else { - setSelectedTreeItemId(treeItem.parent?.id); - } - }} - onRightArrow={(treeItem: TreeItem) => { - if (treeItem.children) { - expandedItems.set(treeItem.id, true); - setExpandedItems(new Map(expandedItems)); - } - }} - onSelected={(treeItem: TreeItem) => { + onSelected={treeItem => { setSelectedTreeItemId(treeItem.id); }} - onIconClicked={(treeItem: TreeItem) => { - if (treeItem.kind === 'test') - return; - if (treeItem.expanded) - expandedItems.set(treeItem.id, false); - else - expandedItems.set(treeItem.id, true); - setExpandedItems(new Map(expandedItems)); - }} noItemsMessage='No tests' /> ; }; @@ -287,20 +275,20 @@ export const SettingsView: React.FC<{ }; export const TraceView: React.FC<{ - testItem: TestItem | undefined, -}> = ({ testItem }) => { + test: TestCase | undefined, +}> = ({ test }) => { const [model, setModel] = React.useState(); const [stepsProgress, setStepsProgress] = React.useState(0); updateStepsProgress = () => setStepsProgress(stepsProgress + 1); React.useEffect(() => { (async () => { - if (!testItem) { + if (!test) { setModel(undefined); return; } - const result = testItem.test?.results?.[0]; + const result = test.results?.[0]; if (result) { const attachment = result.attachments.find(a => a.name === 'trace'); if (attachment && attachment.path) @@ -311,7 +299,7 @@ export const TraceView: React.FC<{ setModel(undefined); } })(); - }, [testItem, stepsProgress]); + }, [test, stepsProgress]); const xterm = ; return { return []; const testIds: string[] = []; const visit = (treeItem: TreeItem) => { - if (treeItem.kind === 'test') + if (treeItem.kind === 'case') + testIds.push(...treeItem.tests.map(t => t.id)); + else if (treeItem.kind === 'test') testIds.push(treeItem.id); - treeItem.children?.forEach(visit); + else + treeItem.children?.forEach(visit); }; visit(treeItem); return testIds; @@ -445,22 +436,28 @@ type Progress = { }; type TreeItemBase = { - kind: 'file' | 'case' | 'test', + kind: 'root' | 'file' | 'case' | 'test', id: string; title: string; parent: TreeItem | null; - children?: TreeItem[]; + children: TreeItem[]; expanded?: boolean; }; +type RootItem = TreeItemBase & { + kind: 'root', + children: FileItem[]; +}; + type FileItem = TreeItemBase & { kind: 'file', file: string; - children?: TestCaseItem[]; + children: TestCaseItem[]; }; type TestCaseItem = TreeItemBase & { kind: 'case', + tests: TestCase[]; location: Location, }; @@ -469,9 +466,16 @@ type TestItem = TreeItemBase & { test: TestCase; }; -type TreeItem = FileItem | TestCaseItem | TestItem; +type TreeItem = RootItem | FileItem | TestCaseItem | TestItem; -function createTree(rootSuite: Suite | undefined, projects: Map): FileItem[] { +function createTree(rootSuite: Suite | undefined, projects: Map): RootItem { + const rootItem: RootItem = { + kind: 'root', + id: 'root', + title: '', + parent: null, + children: [], + }; const fileItems = new Map(); for (const projectSuite of rootSuite?.suites || []) { if (!projects.get(projectSuite.title)) @@ -491,11 +495,12 @@ function createTree(rootSuite: Suite | undefined, projects: Map expanded: false, }; fileItems.set(fileSuite.location!.file, fileItem); + rootItem.children.push(fileItem); } for (const test of fileSuite.allTests()) { const title = test.titlePath().slice(3).join(' › '); - let testCaseItem = fileItem.children!.find(t => t.title === title); + let testCaseItem = fileItem.children.find(t => t.title === title) as TestCaseItem; if (!testCaseItem) { testCaseItem = { kind: 'case', @@ -503,62 +508,56 @@ function createTree(rootSuite: Suite | undefined, projects: Map title, parent: fileItem, children: [], + tests: [], expanded: false, location: test.location, }; - fileItem.children!.push(testCaseItem); + fileItem.children.push(testCaseItem); } - testCaseItem.children!.push({ + testCaseItem.tests.push(test); + testCaseItem.children.push({ kind: 'test', id: test.id, title: projectSuite.title, parent: testCaseItem, test, + children: [], }); } (fileItem.children as TestCaseItem[]).sort((a, b) => a.location.line - b.location.line); } } - return [...fileItems.values()]; + return rootItem; } -function filterTree(fileItems: FileItem[], filterText: string): FileItem[] { +function filterTree(rootItem: RootItem, filterText: string) { const trimmedFilterText = filterText.trim(); const filterTokens = trimmedFilterText.toLowerCase().split(' '); const result: FileItem[] = []; - for (const fileItem of fileItems) { + for (const fileItem of rootItem.children) { if (trimmedFilterText) { const filteredCases: TestCaseItem[] = []; - for (const testCaseItem of fileItem.children!) { + for (const testCaseItem of fileItem.children) { const fullTitle = (fileItem.title + ' ' + testCaseItem.title).toLowerCase(); if (filterTokens.every(token => fullTitle.includes(token))) filteredCases.push(testCaseItem); } fileItem.children = filteredCases; } - if (fileItem.children!.length) + if (fileItem.children.length) result.push(fileItem); } - return result; + rootItem.children = result; } -function flattenTree(fileItems: FileItem[], expandedItems: Map, hasFilter: boolean): TreeItem[] { - const result: TreeItem[] = []; - for (const fileItem of fileItems) { - result.push(fileItem); - const expandState = expandedItems.get(fileItem.id); - const autoExpandMatches = result.length < 100 && (hasFilter && expandState !== false); - fileItem.expanded = expandState || autoExpandMatches || fileItems.length < 10; - if (fileItem.expanded) { - for (const testCaseItem of fileItem.children!) { - result.push(testCaseItem); - testCaseItem.expanded = !!expandedItems.get(testCaseItem.id); - if (testCaseItem.expanded && testCaseItem.children!.length > 1) - result.push(...testCaseItem.children!); - } - } - } - return result; +function hideOnlyTests(rootItem: RootItem) { + const visit = (treeItem: TreeItem) => { + if (treeItem.kind === 'case' && treeItem.children.length === 1) + treeItem.children = []; + else + treeItem.children.forEach(visit); + }; + visit(rootItem); } async function loadSingleTraceFile(url: string): Promise { diff --git a/packages/web/src/common.css b/packages/web/src/common.css index 83718ccc49..3fd325fc84 100644 --- a/packages/web/src/common.css +++ b/packages/web/src/common.css @@ -109,6 +109,10 @@ svg { color: var(--red); } +.codicon-circle-outline { + color: var(--vscode-disabledForeground); +} + input[type=text], input[type=search] { color: var(--vscode-input-foreground); background-color: var(--vscode-input-background); diff --git a/packages/web/src/components/listView.tsx b/packages/web/src/components/listView.tsx index 5b809e1147..6865e30dc2 100644 --- a/packages/web/src/components/listView.tsx +++ b/packages/web/src/components/listView.tsx @@ -17,31 +17,31 @@ import * as React from 'react'; import './listView.css'; -export type ListViewProps = { - items: any[], - itemRender: (item: any) => React.ReactNode, - itemKey?: (item: any) => string, - itemIcon?: (item: any) => string | undefined, - itemIndent?: (item: any) => number | undefined, - itemType?: (item: any) => 'error' | undefined, - selectedItem?: any, - onAccepted?: (item: any) => void, - onSelected?: (item: any) => void, - onLeftArrow?: (item: any) => void, - onRightArrow?: (item: any) => void, - onHighlighted?: (item: any | undefined) => void, - onIconClicked?: (item: any) => void, +export type ListViewProps = { + items: T[], + id?: (item: T) => string, + render: (item: T) => React.ReactNode, + icon?: (item: T) => string | undefined, + indent?: (item: T) => number | undefined, + isError?: (item: T) => boolean, + selectedItem?: T, + onAccepted?: (item: T) => void, + onSelected?: (item: T) => void, + onLeftArrow?: (item: T) => void, + onRightArrow?: (item: T) => void, + onHighlighted?: (item: T | undefined) => void, + onIconClicked?: (item: T) => void, noItemsMessage?: string, dataTestId?: string, }; -export const ListView: React.FC = ({ +export function ListView({ items = [], - itemKey, - itemRender, - itemIcon, - itemType, - itemIndent, + id, + render, + icon, + isError, + indent, selectedItem, onAccepted, onSelected, @@ -51,17 +51,21 @@ export const ListView: React.FC = ({ onIconClicked, noItemsMessage, dataTestId, -}) => { +}: ListViewProps) { const itemListRef = React.createRef(); const [highlightedItem, setHighlightedItem] = React.useState(); + React.useEffect(() => { + onHighlighted?.(highlightedItem); + }, [onHighlighted, highlightedItem]); + return
onAccepted?.(selectedItem)} + onDoubleClick={() => selectedItem && onAccepted?.(selectedItem)} onKeyDown={event => { - if (event.key === 'Enter') { + if (selectedItem && event.key === 'Enter') { onAccepted?.(selectedItem); return; } @@ -71,11 +75,11 @@ export const ListView: React.FC = ({ event.stopPropagation(); event.preventDefault(); - if (event.key === 'ArrowLeft') { + if (selectedItem && event.key === 'ArrowLeft') { onLeftArrow?.(selectedItem); return; } - if (event.key === 'ArrowRight') { + if (selectedItem && event.key === 'ArrowRight') { onRightArrow?.(selectedItem); return; } @@ -96,81 +100,40 @@ export const ListView: React.FC = ({ } const element = itemListRef.current?.children.item(newIndex); - scrollIntoViewIfNeeded(element); + scrollIntoViewIfNeeded(element || undefined); onHighlighted?.(undefined); onSelected?.(items[newIndex]); }} ref={itemListRef} > {noItemsMessage && items.length === 0 &&
{noItemsMessage}
} - {items.map((item, index) => onSelected?.(item)} - onMouseEnter={() => { - setHighlightedItem(item); - onHighlighted?.(item); - }} - onMouseLeave={() => { - setHighlightedItem(undefined); - onHighlighted?.(undefined); - }} - onIconClicked={() => onIconClicked?.(item)} - > - {itemRender(item)} - )} + {items.map((item, index) => { + const selectedSuffix = selectedItem === item ? ' selected' : ''; + const highlightedSuffix = highlightedItem === item ? ' highlighted' : ''; + const errorSuffix = isError?.(item) ? ' error' : ''; + const indentation = indent?.(item) || 0; + const rendered = render(item); + return
onSelected?.(item)} + onMouseEnter={() => setHighlightedItem(item)} + onMouseLeave={() => setHighlightedItem(undefined)} + > + {indentation ?
: undefined} + {icon &&
{ + e.stopPropagation(); + e.preventDefault(); + onIconClicked?.(item); + }}>
} + {typeof rendered === 'string' ?
{rendered}
: rendered} +
; + })}
; -}; +} -const ListItemView: React.FC<{ - key: string, - hasIcons: boolean, - icon: string | undefined, - type: 'error' | undefined, - indent: number | undefined, - isHighlighted: boolean, - isSelected: boolean, - onSelected: () => void, - onMouseEnter: () => void, - onMouseLeave: () => void, - onIconClicked: () => void, - children: React.ReactNode | React.ReactNode[], -}> = ({ key, hasIcons, icon, type, indent, onSelected, onMouseEnter, onMouseLeave, onIconClicked, isHighlighted, isSelected, children }) => { - const selectedSuffix = isSelected ? ' selected' : ''; - const highlightedSuffix = isHighlighted ? ' highlighted' : ''; - const errorSuffix = type === 'error' ? ' error' : ''; - const divRef = React.useRef(null); - - React.useEffect(() => { - if (divRef.current && isSelected) - scrollIntoViewIfNeeded(divRef.current); - }, [isSelected]); - - return
- {indent ?
: undefined} - {hasIcons &&
{ - e.stopPropagation(); - e.preventDefault(); - onIconClicked(); - }}>
} - {typeof children === 'string' ?
{children}
: children} -
; -}; - -function scrollIntoViewIfNeeded(element?: Element | null) { +function scrollIntoViewIfNeeded(element: Element | undefined) { if (!element) return; if ((element as any)?.scrollIntoViewIfNeeded) diff --git a/packages/web/src/components/treeView.tsx b/packages/web/src/components/treeView.tsx new file mode 100644 index 0000000000..8f96e760d4 --- /dev/null +++ b/packages/web/src/components/treeView.tsx @@ -0,0 +1,130 @@ +/* + Copyright (c) Microsoft Corporation. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +import * as React from 'react'; +import { ListView } from './listView'; + +export type TreeItem = { + id: string, + children: TreeItem[], +}; + +export type TreeState = { + expandedItems: Map; +}; + +export type TreeViewProps = { + rootItem: T, + render: (item: T) => React.ReactNode, + icon?: (item: T) => string | undefined, + isError?: (item: T) => boolean, + selectedItem?: T, + onAccepted?: (item: T) => void, + onSelected?: (item: T) => void, + onHighlighted?: (item: T | undefined) => void, + noItemsMessage?: string, + dataTestId?: string, + treeState: TreeState, + setTreeState: (treeState: TreeState) => void, +}; + +const TreeListView = ListView; + +export function TreeView({ + rootItem, + render, + icon, + isError, + selectedItem, + onAccepted, + onSelected, + onHighlighted, + treeState, + setTreeState, + noItemsMessage, +}: TreeViewProps) { + const treeItems = React.useMemo(() => { + return flattenTree(rootItem, treeState.expandedItems); + }, [rootItem, treeState]); + + return item.id} + render={item => { + const rendered = render(item as T); + return <> + {icon &&
} + {typeof rendered === 'string' ?
{rendered}
: rendered} + ; + }} + icon={item => { + const expanded = treeItems.get(item as T)!.expanded; + if (typeof expanded === 'boolean') + return expanded ? 'codicon-chevron-down' : 'codicon-chevron-right'; + }} + isError={item => isError?.(item as T) || false} + indent={item => treeItems.get(item as T)!.depth} + selectedItem={selectedItem} + onAccepted={item => onAccepted?.(item as T)} + onSelected={item => onSelected?.(item as T)} + onHighlighted={item => onHighlighted?.(item as T)} + onLeftArrow={item => { + const { expanded, parent } = treeItems.get(item as T)!; + if (expanded) { + treeState.expandedItems.set(item.id, false); + setTreeState({ ...treeState }); + } else if (parent) { + onSelected?.(parent as T); + } + }} + onRightArrow={item => { + if (item.children.length) { + treeState.expandedItems.set(item.id, true); + setTreeState({ ...treeState }); + } + }} + onIconClicked={item => { + const { expanded } = treeItems.get(item as T)!; + if (expanded) + treeState.expandedItems.set(item.id, false); + else + treeState.expandedItems.set(item.id, true); + setTreeState({ ...treeState }); + }} + noItemsMessage={noItemsMessage} />; +} + +type TreeItemData = { + depth: number, + expanded: boolean | undefined, + parent: TreeItem | null, +}; + +function flattenTree(rootItem: T, expandedItems: Map): Map { + const result = new Map(); + const appendChildren = (parent: T, depth: number) => { + for (const item of parent.children as T[]) { + const expandState = expandedItems.get(item.id); + const autoExpandMatches = depth === 0 && result.size < 25 && expandState !== false; + const expanded = item.children.length ? expandState || autoExpandMatches : undefined; + result.set(item, { depth, expanded, parent: rootItem === parent ? null : parent }); + if (expanded) + appendChildren(item, depth + 1); + } + }; + appendChildren(rootItem, 0); + return result; +}