mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
chore: introduce tree control (#21505)
This commit is contained in:
parent
a2490a8fc8
commit
adc895d31f
@ -14,7 +14,7 @@
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ActionTraceEvent } from '@trace/trace';
|
import { ActionTraceEvent } from '@trace/trace';
|
||||||
import { msToString } from '@web/uiUtils';
|
import { msToString } from '@web/uiUtils';
|
||||||
import { ListView } from '@web/components/listView';
|
import { ListView } from '@web/components/listView';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
@ -32,6 +32,8 @@ export interface ActionListProps {
|
|||||||
revealConsole: () => void,
|
revealConsole: () => void,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ActionListView = ListView<ActionTraceEvent>;
|
||||||
|
|
||||||
export const ActionList: React.FC<ActionListProps> = ({
|
export const ActionList: React.FC<ActionListProps> = ({
|
||||||
actions = [],
|
actions = [],
|
||||||
selectedAction,
|
selectedAction,
|
||||||
@ -40,16 +42,16 @@ export const ActionList: React.FC<ActionListProps> = ({
|
|||||||
onHighlighted = () => {},
|
onHighlighted = () => {},
|
||||||
revealConsole = () => {},
|
revealConsole = () => {},
|
||||||
}) => {
|
}) => {
|
||||||
return <ListView
|
return <ActionListView
|
||||||
items={actions}
|
items={actions}
|
||||||
|
id={action => action.callId}
|
||||||
selectedItem={selectedAction}
|
selectedItem={selectedAction}
|
||||||
onSelected={(action: ActionTraceEvent) => onSelected(action)}
|
onSelected={onSelected}
|
||||||
onHighlighted={(action: ActionTraceEvent) => onHighlighted(action)}
|
onHighlighted={onHighlighted}
|
||||||
itemKey={(action: ActionTraceEvent) => action.callId}
|
isError={action => !!action.error?.message}
|
||||||
itemType={(action: ActionTraceEvent) => action.error?.message ? 'error' : undefined}
|
render={action => renderAction(action, sdkLanguage, revealConsole)}
|
||||||
itemRender={(action: ActionTraceEvent) => renderAction(action, sdkLanguage, revealConsole)}
|
|
||||||
noItemsMessage='No actions'
|
noItemsMessage='No actions'
|
||||||
></ListView>;
|
/>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderAction = (
|
const renderAction = (
|
||||||
|
|||||||
@ -18,6 +18,10 @@ import * as React from 'react';
|
|||||||
import './stackTrace.css';
|
import './stackTrace.css';
|
||||||
import type { ActionTraceEvent } from '@trace/trace';
|
import type { ActionTraceEvent } from '@trace/trace';
|
||||||
import { ListView } from '@web/components/listView';
|
import { ListView } from '@web/components/listView';
|
||||||
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||||
|
import type { StackFrame } from '@protocol/channels';
|
||||||
|
|
||||||
|
const StackFrameListView = ListView<StackFrame>;
|
||||||
|
|
||||||
export const StackTraceView: React.FunctionComponent<{
|
export const StackTraceView: React.FunctionComponent<{
|
||||||
action: ActionTraceEvent | undefined,
|
action: ActionTraceEvent | undefined,
|
||||||
@ -25,11 +29,11 @@ export const StackTraceView: React.FunctionComponent<{
|
|||||||
setSelectedFrame: (index: number) => void
|
setSelectedFrame: (index: number) => void
|
||||||
}> = ({ action, setSelectedFrame, selectedFrame }) => {
|
}> = ({ action, setSelectedFrame, selectedFrame }) => {
|
||||||
const frames = action?.stack || [];
|
const frames = action?.stack || [];
|
||||||
return <ListView
|
return <StackFrameListView
|
||||||
dataTestId='stack-trace'
|
dataTestId='stack-trace'
|
||||||
items={frames}
|
items={frames}
|
||||||
selectedItem={frames[selectedFrame]}
|
selectedItem={frames[selectedFrame]}
|
||||||
itemRender={frame => {
|
render={frame => {
|
||||||
const pathSep = frame.file[1] === ':' ? '\\' : '/';
|
const pathSep = frame.file[1] === ':' ? '\\' : '/';
|
||||||
return <>
|
return <>
|
||||||
<span className='stack-trace-frame-function'>
|
<span className='stack-trace-frame-function'>
|
||||||
|
|||||||
@ -18,7 +18,8 @@ import '@web/third_party/vscode/codicon.css';
|
|||||||
import { Workbench } from './workbench';
|
import { Workbench } from './workbench';
|
||||||
import '@web/common.css';
|
import '@web/common.css';
|
||||||
import React from 'react';
|
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 { TeleReporterReceiver } from '../../../playwright-test/src/isomorphic/teleReceiver';
|
||||||
import type { FullConfig, Suite, TestCase, TestResult, TestStep, Location } from '../../../playwright-test/types/testReporter';
|
import type { FullConfig, Suite, TestCase, TestResult, TestStep, Location } from '../../../playwright-test/types/testReporter';
|
||||||
import { SplitView } from '@web/components/splitView';
|
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 [rootSuite, setRootSuite] = React.useState<{ value: Suite | undefined }>({ value: undefined });
|
||||||
const [isRunningTest, setIsRunningTest] = React.useState<boolean>(false);
|
const [isRunningTest, setIsRunningTest] = React.useState<boolean>(false);
|
||||||
const [progress, setProgress] = React.useState<Progress>({ total: 0, passed: 0, failed: 0 });
|
const [progress, setProgress] = React.useState<Progress>({ total: 0, passed: 0, failed: 0 });
|
||||||
const [selectedTestItem, setSelectedTestItem] = React.useState<TestItem | undefined>(undefined);
|
const [selectedTest, setSelectedTest] = React.useState<TestCase | undefined>(undefined);
|
||||||
const [settingsVisible, setSettingsVisible] = React.useState<boolean>(false);
|
const [settingsVisible, setSettingsVisible] = React.useState<boolean>(false);
|
||||||
const [isWatchingFiles, setIsWatchingFiles] = React.useState<boolean>(true);
|
const [isWatchingFiles, setIsWatchingFiles] = React.useState<boolean>(true);
|
||||||
|
|
||||||
@ -84,7 +85,7 @@ export const WatchModeView: React.FC<{}> = ({
|
|||||||
|
|
||||||
return <div className='vbox'>
|
return <div className='vbox'>
|
||||||
<SplitView sidebarSize={250} orientation='horizontal' sidebarIsFirst={true}>
|
<SplitView sidebarSize={250} orientation='horizontal' sidebarIsFirst={true}>
|
||||||
<TraceView testItem={selectedTestItem}></TraceView>
|
<TraceView test={selectedTest}></TraceView>
|
||||||
<div className='vbox watch-mode-sidebar'>
|
<div className='vbox watch-mode-sidebar'>
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<div className='section-title' style={{ cursor: 'pointer' }} onClick={() => setSettingsVisible(false)}>Tests</div>
|
<div className='section-title' style={{ cursor: 'pointer' }} onClick={() => setSettingsVisible(false)}>Tests</div>
|
||||||
@ -101,7 +102,7 @@ export const WatchModeView: React.FC<{}> = ({
|
|||||||
isRunningTest={isRunningTest}
|
isRunningTest={isRunningTest}
|
||||||
isWatchingFiles={isWatchingFiles}
|
isWatchingFiles={isWatchingFiles}
|
||||||
runTests={runTests}
|
runTests={runTests}
|
||||||
onTestItemSelected={setSelectedTestItem}
|
onTestSelected={setSelectedTest}
|
||||||
isVisible={!settingsVisible} />
|
isVisible={!settingsVisible} />
|
||||||
{settingsVisible && <SettingsView projects={projects} setProjects={setProjects} onClose={() => setSettingsVisible(false)}></SettingsView>}
|
{settingsVisible && <SettingsView projects={projects} setProjects={setProjects} onClose={() => setSettingsVisible(false)}></SettingsView>}
|
||||||
</div>
|
</div>
|
||||||
@ -112,6 +113,8 @@ export const WatchModeView: React.FC<{}> = ({
|
|||||||
</div>;
|
</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TreeListView = TreeView<TreeItem>;
|
||||||
|
|
||||||
export const TestList: React.FC<{
|
export const TestList: React.FC<{
|
||||||
projects: Map<string, boolean>,
|
projects: Map<string, boolean>,
|
||||||
rootSuite: { value: Suite | undefined },
|
rootSuite: { value: Suite | undefined },
|
||||||
@ -119,11 +122,11 @@ export const TestList: React.FC<{
|
|||||||
isRunningTest: boolean,
|
isRunningTest: boolean,
|
||||||
isWatchingFiles: boolean,
|
isWatchingFiles: boolean,
|
||||||
isVisible: boolean
|
isVisible: boolean
|
||||||
onTestItemSelected: (test: TestItem | undefined) => void,
|
onTestSelected: (test: TestCase | undefined) => void,
|
||||||
}> = ({ projects, rootSuite, runTests, isRunningTest, isWatchingFiles, isVisible, onTestItemSelected }) => {
|
}> = ({ projects, rootSuite, runTests, isRunningTest, isWatchingFiles, isVisible, onTestSelected }) => {
|
||||||
|
const [treeState, setTreeState] = React.useState<TreeState>({ expandedItems: new Map() });
|
||||||
const [filterText, setFilterText] = React.useState<string>('');
|
const [filterText, setFilterText] = React.useState<string>('');
|
||||||
const [selectedTreeItemId, setSelectedTreeItemId] = React.useState<string | undefined>();
|
const [selectedTreeItemId, setSelectedTreeItemId] = React.useState<string | undefined>();
|
||||||
const [expandedItems, setExpandedItems] = React.useState<Map<string, boolean>>(new Map());
|
|
||||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@ -131,10 +134,9 @@ export const TestList: React.FC<{
|
|||||||
refreshRootSuite(true);
|
refreshRootSuite(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { filteredItems, treeItemMap, visibleTestIds } = React.useMemo(() => {
|
const { rootItem, treeItemMap, visibleTestIds } = React.useMemo(() => {
|
||||||
const treeItems = createTree(rootSuite.value, projects);
|
const rootItem = createTree(rootSuite.value, projects);
|
||||||
const filteredItems = filterTree(treeItems, filterText);
|
filterTree(rootItem, filterText);
|
||||||
|
|
||||||
const treeItemMap = new Map<string, TreeItem>();
|
const treeItemMap = new Map<string, TreeItem>();
|
||||||
const visibleTestIds = new Set<string>();
|
const visibleTestIds = new Set<string>();
|
||||||
const visit = (treeItem: TreeItem) => {
|
const visit = (treeItem: TreeItem) => {
|
||||||
@ -143,35 +145,30 @@ export const TestList: React.FC<{
|
|||||||
treeItem.children?.forEach(visit);
|
treeItem.children?.forEach(visit);
|
||||||
treeItemMap.set(treeItem.id, treeItem);
|
treeItemMap.set(treeItem.id, treeItem);
|
||||||
};
|
};
|
||||||
filteredItems.forEach(visit);
|
visit(rootItem);
|
||||||
return { treeItemMap, visibleTestIds, filteredItems };
|
hideOnlyTests(rootItem);
|
||||||
|
return { rootItem, treeItemMap, visibleTestIds };
|
||||||
}, [filterText, rootSuite, projects]);
|
}, [filterText, rootSuite, projects]);
|
||||||
|
|
||||||
runVisibleTests = () => runTests([...visibleTestIds]);
|
runVisibleTests = () => runTests([...visibleTestIds]);
|
||||||
|
|
||||||
const { listItems } = React.useMemo(() => {
|
const { selectedTreeItem } = React.useMemo(() => {
|
||||||
const listItems = flattenTree(filteredItems, expandedItems, !!filterText.trim());
|
|
||||||
return { listItems };
|
|
||||||
}, [filteredItems, filterText, expandedItems]);
|
|
||||||
|
|
||||||
const { selectedTreeItem, selectedTestItem } = React.useMemo(() => {
|
|
||||||
const selectedTreeItem = selectedTreeItemId ? treeItemMap.get(selectedTreeItemId) : undefined;
|
const selectedTreeItem = selectedTreeItemId ? treeItemMap.get(selectedTreeItemId) : undefined;
|
||||||
let selectedTestItem: TestItem | undefined;
|
let selectedTest: TestCase | undefined;
|
||||||
if (selectedTreeItem?.kind === 'test')
|
if (selectedTreeItem?.kind === 'test')
|
||||||
selectedTestItem = selectedTreeItem;
|
selectedTest = selectedTreeItem.test;
|
||||||
else if (selectedTreeItem?.kind === 'case' && selectedTreeItem.children?.length === 1)
|
else if (selectedTreeItem?.kind === 'case' && selectedTreeItem.tests.length === 1)
|
||||||
selectedTestItem = selectedTreeItem.children[0]! as TestItem;
|
selectedTest = selectedTreeItem.tests[0];
|
||||||
return { selectedTreeItem, selectedTestItem };
|
onTestSelected(selectedTest);
|
||||||
}, [selectedTreeItemId, treeItemMap]);
|
return { selectedTreeItem };
|
||||||
|
}, [onTestSelected, selectedTreeItemId, treeItemMap]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
sendMessageNoReply('watch', { fileName: isWatchingFiles ? fileName(selectedTreeItem) : undefined });
|
sendMessageNoReply('watch', { fileName: isWatchingFiles ? fileName(selectedTreeItem) : undefined });
|
||||||
}, [selectedTreeItem, isWatchingFiles]);
|
}, [selectedTreeItem, isWatchingFiles]);
|
||||||
|
|
||||||
onTestItemSelected(selectedTestItem);
|
|
||||||
|
|
||||||
const runTreeItem = (treeItem: TreeItem) => {
|
const runTreeItem = (treeItem: TreeItem) => {
|
||||||
expandedItems.set(treeItem.id, true);
|
// expandedItems.set(treeItem.id, true);
|
||||||
setSelectedTreeItemId(treeItem.id);
|
setSelectedTreeItemId(treeItem.id);
|
||||||
runTests(collectTestIds(treeItem));
|
runTests(collectTestIds(treeItem));
|
||||||
};
|
};
|
||||||
@ -194,19 +191,35 @@ export const TestList: React.FC<{
|
|||||||
runVisibleTests();
|
runVisibleTests();
|
||||||
}}></input>
|
}}></input>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
<ListView
|
<TreeListView
|
||||||
items={listItems}
|
treeState={treeState}
|
||||||
itemKey={(treeItem: TreeItem) => treeItem.id }
|
setTreeState={setTreeState}
|
||||||
itemRender={(treeItem: TreeItem) => {
|
rootItem={rootItem}
|
||||||
|
render={treeItem => {
|
||||||
return <div className='hbox watch-mode-list-item'>
|
return <div className='hbox watch-mode-list-item'>
|
||||||
<div className='watch-mode-list-item-title'>{treeItem.title}</div>
|
<div className='watch-mode-list-item-title'>{treeItem.title}</div>
|
||||||
<ToolbarButton icon='play' title='Run' onClick={() => runTreeItem(treeItem)} disabled={isRunningTest}></ToolbarButton>
|
<ToolbarButton icon='play' title='Run' onClick={() => runTreeItem(treeItem)} disabled={isRunningTest}></ToolbarButton>
|
||||||
<ToolbarButton icon='go-to-file' title='Open in VS Code' onClick={() => sendMessageNoReply('open', { location: locationToOpen(treeItem) })}></ToolbarButton>
|
<ToolbarButton icon='go-to-file' title='Open in VS Code' onClick={() => sendMessageNoReply('open', { location: locationToOpen(treeItem) })}></ToolbarButton>
|
||||||
</div>;
|
</div>;
|
||||||
}}
|
}}
|
||||||
itemIcon={(treeItem: TreeItem) => {
|
icon={treeItem => {
|
||||||
if (treeItem.kind === 'case' && treeItem.children?.length === 1)
|
if (treeItem.kind === 'case') {
|
||||||
treeItem = treeItem.children[0];
|
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') {
|
if (treeItem.kind === 'test') {
|
||||||
const ok = treeItem.test.outcome() === 'expected';
|
const ok = treeItem.test.outcome() === 'expected';
|
||||||
const failed = treeItem.test.results.length && 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';
|
return 'codicon-check';
|
||||||
if (failed)
|
if (failed)
|
||||||
return 'codicon-error';
|
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}
|
selectedItem={selectedTreeItem}
|
||||||
onAccepted={runTreeItem}
|
onAccepted={runTreeItem}
|
||||||
onLeftArrow={(treeItem: TreeItem) => {
|
onSelected={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) => {
|
|
||||||
setSelectedTreeItemId(treeItem.id);
|
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' />
|
noItemsMessage='No tests' />
|
||||||
</div>;
|
</div>;
|
||||||
};
|
};
|
||||||
@ -287,20 +275,20 @@ export const SettingsView: React.FC<{
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const TraceView: React.FC<{
|
export const TraceView: React.FC<{
|
||||||
testItem: TestItem | undefined,
|
test: TestCase | undefined,
|
||||||
}> = ({ testItem }) => {
|
}> = ({ test }) => {
|
||||||
const [model, setModel] = React.useState<MultiTraceModel | undefined>();
|
const [model, setModel] = React.useState<MultiTraceModel | undefined>();
|
||||||
const [stepsProgress, setStepsProgress] = React.useState(0);
|
const [stepsProgress, setStepsProgress] = React.useState(0);
|
||||||
updateStepsProgress = () => setStepsProgress(stepsProgress + 1);
|
updateStepsProgress = () => setStepsProgress(stepsProgress + 1);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (!testItem) {
|
if (!test) {
|
||||||
setModel(undefined);
|
setModel(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = testItem.test?.results?.[0];
|
const result = test.results?.[0];
|
||||||
if (result) {
|
if (result) {
|
||||||
const attachment = result.attachments.find(a => a.name === 'trace');
|
const attachment = result.attachments.find(a => a.name === 'trace');
|
||||||
if (attachment && attachment.path)
|
if (attachment && attachment.path)
|
||||||
@ -311,7 +299,7 @@ export const TraceView: React.FC<{
|
|||||||
setModel(undefined);
|
setModel(undefined);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [testItem, stepsProgress]);
|
}, [test, stepsProgress]);
|
||||||
|
|
||||||
const xterm = <XtermWrapper source={xtermDataSource}></XtermWrapper>;
|
const xterm = <XtermWrapper source={xtermDataSource}></XtermWrapper>;
|
||||||
return <Workbench model={model} output={xterm} rightToolbar={[
|
return <Workbench model={model} output={xterm} rightToolbar={[
|
||||||
@ -430,9 +418,12 @@ const collectTestIds = (treeItem?: TreeItem): string[] => {
|
|||||||
return [];
|
return [];
|
||||||
const testIds: string[] = [];
|
const testIds: string[] = [];
|
||||||
const visit = (treeItem: TreeItem) => {
|
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);
|
testIds.push(treeItem.id);
|
||||||
treeItem.children?.forEach(visit);
|
else
|
||||||
|
treeItem.children?.forEach(visit);
|
||||||
};
|
};
|
||||||
visit(treeItem);
|
visit(treeItem);
|
||||||
return testIds;
|
return testIds;
|
||||||
@ -445,22 +436,28 @@ type Progress = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type TreeItemBase = {
|
type TreeItemBase = {
|
||||||
kind: 'file' | 'case' | 'test',
|
kind: 'root' | 'file' | 'case' | 'test',
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
parent: TreeItem | null;
|
parent: TreeItem | null;
|
||||||
children?: TreeItem[];
|
children: TreeItem[];
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type RootItem = TreeItemBase & {
|
||||||
|
kind: 'root',
|
||||||
|
children: FileItem[];
|
||||||
|
};
|
||||||
|
|
||||||
type FileItem = TreeItemBase & {
|
type FileItem = TreeItemBase & {
|
||||||
kind: 'file',
|
kind: 'file',
|
||||||
file: string;
|
file: string;
|
||||||
children?: TestCaseItem[];
|
children: TestCaseItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type TestCaseItem = TreeItemBase & {
|
type TestCaseItem = TreeItemBase & {
|
||||||
kind: 'case',
|
kind: 'case',
|
||||||
|
tests: TestCase[];
|
||||||
location: Location,
|
location: Location,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -469,9 +466,16 @@ type TestItem = TreeItemBase & {
|
|||||||
test: TestCase;
|
test: TestCase;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TreeItem = FileItem | TestCaseItem | TestItem;
|
type TreeItem = RootItem | FileItem | TestCaseItem | TestItem;
|
||||||
|
|
||||||
function createTree(rootSuite: Suite | undefined, projects: Map<string, boolean>): FileItem[] {
|
function createTree(rootSuite: Suite | undefined, projects: Map<string, boolean>): RootItem {
|
||||||
|
const rootItem: RootItem = {
|
||||||
|
kind: 'root',
|
||||||
|
id: 'root',
|
||||||
|
title: '',
|
||||||
|
parent: null,
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
const fileItems = new Map<string, FileItem>();
|
const fileItems = new Map<string, FileItem>();
|
||||||
for (const projectSuite of rootSuite?.suites || []) {
|
for (const projectSuite of rootSuite?.suites || []) {
|
||||||
if (!projects.get(projectSuite.title))
|
if (!projects.get(projectSuite.title))
|
||||||
@ -491,11 +495,12 @@ function createTree(rootSuite: Suite | undefined, projects: Map<string, boolean>
|
|||||||
expanded: false,
|
expanded: false,
|
||||||
};
|
};
|
||||||
fileItems.set(fileSuite.location!.file, fileItem);
|
fileItems.set(fileSuite.location!.file, fileItem);
|
||||||
|
rootItem.children.push(fileItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const test of fileSuite.allTests()) {
|
for (const test of fileSuite.allTests()) {
|
||||||
const title = test.titlePath().slice(3).join(' › ');
|
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) {
|
if (!testCaseItem) {
|
||||||
testCaseItem = {
|
testCaseItem = {
|
||||||
kind: 'case',
|
kind: 'case',
|
||||||
@ -503,62 +508,56 @@ function createTree(rootSuite: Suite | undefined, projects: Map<string, boolean>
|
|||||||
title,
|
title,
|
||||||
parent: fileItem,
|
parent: fileItem,
|
||||||
children: [],
|
children: [],
|
||||||
|
tests: [],
|
||||||
expanded: false,
|
expanded: false,
|
||||||
location: test.location,
|
location: test.location,
|
||||||
};
|
};
|
||||||
fileItem.children!.push(testCaseItem);
|
fileItem.children.push(testCaseItem);
|
||||||
}
|
}
|
||||||
testCaseItem.children!.push({
|
testCaseItem.tests.push(test);
|
||||||
|
testCaseItem.children.push({
|
||||||
kind: 'test',
|
kind: 'test',
|
||||||
id: test.id,
|
id: test.id,
|
||||||
title: projectSuite.title,
|
title: projectSuite.title,
|
||||||
parent: testCaseItem,
|
parent: testCaseItem,
|
||||||
test,
|
test,
|
||||||
|
children: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
(fileItem.children as TestCaseItem[]).sort((a, b) => a.location.line - b.location.line);
|
(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 trimmedFilterText = filterText.trim();
|
||||||
const filterTokens = trimmedFilterText.toLowerCase().split(' ');
|
const filterTokens = trimmedFilterText.toLowerCase().split(' ');
|
||||||
const result: FileItem[] = [];
|
const result: FileItem[] = [];
|
||||||
for (const fileItem of fileItems) {
|
for (const fileItem of rootItem.children) {
|
||||||
if (trimmedFilterText) {
|
if (trimmedFilterText) {
|
||||||
const filteredCases: TestCaseItem[] = [];
|
const filteredCases: TestCaseItem[] = [];
|
||||||
for (const testCaseItem of fileItem.children!) {
|
for (const testCaseItem of fileItem.children) {
|
||||||
const fullTitle = (fileItem.title + ' ' + testCaseItem.title).toLowerCase();
|
const fullTitle = (fileItem.title + ' ' + testCaseItem.title).toLowerCase();
|
||||||
if (filterTokens.every(token => fullTitle.includes(token)))
|
if (filterTokens.every(token => fullTitle.includes(token)))
|
||||||
filteredCases.push(testCaseItem);
|
filteredCases.push(testCaseItem);
|
||||||
}
|
}
|
||||||
fileItem.children = filteredCases;
|
fileItem.children = filteredCases;
|
||||||
}
|
}
|
||||||
if (fileItem.children!.length)
|
if (fileItem.children.length)
|
||||||
result.push(fileItem);
|
result.push(fileItem);
|
||||||
}
|
}
|
||||||
return result;
|
rootItem.children = result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function flattenTree(fileItems: FileItem[], expandedItems: Map<string, boolean | undefined>, hasFilter: boolean): TreeItem[] {
|
function hideOnlyTests(rootItem: RootItem) {
|
||||||
const result: TreeItem[] = [];
|
const visit = (treeItem: TreeItem) => {
|
||||||
for (const fileItem of fileItems) {
|
if (treeItem.kind === 'case' && treeItem.children.length === 1)
|
||||||
result.push(fileItem);
|
treeItem.children = [];
|
||||||
const expandState = expandedItems.get(fileItem.id);
|
else
|
||||||
const autoExpandMatches = result.length < 100 && (hasFilter && expandState !== false);
|
treeItem.children.forEach(visit);
|
||||||
fileItem.expanded = expandState || autoExpandMatches || fileItems.length < 10;
|
};
|
||||||
if (fileItem.expanded) {
|
visit(rootItem);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadSingleTraceFile(url: string): Promise<MultiTraceModel> {
|
async function loadSingleTraceFile(url: string): Promise<MultiTraceModel> {
|
||||||
|
|||||||
@ -109,6 +109,10 @@ svg {
|
|||||||
color: var(--red);
|
color: var(--red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.codicon-circle-outline {
|
||||||
|
color: var(--vscode-disabledForeground);
|
||||||
|
}
|
||||||
|
|
||||||
input[type=text], input[type=search] {
|
input[type=text], input[type=search] {
|
||||||
color: var(--vscode-input-foreground);
|
color: var(--vscode-input-foreground);
|
||||||
background-color: var(--vscode-input-background);
|
background-color: var(--vscode-input-background);
|
||||||
|
|||||||
@ -17,31 +17,31 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import './listView.css';
|
import './listView.css';
|
||||||
|
|
||||||
export type ListViewProps = {
|
export type ListViewProps<T> = {
|
||||||
items: any[],
|
items: T[],
|
||||||
itemRender: (item: any) => React.ReactNode,
|
id?: (item: T) => string,
|
||||||
itemKey?: (item: any) => string,
|
render: (item: T) => React.ReactNode,
|
||||||
itemIcon?: (item: any) => string | undefined,
|
icon?: (item: T) => string | undefined,
|
||||||
itemIndent?: (item: any) => number | undefined,
|
indent?: (item: T) => number | undefined,
|
||||||
itemType?: (item: any) => 'error' | undefined,
|
isError?: (item: T) => boolean,
|
||||||
selectedItem?: any,
|
selectedItem?: T,
|
||||||
onAccepted?: (item: any) => void,
|
onAccepted?: (item: T) => void,
|
||||||
onSelected?: (item: any) => void,
|
onSelected?: (item: T) => void,
|
||||||
onLeftArrow?: (item: any) => void,
|
onLeftArrow?: (item: T) => void,
|
||||||
onRightArrow?: (item: any) => void,
|
onRightArrow?: (item: T) => void,
|
||||||
onHighlighted?: (item: any | undefined) => void,
|
onHighlighted?: (item: T | undefined) => void,
|
||||||
onIconClicked?: (item: any) => void,
|
onIconClicked?: (item: T) => void,
|
||||||
noItemsMessage?: string,
|
noItemsMessage?: string,
|
||||||
dataTestId?: string,
|
dataTestId?: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ListView: React.FC<ListViewProps> = ({
|
export function ListView<T>({
|
||||||
items = [],
|
items = [],
|
||||||
itemKey,
|
id,
|
||||||
itemRender,
|
render,
|
||||||
itemIcon,
|
icon,
|
||||||
itemType,
|
isError,
|
||||||
itemIndent,
|
indent,
|
||||||
selectedItem,
|
selectedItem,
|
||||||
onAccepted,
|
onAccepted,
|
||||||
onSelected,
|
onSelected,
|
||||||
@ -51,17 +51,21 @@ export const ListView: React.FC<ListViewProps> = ({
|
|||||||
onIconClicked,
|
onIconClicked,
|
||||||
noItemsMessage,
|
noItemsMessage,
|
||||||
dataTestId,
|
dataTestId,
|
||||||
}) => {
|
}: ListViewProps<T>) {
|
||||||
const itemListRef = React.createRef<HTMLDivElement>();
|
const itemListRef = React.createRef<HTMLDivElement>();
|
||||||
const [highlightedItem, setHighlightedItem] = React.useState<any>();
|
const [highlightedItem, setHighlightedItem] = React.useState<any>();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
onHighlighted?.(highlightedItem);
|
||||||
|
}, [onHighlighted, highlightedItem]);
|
||||||
|
|
||||||
return <div className='list-view vbox' data-testid={dataTestId}>
|
return <div className='list-view vbox' data-testid={dataTestId}>
|
||||||
<div
|
<div
|
||||||
className='list-view-content'
|
className='list-view-content'
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onDoubleClick={() => onAccepted?.(selectedItem)}
|
onDoubleClick={() => selectedItem && onAccepted?.(selectedItem)}
|
||||||
onKeyDown={event => {
|
onKeyDown={event => {
|
||||||
if (event.key === 'Enter') {
|
if (selectedItem && event.key === 'Enter') {
|
||||||
onAccepted?.(selectedItem);
|
onAccepted?.(selectedItem);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -71,11 +75,11 @@ export const ListView: React.FC<ListViewProps> = ({
|
|||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
if (event.key === 'ArrowLeft') {
|
if (selectedItem && event.key === 'ArrowLeft') {
|
||||||
onLeftArrow?.(selectedItem);
|
onLeftArrow?.(selectedItem);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key === 'ArrowRight') {
|
if (selectedItem && event.key === 'ArrowRight') {
|
||||||
onRightArrow?.(selectedItem);
|
onRightArrow?.(selectedItem);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -96,81 +100,40 @@ export const ListView: React.FC<ListViewProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const element = itemListRef.current?.children.item(newIndex);
|
const element = itemListRef.current?.children.item(newIndex);
|
||||||
scrollIntoViewIfNeeded(element);
|
scrollIntoViewIfNeeded(element || undefined);
|
||||||
onHighlighted?.(undefined);
|
onHighlighted?.(undefined);
|
||||||
onSelected?.(items[newIndex]);
|
onSelected?.(items[newIndex]);
|
||||||
}}
|
}}
|
||||||
ref={itemListRef}
|
ref={itemListRef}
|
||||||
>
|
>
|
||||||
{noItemsMessage && items.length === 0 && <div className='list-view-empty'>{noItemsMessage}</div>}
|
{noItemsMessage && items.length === 0 && <div className='list-view-empty'>{noItemsMessage}</div>}
|
||||||
{items.map((item, index) => <ListItemView
|
{items.map((item, index) => {
|
||||||
key={itemKey ? itemKey(item) : String(index)}
|
const selectedSuffix = selectedItem === item ? ' selected' : '';
|
||||||
hasIcons={!!itemIcon}
|
const highlightedSuffix = highlightedItem === item ? ' highlighted' : '';
|
||||||
icon={itemIcon?.(item)}
|
const errorSuffix = isError?.(item) ? ' error' : '';
|
||||||
type={itemType?.(item)}
|
const indentation = indent?.(item) || 0;
|
||||||
indent={itemIndent?.(item)}
|
const rendered = render(item);
|
||||||
isHighlighted={item === highlightedItem}
|
return <div
|
||||||
isSelected={item === selectedItem}
|
key={id?.(item) || index}
|
||||||
onSelected={() => onSelected?.(item)}
|
className={'list-view-entry' + selectedSuffix + highlightedSuffix + errorSuffix}
|
||||||
onMouseEnter={() => {
|
onClick={() => onSelected?.(item)}
|
||||||
setHighlightedItem(item);
|
onMouseEnter={() => setHighlightedItem(item)}
|
||||||
onHighlighted?.(item);
|
onMouseLeave={() => setHighlightedItem(undefined)}
|
||||||
}}
|
>
|
||||||
onMouseLeave={() => {
|
{indentation ? <div style={{ minWidth: indentation * 16 }}></div> : undefined}
|
||||||
setHighlightedItem(undefined);
|
{icon && <div className={'codicon ' + (icon(item) || 'blank')} style={{ minWidth: 16, marginRight: 4 }} onClick={e => {
|
||||||
onHighlighted?.(undefined);
|
e.stopPropagation();
|
||||||
}}
|
e.preventDefault();
|
||||||
onIconClicked={() => onIconClicked?.(item)}
|
onIconClicked?.(item);
|
||||||
>
|
}}></div>}
|
||||||
{itemRender(item)}
|
{typeof rendered === 'string' ? <div style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}>{rendered}</div> : rendered}
|
||||||
</ListItemView>)}
|
</div>;
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>;
|
</div>;
|
||||||
};
|
}
|
||||||
|
|
||||||
const ListItemView: React.FC<{
|
function scrollIntoViewIfNeeded(element: Element | undefined) {
|
||||||
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<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (divRef.current && isSelected)
|
|
||||||
scrollIntoViewIfNeeded(divRef.current);
|
|
||||||
}, [isSelected]);
|
|
||||||
|
|
||||||
return <div
|
|
||||||
key={key}
|
|
||||||
className={'list-view-entry' + selectedSuffix + highlightedSuffix + errorSuffix}
|
|
||||||
onClick={onSelected}
|
|
||||||
onMouseEnter={onMouseEnter}
|
|
||||||
onMouseLeave={onMouseLeave}
|
|
||||||
ref={divRef}
|
|
||||||
>
|
|
||||||
{indent ? <div style={{ minWidth: indent * 16 }}></div> : undefined}
|
|
||||||
{hasIcons && <div className={'codicon ' + (icon || 'blank')} style={{ minWidth: 16, marginRight: 4 }} onClick={e => {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
onIconClicked();
|
|
||||||
}}></div>}
|
|
||||||
{typeof children === 'string' ? <div style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}>{children}</div> : children}
|
|
||||||
</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function scrollIntoViewIfNeeded(element?: Element | null) {
|
|
||||||
if (!element)
|
if (!element)
|
||||||
return;
|
return;
|
||||||
if ((element as any)?.scrollIntoViewIfNeeded)
|
if ((element as any)?.scrollIntoViewIfNeeded)
|
||||||
|
|||||||
130
packages/web/src/components/treeView.tsx
Normal file
130
packages/web/src/components/treeView.tsx
Normal file
@ -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<string, boolean>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TreeViewProps<T> = {
|
||||||
|
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<TreeItem>;
|
||||||
|
|
||||||
|
export function TreeView<T extends TreeItem>({
|
||||||
|
rootItem,
|
||||||
|
render,
|
||||||
|
icon,
|
||||||
|
isError,
|
||||||
|
selectedItem,
|
||||||
|
onAccepted,
|
||||||
|
onSelected,
|
||||||
|
onHighlighted,
|
||||||
|
treeState,
|
||||||
|
setTreeState,
|
||||||
|
noItemsMessage,
|
||||||
|
}: TreeViewProps<T>) {
|
||||||
|
const treeItems = React.useMemo(() => {
|
||||||
|
return flattenTree<T>(rootItem, treeState.expandedItems);
|
||||||
|
}, [rootItem, treeState]);
|
||||||
|
|
||||||
|
return <TreeListView
|
||||||
|
items={[...treeItems.keys()]}
|
||||||
|
id={item => item.id}
|
||||||
|
render={item => {
|
||||||
|
const rendered = render(item as T);
|
||||||
|
return <>
|
||||||
|
{icon && <div className={'codicon ' + (icon(item as T) || 'blank')} style={{ minWidth: 16, marginRight: 4 }}></div>}
|
||||||
|
{typeof rendered === 'string' ? <div style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}>{rendered}</div> : 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<T extends TreeItem>(rootItem: T, expandedItems: Map<string, boolean | undefined>): Map<T, TreeItemData> {
|
||||||
|
const result = new Map<T, TreeItemData>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user