From 30e4c4bd8bb82c29bd2daa0d872adf0c170e5a75 Mon Sep 17 00:00:00 2001 From: shadowusr Date: Tue, 3 Dec 2024 00:19:57 +0300 Subject: [PATCH 1/7] feat: implement tests sorting --- lib/static/modules/action-names.ts | 5 +- lib/static/modules/actions/sort-tests.ts | 19 ++ lib/static/modules/actions/suites-page.ts | 10 +- lib/static/modules/actions/types.ts | 4 +- lib/static/modules/default-state.ts | 8 +- lib/static/modules/reducers/index.js | 2 + .../reducers/new-ui-grouped-tests/utils.ts | 24 ++- lib/static/modules/reducers/sort-tests.ts | 44 ++++ lib/static/modules/reducers/suites-page.ts | 11 +- .../AdaptiveSelect/index.module.css | 94 +++++++++ .../components/AdaptiveSelect/index.tsx | 81 ++++++++ .../components/AttemptPickerItem/index.tsx | 4 +- .../suites/components/GroupBySelect/index.tsx | 27 ++- .../components/SortBySelect/index.module.css | 3 + .../suites/components/SortBySelect/index.tsx | 55 +++++ .../suites/components/SuitesPage/types.ts | 4 +- .../components/SuitesTreeView/index.tsx | 13 +- .../components/SuitesTreeView/selectors.ts | 192 ++++++++++++++++-- .../TreeActionsToolbar/index.module.css | 1 + .../components/TreeActionsToolbar/index.tsx | 24 ++- .../components/TreeViewItemSubtitle/index.tsx | 2 +- .../TreeViewItemTitle/index.module.css | 31 +++ .../components/TreeViewItemTitle/index.tsx | 42 ++-- .../components/TreeViewItemTitle/selectors.ts | 4 +- .../new-ui/features/suites/selectors.ts | 4 +- lib/static/new-ui/store/selectors.ts | 4 +- lib/static/new-ui/types/store.ts | 27 +++ 27 files changed, 671 insertions(+), 68 deletions(-) create mode 100644 lib/static/modules/actions/sort-tests.ts create mode 100644 lib/static/modules/reducers/sort-tests.ts create mode 100644 lib/static/new-ui/components/AdaptiveSelect/index.module.css create mode 100644 lib/static/new-ui/components/AdaptiveSelect/index.tsx create mode 100644 lib/static/new-ui/features/suites/components/SortBySelect/index.module.css create mode 100644 lib/static/new-ui/features/suites/components/SortBySelect/index.tsx diff --git a/lib/static/modules/action-names.ts b/lib/static/modules/action-names.ts index be76a25e..6b6bd440 100644 --- a/lib/static/modules/action-names.ts +++ b/lib/static/modules/action-names.ts @@ -69,11 +69,14 @@ export default { SUITES_PAGE_SET_ALL_TREE_NODES: 'SUITES_PAGE_SET_ALL_TREE_NODES', SUITES_PAGE_REVEAL_TREE_NODE: 'SUITES_PAGE_REVEAL_TREE_NODE', SUITES_PAGE_SET_STEPS_EXPANDED: 'SUITES_PAGE_SET_STEPS_EXPANDED', + SUITES_PAGE_SET_TREE_VIEW_MODE: 'SUITES_PAGE_SET_TREE_VIEW_MODE', VISUAL_CHECKS_PAGE_SET_CURRENT_NAMED_IMAGE: 'VISUAL_CHECKS_PAGE_SET_CURRENT_NAMED_IMAGE', UPDATE_LOADING_PROGRESS: 'UPDATE_LOADING_PROGRESS', UPDATE_LOADING_VISIBILITY: 'UPDATE_LOADING_VISIBILITY', UPDATE_LOADING_TITLE: 'UPDATE_LOADING_TITLE', UPDATE_LOADING_IS_IN_PROGRESS: 'UPDATE_LOADING_IS_IN_PROGRESS', SELECT_ALL: 'SELECT_ALL', - DESELECT_ALL: 'DESELECT_ALL' + DESELECT_ALL: 'DESELECT_ALL', + SORT_TESTS_SET_CURRENT_EXPRESSION: 'SORT_TESTS_SET_CURRENT_EXPRESSION', + SORT_TESTS_SET_DIRECTION: 'SORT_TESTS_SET_DIRECTION' } as const; diff --git a/lib/static/modules/actions/sort-tests.ts b/lib/static/modules/actions/sort-tests.ts new file mode 100644 index 00000000..0b9087cb --- /dev/null +++ b/lib/static/modules/actions/sort-tests.ts @@ -0,0 +1,19 @@ +import actionNames from '@/static/modules/action-names'; +import {Action} from '@/static/modules/actions/types'; +import {SortDirection} from '@/static/new-ui/types/store'; + +type SetCurrentSortByExpressionAction = Action; +export const setCurrentSortByExpression = (payload: SetCurrentSortByExpressionAction['payload']): SetCurrentSortByExpressionAction => + ({type: actionNames.SORT_TESTS_SET_CURRENT_EXPRESSION, payload}); + +type SetSortByDirectionAction = Action; +export const setSortByDirection = (payload: SetSortByDirectionAction['payload']): SetSortByDirectionAction => + ({type: actionNames.SORT_TESTS_SET_DIRECTION, payload}); + +export type SortTestsAction = + | SetCurrentSortByExpressionAction + | SetSortByDirectionAction; diff --git a/lib/static/modules/actions/suites-page.ts b/lib/static/modules/actions/suites-page.ts index e3332800..7c918eed 100644 --- a/lib/static/modules/actions/suites-page.ts +++ b/lib/static/modules/actions/suites-page.ts @@ -1,5 +1,6 @@ import actionNames from '@/static/modules/action-names'; import {Action} from '@/static/modules/actions/types'; +import {TreeViewMode} from '@/static/new-ui/types/store'; export type SuitesPageSetCurrentTreeNodeAction = Action ({type: actionNames.SUITES_PAGE_SET_STEPS_EXPANDED, payload}); +type SetTreeViewModeAction = Action; +export const setTreeViewMode = (payload: SetTreeViewModeAction['payload']): SetTreeViewModeAction => + ({type: actionNames.SUITES_PAGE_SET_TREE_VIEW_MODE, payload}); + export type SuitesPageAction = | SetTreeNodeExpandedStateAction | SetAllTreeNodesStateAction | SuitesPageSetCurrentTreeNodeAction | SetSectionExpandedStateAction | SetStepsExpandedStateAction - | RevealTreeNodeAction; + | RevealTreeNodeAction + | SetTreeViewModeAction; diff --git a/lib/static/modules/actions/types.ts b/lib/static/modules/actions/types.ts index d90ad9e6..1c4bb3f1 100644 --- a/lib/static/modules/actions/types.ts +++ b/lib/static/modules/actions/types.ts @@ -7,6 +7,7 @@ import {ThunkAction} from 'redux-thunk'; import {State} from '@/static/new-ui/types/store'; import {LifecycleAction} from '@/static/modules/actions/lifecycle'; import {SuitesPageAction} from '@/static/modules/actions/suites-page'; +import {SortTestsAction} from '@/static/modules/actions/sort-tests'; export type {Dispatch} from 'redux'; @@ -22,4 +23,5 @@ export type AppThunk> = ThunkAction const groupTestsByMeta = (expr: GroupByMetaExpression, resultsById: Record): Record => { const DEFAULT_GROUP = `__${GroupByType.Meta}__DEFAULT_GROUP`; const results = Object.values(resultsById); - const groups: Record = {}; + const groupsById: Record = {}; + const groupingKeyToId: Record = {}; let id = 1; for (const result of results) { @@ -59,24 +60,29 @@ const groupTestsByMeta = (expr: GroupByMetaExpression, resultsById: Record, imagesById: Record, errorPatterns: State['config']['errorPatterns']): Record => { diff --git a/lib/static/modules/reducers/sort-tests.ts b/lib/static/modules/reducers/sort-tests.ts new file mode 100644 index 00000000..74d640d8 --- /dev/null +++ b/lib/static/modules/reducers/sort-tests.ts @@ -0,0 +1,44 @@ +import {SortByExpression, SortType, State} from '@/static/new-ui/types/store'; +import {SomeAction} from '@/static/modules/actions/types'; +import actionNames from '@/static/modules/action-names'; +import {applyStateUpdate} from '@/static/modules/utils'; + +export default (state: State, action: SomeAction): State => { + switch (action.type) { + case actionNames.INIT_STATIC_REPORT: + case actionNames.INIT_GUI_REPORT: { + const availableExpressions: SortByExpression[] = [ + {id: 'by-name', label: 'name', type: SortType.ByName}, + {id: 'by-retries', label: 'failed retries', type: SortType.ByRetries} + ]; + + return applyStateUpdate(state, { + app: { + sortTestsData: { + availableExpressions + } + } + }); + } + case actionNames.SORT_TESTS_SET_CURRENT_EXPRESSION: { + return applyStateUpdate(state, { + app: { + sortTestsData: { + currentExpressionIds: action.payload.expressionIds + } + } + }); + } + case actionNames.SORT_TESTS_SET_DIRECTION: { + return applyStateUpdate(state, { + app: { + sortTestsData: { + currentDirection: action.payload.direction + } + } + }); + } + default: + return state; + } +}; diff --git a/lib/static/modules/reducers/suites-page.ts b/lib/static/modules/reducers/suites-page.ts index d03394ae..009eb8f7 100644 --- a/lib/static/modules/reducers/suites-page.ts +++ b/lib/static/modules/reducers/suites-page.ts @@ -9,6 +9,7 @@ export default (state: State, action: SomeAction): State => { switch (action.type) { case actionNames.INIT_STATIC_REPORT: case actionNames.INIT_GUI_REPORT: + case actionNames.SUITES_PAGE_SET_TREE_VIEW_MODE: case actionNames.GROUP_TESTS_SET_CURRENT_EXPRESSION: { const {allTreeNodeIds} = getTreeViewItems(state); @@ -20,7 +21,8 @@ export default (state: State, action: SomeAction): State => { let currentGroupId: string | null | undefined = null; let currentTreeNodeId: string | null | undefined; - if (action.type === actionNames.GROUP_TESTS_SET_CURRENT_EXPRESSION) { + let treeViewMode = state.ui.suitesPage.treeViewMode; + if (action.type === actionNames.GROUP_TESTS_SET_CURRENT_EXPRESSION || action.type === actionNames.SUITES_PAGE_SET_TREE_VIEW_MODE) { const {currentBrowserId} = state.app.suitesPage; if (currentBrowserId) { const {tree} = getTreeViewItems(state); @@ -32,6 +34,10 @@ export default (state: State, action: SomeAction): State => { } } + if (action.type === actionNames.SUITES_PAGE_SET_TREE_VIEW_MODE) { + treeViewMode = action.payload.treeViewMode; + } + return applyStateUpdate(state, { app: { suitesPage: { @@ -41,7 +47,8 @@ export default (state: State, action: SomeAction): State => { }, ui: { suitesPage: { - expandedTreeNodesById + expandedTreeNodesById, + treeViewMode } } }); diff --git a/lib/static/new-ui/components/AdaptiveSelect/index.module.css b/lib/static/new-ui/components/AdaptiveSelect/index.module.css new file mode 100644 index 00000000..96b0d476 --- /dev/null +++ b/lib/static/new-ui/components/AdaptiveSelect/index.module.css @@ -0,0 +1,94 @@ +.selectPopup { + width: 150px; + font-size: var(--g-text-body-1-font-size); +} + +.selectPopup :global(.g-select-list__option) { + padding: 0; +} + +.selectPopup :global(.g-select-list__option .g-select-list__tick-icon) { + display: none; +} + +.option-container { + display: flex; + align-items: center; + width: 100%; + height: 100%; + padding: 0 10px; + gap: 4px; +} + +.current-option-icons-container { + margin-left: auto; + display: flex; + gap: 4px; +} + +.label-icons-container { + display: none; + position: relative; + padding-right: 2px; +} + +.label-icon-right { + margin-left: -6px; +} + +.label-dot { + display: none; + position: absolute; + right: 0; + top: 0; + height: 4px; + width: 4px; + border-radius: 100vh; + background-color: var(--g-color-private-red-600-solid); +} + +.direction-icon { + margin-left: auto; +} + +.clear-button { + display: none; + margin-left: auto; +} + +.clear-button:hover { + opacity: 0.7; + cursor: pointer; +} + +.container div:has(.tooltip) { + display: none; +} + +@container (max-width: 500px) { + .label-text { + display: none; + } + + .container div:has(.tooltip) { + display: block; + } + + .label-icons-container { + display: block; + } +} + +@container (max-width: 450px) { + .select :global(.g-select-control__option-text), .select :global(.g-select-clear) { + display: none; + } + + .clear-button { + display: block; + } + + .label-dot { + display: block; + } +} diff --git a/lib/static/new-ui/components/AdaptiveSelect/index.tsx b/lib/static/new-ui/components/AdaptiveSelect/index.tsx new file mode 100644 index 00000000..42a998a5 --- /dev/null +++ b/lib/static/new-ui/components/AdaptiveSelect/index.tsx @@ -0,0 +1,81 @@ +import {Xmark} from '@gravity-ui/icons'; +import {Icon, Select, SelectOption, SelectOptionGroup, Tooltip} from '@gravity-ui/uikit'; +import React, {ReactNode} from 'react'; + +import styles from './index.module.css'; + +interface AdaptiveSelectProps { + options: SelectOptionGroup[] | SelectOption[]; + currentValue: string; + label: string; + labelIcon: ReactNode; + autoClose?: boolean; + onClear?: () => void; + onOptionClick?: (value: string) => void; + currentOptionIcon?: ReactNode; +} + +/* This component implements a select that has 3 states: + - Full size, just like regular select + - Medium size, when label turns into icon + - Compact size, when only icon is displayed */ +export function AdaptiveSelect(props: AdaptiveSelectProps): ReactNode { + const onUpdate = (ids: string[]): void => { + if (ids.length === 0) { + props.onClear?.(); + } + }; + + const renderOption = (option: SelectOption): React.JSX.Element => { + const onOptionClick = (e: React.MouseEvent): void => { + e.stopPropagation(); + props.onOptionClick?.(option.value); + }; + + const onClearButtonClick = (e: React.MouseEvent): void => { + e.stopPropagation(); + onUpdate([]); + }; + + return
+ {option.content} + {props.currentValue === option.value &&
+ {props.currentOptionIcon} +
+
} +
; + }; + + return
+ + {/* This wrapper is crucial for the tooltip to position correctly */} +
+ ; + const onClear = (): void => { + dispatch(setCurrentGroupByExpression({expressionIds: []})); + }; + + return } + onClear={onClear} + onOptionClick={onOptionClick} + />; } diff --git a/lib/static/new-ui/features/suites/components/SortBySelect/index.module.css b/lib/static/new-ui/features/suites/components/SortBySelect/index.module.css new file mode 100644 index 00000000..126537d6 --- /dev/null +++ b/lib/static/new-ui/features/suites/components/SortBySelect/index.module.css @@ -0,0 +1,3 @@ +.label-icon-right { + margin-left: -6px; +} diff --git a/lib/static/new-ui/features/suites/components/SortBySelect/index.tsx b/lib/static/new-ui/features/suites/components/SortBySelect/index.tsx new file mode 100644 index 00000000..f66c6e8f --- /dev/null +++ b/lib/static/new-ui/features/suites/components/SortBySelect/index.tsx @@ -0,0 +1,55 @@ +import { + ArrowDown, + ArrowUp, + BarsAscendingAlignLeftArrowDown, + BarsDescendingAlignLeftArrowDown +} from '@gravity-ui/icons'; +import {Icon, SelectOption} from '@gravity-ui/uikit'; +import React, {ReactNode} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; + +import {SortDirection} from '@/static/new-ui/types/store'; +import {setCurrentSortByExpression, setSortByDirection} from '@/static/modules/actions/sort-tests'; +import {AdaptiveSelect} from '@/static/new-ui/components/AdaptiveSelect'; +import styles from './index.module.css'; + +export function SortBySelect(): ReactNode { + const dispatch = useDispatch(); + + const sortByExpressionId = useSelector((state) => state.app.sortTestsData.currentExpressionIds)[0]; + const currentDirection = useSelector((state) => state.app.sortTestsData.currentDirection); + + const sortByExpressions = useSelector(state => state.app.sortTestsData.availableExpressions) + .map((expr): SelectOption => ({content: expr.label, value: expr.id})); + + const onOptionClick = (newExpressionId: string): void => { + if (sortByExpressionId !== newExpressionId) { + dispatch(setCurrentSortByExpression({expressionIds: [newExpressionId]})); + } else { + dispatch(setSortByDirection({direction: currentDirection === SortDirection.Asc ? SortDirection.Desc : SortDirection.Asc})); + } + }; + + const onClear = (): void => { + dispatch(setCurrentSortByExpression({expressionIds: []})); + dispatch(setSortByDirection({direction: SortDirection.Asc})); + }; + + return + + + } + options={sortByExpressions} + currentValue={sortByExpressionId} + onClear={onClear} + onOptionClick={onOptionClick} + autoClose={false} + currentOptionIcon={<> + + } + />; +} diff --git a/lib/static/new-ui/features/suites/components/SuitesPage/types.ts b/lib/static/new-ui/features/suites/components/SuitesPage/types.ts index 0b104149..15078359 100644 --- a/lib/static/new-ui/features/suites/components/SuitesPage/types.ts +++ b/lib/static/new-ui/features/suites/components/SuitesPage/types.ts @@ -12,9 +12,9 @@ export interface TreeViewItemData { entityType: EntityType; entityId: string; prefix?: string; - title: string; + title: string[]; status: TestStatus | null; - tags?: string[]; + tags: string[]; errorTitle?: string; errorStack?: string; images?: ImageEntity[]; diff --git a/lib/static/new-ui/features/suites/components/SuitesTreeView/index.tsx b/lib/static/new-ui/features/suites/components/SuitesTreeView/index.tsx index aad97832..cd036e8e 100644 --- a/lib/static/new-ui/features/suites/components/SuitesTreeView/index.tsx +++ b/lib/static/new-ui/features/suites/components/SuitesTreeView/index.tsx @@ -61,7 +61,15 @@ export const SuitesTreeView = forwardRef parentRef.current, - estimateSize: () => 32, + estimateSize: (index) => { + const id = list.structure.visibleFlattenIds[index]; + const item = list.structure.itemsById[id]; + + // Groups on average take 3 lines: 2 lines of text (clamped) + 1 line for tags -> 73px in total + // Regular items on average take 1 line -> 32px + // Providing more precise estimates here greatly improves scrolling performance + return item.entityType === EntityType.Group ? 73 : 32; + }, getItemKey: useCallback((index: number) => list.structure.visibleFlattenIds[index], [list]), overscan: 50 }); @@ -144,6 +152,9 @@ export const SuitesTreeView = forwardRef { + [getGroups, getSuites, getAllRootGroupIds, getBrowsers, getBrowsersState, getResults, getImages, getTreeViewMode, getSortTestsData], + (groups, suites, rootGroupIds, browsers, browsersState, results, images, treeViewMode, sortTestsData): TreeViewData => { + const getTitlePath = (entity: SuiteEntity | BrowserEntity | undefined): string[] => { + if (!entity) { + return []; + } + + if (isSuiteEntity(entity)) { + return entity.suitePath; + } + + return [...getTitlePath(suites[entity.parentId]), entity.name]; + }; + const formatEntityToTreeNodeData = (entity: SuiteEntity | BrowserEntity, id: string, parentData?: TreeNode['data']): TreeNode['data'] => { if (isSuiteEntity(entity)) { return { id, entityType: getEntityType(entity), entityId: entity.id, - title: entity.name, + title: [entity.name], status: entity.status, - parentData + parentData, + tags: [] }; } @@ -61,13 +81,14 @@ export const getTreeViewItems = createSelector( id, entityType: getEntityType(entity), entityId: entity.id, - title: entity.name, + title: treeViewMode === TreeViewMode.Tree ? [entity.name] : getTitlePath(entity), status: lastResult.status, images: resultImages, errorTitle, errorStack, parentData, - skipReason: lastResult.skipReason + skipReason: lastResult.skipReason, + tags: [] }; }; @@ -84,7 +105,7 @@ export const getTreeViewItems = createSelector( let parentNode: TreeNode | TreeRoot; const {parentId} = entity; - if (parentId) { + if (treeViewMode === TreeViewMode.Tree && parentId) { const parentEntity = (suites[parentId] as SuiteEntity | undefined) ?? browsers[parentId]; parentNode = build(parentEntity); } else { @@ -98,7 +119,9 @@ export const getTreeViewItems = createSelector( return parentNode; } - const nodePartialId = isBrowserEntity(entity) ? entity.name : entity.suitePath[entity.suitePath.length - 1]; + const nodePartialId = treeViewMode === TreeViewMode.Tree ? + (isBrowserEntity(entity) ? entity.name : entity.suitePath[entity.suitePath.length - 1]) : + entity.id; const currentId = parentNode.data ? `${parentNode.data.id}/${nodePartialId}` : nodePartialId; if (cache[currentId]) { return cache[currentId]; @@ -140,7 +163,7 @@ export const getTreeViewItems = createSelector( entityType: EntityType.Group, entityId: groupEntity.id, prefix: `${groupEntity.key}:`, - title: groupEntity.label, + title: [groupEntity.label], status: null, tags: [ `${testsCount} ${testsCount > 1 ? ' tests' : 'test'}`, @@ -177,21 +200,158 @@ export const getTreeViewItems = createSelector( } }; + const currentSortDirection = sortTestsData.currentDirection; + const currentSortExpression = sortTestsData.availableExpressions.find(expr => expr.id === sortTestsData.currentExpressionIds[0]); + + const sortTreeNodes = (treeNodes: TreeNode[]): TreeNode[] => { + type TreeNodeWeight = number[] | string[]; + interface TreeWeightedSortResult { + sortedTreeNodes: TreeNode[]; + weight: TreeNodeWeight; + } + + const extractWeight = (treeNode: TreeNode, childrenWeight?: TreeNodeWeight): TreeNodeWeight => { + const notifyOfUnsuccessfulWeightComputation = (): void => { + console.warn('Failed to determine suite weight for tree node listed below. Please let us now at ' + NEW_ISSUE_LINK); + console.warn(treeNode); + }; + + switch (treeNode.data.entityType) { + case EntityType.Group: { + const group = groups[treeNode.data.entityId]; + + return [group.browserIds.length, group.resultIds.length]; + } + case EntityType.Suite: { + if (!currentSortExpression || currentSortExpression.type === SortType.ByName) { + return [treeNode.data.title.join(' ')]; + } else if (currentSortExpression.type === SortType.ByRetries) { + if (!childrenWeight) { + notifyOfUnsuccessfulWeightComputation(); + return [0]; + } + + return childrenWeight; + } + break; + } + case EntityType.Browser: { + if (!currentSortExpression || currentSortExpression.type === SortType.ByName) { + return [treeNode.data.title.join(' ')]; + } else if (currentSortExpression.type === SortType.ByRetries) { + const browser = browsers[treeNode.data.entityId]; + return [browser.resultIds.filter(resultId => isFailStatus(results[resultId].status) || isErrorStatus(results[resultId].status)).length]; + } + break; + } + } + + notifyOfUnsuccessfulWeightComputation(); + return [0]; + }; + + const aggregateWeights = (weights: TreeNodeWeight[]): TreeNodeWeight => { + if (!currentSortExpression || currentSortExpression.type === SortType.ByName) { + return [0]; + } + + if (currentSortExpression.type === SortType.ByRetries) { + return weights.reduce((acc, weight) => { + const newAcc = acc.slice(0); + for (let i = 0; i < weight.length; i++) { + newAcc[i] = (acc[i] ?? 0) + weight[i]; + } + return newAcc; + }, new Array(weights[0]?.length)); + } + + return [0]; + }; + + const sortAndGetWeight = (treeNodes: TreeNode[]): TreeWeightedSortResult => { + const treeNodesCopy = treeNodes.slice(0); + const weights: Record = {}; + + treeNodesCopy.forEach((treeNode, index) => { + if (treeNode.data.entityType === EntityType.Group && treeNode.children?.length) { + treeNodesCopy[index] = Object.assign({}, treeNode, { + children: sortAndGetWeight(treeNode.children).sortedTreeNodes + }); + + weights[treeNode.data.id] = extractWeight(treeNode); + } else if (treeNode.data.entityType === EntityType.Suite && treeNode.children?.length) { + const sortResult = sortAndGetWeight(treeNode.children); + const newTreeNode = Object.assign({}, treeNode, { + children: sortResult.sortedTreeNodes + }); + + const retriesCount = Number(sortResult.weight[0]); + if (currentSortExpression?.type === SortType.ByRetries && retriesCount > 0) { + newTreeNode.data.tags.push(`${retriesCount} ${retriesCount > 1 ? 'failed retries' : 'failed retry'}`); + } + treeNodesCopy[index] = newTreeNode; + + weights[treeNode.data.id] = extractWeight(treeNode, sortResult.weight); + } else if (treeNode.data.entityType === EntityType.Browser) { + const newTreeNode = Object.assign({}, treeNode); + + weights[treeNode.data.id] = extractWeight(treeNode); + + const retriesCount = weights[treeNode.data.id][0] as number; + if (currentSortExpression?.type === SortType.ByRetries && retriesCount > 0) { + newTreeNode.data.tags.push(`${retriesCount} ${retriesCount > 1 ? 'failed retries' : 'failed retry'}`); + } + + treeNodesCopy[index] = newTreeNode; + } + }); + + const sortedTreeNodes = treeNodesCopy.sort((a, b): number => { + const direction = currentSortDirection === SortDirection.Desc || a.data.entityType === EntityType.Group ? -1 : 1; + + for (let i = 0; i < weights[a.data.id].length; i++) { + const aWeight = weights[a.data.id][i]; + const bWeight = weights[b.data.id][i]; + if (aWeight === bWeight) { + continue; + } + + if (typeof aWeight === 'string' || typeof bWeight === 'string') { + return aWeight.toString().localeCompare(bWeight.toString()) * direction; + } + + return (aWeight - bWeight) * direction; + } + + return 0; + }); + + return { + sortedTreeNodes: sortedTreeNodes, + weight: aggregateWeights(Object.values(weights)) + }; + }; + + return sortAndGetWeight(treeNodes).sortedTreeNodes; + }; + if (rootGroupIds.length > 0) { const treeNodes = rootGroupIds .map(rootId => formatGroup(groups[rootId])) .filter(treeNode => treeNode.children?.length); - treeNodes.forEach(treeNode => collectVisibleBrowserIds(treeNode)); + const sortedTreeNodes = sortTreeNodes(treeNodes); + sortedTreeNodes.forEach(treeNode => collectVisibleBrowserIds(treeNode)); return { - tree: treeNodes, + tree: sortedTreeNodes, allTreeNodeIds, visibleTreeNodeIds }; } const suitesTreeRoot = buildTreeBottomUp(Object.values(browsers).filter(browser => browsersState[browser.id].shouldBeShown)); + suitesTreeRoot.children = sortTreeNodes(suitesTreeRoot.children ?? []); collectVisibleBrowserIds(suitesTreeRoot); return { diff --git a/lib/static/new-ui/features/suites/components/TreeActionsToolbar/index.module.css b/lib/static/new-ui/features/suites/components/TreeActionsToolbar/index.module.css index b07deb50..48feb159 100644 --- a/lib/static/new-ui/features/suites/components/TreeActionsToolbar/index.module.css +++ b/lib/static/new-ui/features/suites/components/TreeActionsToolbar/index.module.css @@ -5,6 +5,7 @@ display: flex; gap: 4px; position: relative; + container-type: inline-size; } .buttons-container { diff --git a/lib/static/new-ui/features/suites/components/TreeActionsToolbar/index.tsx b/lib/static/new-ui/features/suites/components/TreeActionsToolbar/index.tsx index cb4dda3a..08957597 100644 --- a/lib/static/new-ui/features/suites/components/TreeActionsToolbar/index.tsx +++ b/lib/static/new-ui/features/suites/components/TreeActionsToolbar/index.tsx @@ -9,7 +9,9 @@ import { Play, Square, SquareCheck, - SquareDashed + SquareDashed, + ListUl, + Hierarchy } from '@gravity-ui/icons'; import React, {ReactNode, useMemo} from 'react'; import {useDispatch, useSelector} from 'react-redux'; @@ -19,12 +21,13 @@ import { acceptOpened, deselectAll, retrySuite, - selectAll, setAllTreeNodesState, + selectAll, + setAllTreeNodesState, setTreeViewMode, staticAccepterStageScreenshot, staticAccepterUnstageScreenshot, undoAcceptImages } from '@/static/modules/actions'; -import {ImageEntity} from '@/static/new-ui/types/store'; +import {ImageEntity, TreeViewMode} from '@/static/new-ui/types/store'; import {CHECKED, INDETERMINATE} from '@/constants/checked-statuses'; import {IconButton} from '@/static/new-ui/components/IconButton'; import { @@ -43,6 +46,7 @@ import {isAcceptable, isScreenRevertable} from '@/static/modules/utils'; import {EditScreensFeature, RunTestsFeature} from '@/constants'; import {getTreeViewItems} from '@/static/new-ui/features/suites/components/SuitesTreeView/selectors'; import {GroupBySelect} from '@/static/new-ui/features/suites/components/GroupBySelect'; +import {SortBySelect} from '@/static/new-ui/features/suites/components/SortBySelect'; interface TreeActionsToolbarProps { onHighlightCurrentTest?: () => void; @@ -86,6 +90,7 @@ export function TreeActionsToolbar(props: TreeActionsToolbarProps): ReactNode { const selectedImages: ImageEntity[] = useSelector(getSelectedImages); const activeImages = isSelectedAtLeastOne ? selectedImages : visibleImages; + const treeViewMode = useSelector(state => state.ui.suitesPage.treeViewMode); const currentTreeNodeId = useSelector(state => state.app.suitesPage.currentTreeNodeId); const {visibleTreeNodeIds} = useSelector(getTreeViewItems); const isFocusAvailable = isInitialized && currentTreeNodeId && visibleTreeNodeIds.includes(currentTreeNodeId); @@ -152,6 +157,10 @@ export function TreeActionsToolbar(props: TreeActionsToolbarProps): ReactNode { } }; + const handleToggleTreeView = (): void => { + dispatch(setTreeViewMode({treeViewMode: treeViewMode === TreeViewMode.Tree ? TreeViewMode.List : TreeViewMode.Tree})); + }; + const selectedOrVisible = isSelectedAtLeastOne ? 'selected' : 'visible'; const areActionsDisabled = isRunning || !isInitialized; @@ -165,6 +174,12 @@ export function TreeActionsToolbar(props: TreeActionsToolbarProps): ReactNode { } tooltip={`Accept ${selectedOrVisible} screenshots`} view={'flat'} onClick={handleAccept} disabled={areActionsDisabled || !isAtLeastOneAcceptable}> )} {(isRunTestsAvailable || isEditScreensAvailable) &&
} + } + tooltip={treeViewMode === TreeViewMode.Tree ? 'Switch to list view' : 'Switch to tree view'} + view={'flat'} + onClick={handleToggleTreeView} + disabled={!isInitialized} /> } tooltip={'Focus on active test'} view={'flat'} onClick={props.onHighlightCurrentTest} disabled={!isFocusAvailable}/> } tooltip={'Expand all'} view={'flat'} onClick={handleExpandAll} disabled={!isInitialized}/> } tooltip={'Collapse all'} view={'flat'} onClick={handleCollapseAll} disabled={!isInitialized}/> @@ -172,7 +187,8 @@ export function TreeActionsToolbar(props: TreeActionsToolbarProps): ReactNode { ; return
- + +
{viewButtons}
diff --git a/lib/static/new-ui/features/suites/components/TreeViewItemSubtitle/index.tsx b/lib/static/new-ui/features/suites/components/TreeViewItemSubtitle/index.tsx index 7e283cad..81a6ad53 100644 --- a/lib/static/new-ui/features/suites/components/TreeViewItemSubtitle/index.tsx +++ b/lib/static/new-ui/features/suites/components/TreeViewItemSubtitle/index.tsx @@ -38,7 +38,7 @@ export function TreeViewItemSubtitle(props: TreeViewItemSubtitleProps): ReactNod
; } else if (props.item.errorStack) { return
- {stripAnsi(props.item.errorStack)} + {(props.item.errorTitle + '\n' + stripAnsi(props.item.errorStack)).trim()}
; } diff --git a/lib/static/new-ui/features/suites/components/TreeViewItemTitle/index.module.css b/lib/static/new-ui/features/suites/components/TreeViewItemTitle/index.module.css index 2e0b59ea..10cee25a 100644 --- a/lib/static/new-ui/features/suites/components/TreeViewItemTitle/index.module.css +++ b/lib/static/new-ui/features/suites/components/TreeViewItemTitle/index.module.css @@ -29,6 +29,15 @@ margin-right: 8px; } +.title-part { + opacity: 0.7; +} + +.title-separator { + opacity: 0.5; + margin: 0 4px; +} + .tags-container { display: inline-block; } @@ -43,6 +52,28 @@ padding: 0 4px; } +:global(.error) .tag { + background: var(--g-color-private-red-50); + color: var(--g-color-private-red-500-solid); +} + +:global(.current) .tag { + background-color: rgba(255, 255, 255, .15); + color: rgba(255, 255, 255, .7); +} + .tag:last-child { margin-right: 0; } + +.title-container--clamped { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + word-break: break-all; +} + +.title-container--inline { + display: inline; +} diff --git a/lib/static/new-ui/features/suites/components/TreeViewItemTitle/index.tsx b/lib/static/new-ui/features/suites/components/TreeViewItemTitle/index.tsx index d6549b80..7d920cd8 100644 --- a/lib/static/new-ui/features/suites/components/TreeViewItemTitle/index.tsx +++ b/lib/static/new-ui/features/suites/components/TreeViewItemTitle/index.tsx @@ -1,21 +1,22 @@ -import React, {useEffect, useRef} from 'react'; -import {EntityType, TreeViewItemData} from '@/static/new-ui/features/suites/components/SuitesPage/types'; -import styles from './index.module.css'; -import classNames from 'classnames'; +import {ChevronRight} from '@gravity-ui/icons'; import {Checkbox} from '@gravity-ui/uikit'; +import classNames from 'classnames'; +import React, {useEffect, useRef} from 'react'; import {useDispatch, useSelector} from 'react-redux'; -import {toggleBrowserCheckbox, toggleGroupCheckbox, toggleSuiteCheckbox} from '@/static/modules/actions'; + import {getToggledCheckboxState, isCheckboxChecked, isCheckboxIndeterminate} from '@/common-utils'; +import {EntityType, TreeViewItemData} from '@/static/new-ui/features/suites/components/SuitesPage/types'; +import {toggleBrowserCheckbox, toggleGroupCheckbox, toggleSuiteCheckbox} from '@/static/modules/actions'; import {getAreCheckboxesNeeded} from '@/static/new-ui/store/selectors'; import {getItemCheckStatus} from '@/static/new-ui/features/suites/components/TreeViewItemTitle/selectors'; -import {GroupEntity} from '@/static/new-ui/types/store'; +import styles from './index.module.css'; interface TreeViewItemTitleProps { className?: string; item: TreeViewItemData; } -export function TreeViewItemTitle({item, className}: TreeViewItemTitleProps): React.JSX.Element { +export function TreeViewItemTitle({item}: TreeViewItemTitleProps): React.JSX.Element { const dispatch = useDispatch(); const areCheckboxesNeeded = useSelector(getAreCheckboxesNeeded); const groups = useSelector(state => state.tree.groups.byId); @@ -28,7 +29,7 @@ export function TreeViewItemTitle({item, className}: TreeViewItemTitleProps): Re e.stopPropagation(); if (item.entityType === EntityType.Group) { - const group = Object.values(groups).find(group => group.id === item.entityId) as GroupEntity; + const group = groups[item.entityId]; dispatch(toggleGroupCheckbox({ browserIds: group.browserIds, @@ -49,21 +50,32 @@ export function TreeViewItemTitle({item, className}: TreeViewItemTitleProps): Re } }, [ref, item, checkStatus]); + const headTitleParts = item.title.slice(0, -1); + const tailTitlePart = item.title[item.title.length - 1]; + + const titleContainerClassName = classNames({ + [styles['title-container--clamped']]: item.entityType === EntityType.Group, + [styles['title-container--inline']]: item.entityType !== EntityType.Group + }); + return
- {item.prefix && {item.prefix}} - {item.title} +
+ {item.prefix && {item.prefix}} + + {headTitleParts.map((titlePart, index) => <> + {titlePart} + + )} + 0 ? styles.titlePart : ''}>{tailTitlePart} + +
{item.tags && item.tags.length > 0 &&
{item.tags.map((tag, index) => {tag})}
}
- { - item.entityType === EntityType.Browser && - item.errorTitle && - {item.errorTitle} - } {areCheckboxesNeeded && }
; } diff --git a/lib/static/new-ui/features/suites/components/TreeViewItemTitle/selectors.ts b/lib/static/new-ui/features/suites/components/TreeViewItemTitle/selectors.ts index 943ba717..189aa341 100644 --- a/lib/static/new-ui/features/suites/components/TreeViewItemTitle/selectors.ts +++ b/lib/static/new-ui/features/suites/components/TreeViewItemTitle/selectors.ts @@ -1,7 +1,7 @@ import {createSelector} from 'reselect'; import {CheckStatus, INDETERMINATE, UNCHECKED} from '@/constants/checked-statuses'; import {EntityType, TreeViewItemData} from '@/static/new-ui/features/suites/components/SuitesPage/types'; -import {GroupEntity, State} from '@/static/new-ui/types/store'; +import {State} from '@/static/new-ui/types/store'; import {getBrowsersState, getGroups, getSuitesState} from '@/static/new-ui/store/selectors'; import {NEW_ISSUE_LINK} from '@/constants'; @@ -13,7 +13,7 @@ export const getItemCheckStatus = createSelector( } else if (item.entityType === EntityType.Browser) { return browsersStateById[item.entityId].checkStatus; } else if (item.entityType === EntityType.Group) { - const group = Object.values(groups).find(group => group.id === item.entityId) as GroupEntity; + const group = groups[item.entityId]; const childCount = group.browserIds.length; const checkedCount = group.browserIds.reduce((sum, browserId) => { return sum + browsersStateById[browserId].checkStatus; diff --git a/lib/static/new-ui/features/suites/selectors.ts b/lib/static/new-ui/features/suites/selectors.ts index d56e7607..29e00216 100644 --- a/lib/static/new-ui/features/suites/selectors.ts +++ b/lib/static/new-ui/features/suites/selectors.ts @@ -23,8 +23,8 @@ export const getCurrentResultId = (state: State): string | null => { const treeNodeRetryResultId = resultIds[state.ui.suitesPage.retryIndexByTreeNodeId[treeNodeId] ?? -1]; let lastMatchedGroupResultId: string | undefined; - const group = Object.values(state.tree.groups.byId).find(group => group.id === groupId); - if (groupId && group) { + if (groupId) { + const group = state.tree.groups.byId[groupId]; lastMatchedGroupResultId = resultIds.findLast(resultId => group.resultIds.includes(resultId)); } diff --git a/lib/static/new-ui/store/selectors.ts b/lib/static/new-ui/store/selectors.ts index 44e5ba30..c465a05b 100644 --- a/lib/static/new-ui/store/selectors.ts +++ b/lib/static/new-ui/store/selectors.ts @@ -5,7 +5,7 @@ import { ResultEntity, SuiteEntity, SuiteState, - BrowserState, GroupEntity + BrowserState, GroupEntity, TreeViewMode } from '@/static/new-ui/types/store'; import {EditScreensFeature, RunTestsFeature} from '@/constants'; @@ -24,5 +24,7 @@ export const getImages = (state: State): Record => state.tr export const getIsInitialized = (state: State): boolean => state.app.isInitialized; export const getIsStaticImageAccepterEnabled = (state: State): boolean => state.staticImageAccepter.enabled; export const getIsGui = (state: State): boolean => state.gui; +export const getTreeViewMode = (state: State): TreeViewMode => state.ui.suitesPage.treeViewMode; +export const getSortTestsData = (state: State): State['app']['sortTestsData'] => state.app.sortTestsData; export const getAreCheckboxesNeeded = (state: State): boolean => state.app.availableFeatures.includes(RunTestsFeature) || state.app.availableFeatures.includes(EditScreensFeature); diff --git a/lib/static/new-ui/types/store.ts b/lib/static/new-ui/types/store.ts index dad16c69..18cc9eb1 100644 --- a/lib/static/new-ui/types/store.ts +++ b/lib/static/new-ui/types/store.ts @@ -206,6 +206,27 @@ export interface GroupByErrorExpression { export type GroupByExpression = GroupByMetaExpression | GroupByErrorExpression; +export enum SortType { + ByName, + ByRetries +} + +export enum SortDirection { + Asc, + Desc +} + +export interface SortByExpression { + id: string; + type: SortType; + label: string; +} + +export enum TreeViewMode { + Tree, + List +} + export interface State { app: { isNewUi: boolean; @@ -236,9 +257,15 @@ export interface State { availableExpressions: GroupByExpression[]; currentExpressionIds: string[]; }; + sortTestsData: { + availableExpressions: SortByExpression[]; + currentExpressionIds: string[]; + currentDirection: SortDirection; + } }; ui: { suitesPage: { + treeViewMode: TreeViewMode; retryIndexByTreeNodeId: Record; expandedTreeNodesById: Record; expandedSectionsById: Record; From 6273b6c6c072dec375ac791bdda56b5d0d936d2a Mon Sep 17 00:00:00 2001 From: shadowusr Date: Tue, 3 Dec 2024 02:03:25 +0300 Subject: [PATCH 2/7] refactor: simplify sorting logic --- .../reducers/new-ui-grouped-tests/utils.ts | 7 +- .../AdaptiveSelect/index.module.css | 6 +- .../suites/components/SuitesPage/types.ts | 6 +- .../components/SuitesTreeView/selectors.ts | 338 ++---------------- .../suites/components/SuitesTreeView/utils.ts | 330 +++++++++++++++++ lib/static/new-ui/types/store.ts | 5 +- 6 files changed, 367 insertions(+), 325 deletions(-) create mode 100644 lib/static/new-ui/features/suites/components/SuitesTreeView/utils.ts diff --git a/lib/static/modules/reducers/new-ui-grouped-tests/utils.ts b/lib/static/modules/reducers/new-ui-grouped-tests/utils.ts index b1aedef3..138d64d4 100644 --- a/lib/static/modules/reducers/new-ui-grouped-tests/utils.ts +++ b/lib/static/modules/reducers/new-ui-grouped-tests/utils.ts @@ -12,6 +12,7 @@ import {isAssertViewError} from '@/common-utils'; import stripAnsi from 'strip-ansi'; import {IMAGE_COMPARISON_FAILED_MESSAGE, TestStatus} from '@/constants'; import {stringify} from '@/static/new-ui/utils'; +import {EntityType} from '@/static/new-ui/features/suites/components/SuitesPage/types'; const extractErrors = (result: ResultEntity, images: ImageEntity[]): string[] => { const errors = new Set(); @@ -72,7 +73,8 @@ const groupTestsByMeta = (expr: GroupByMetaExpression, resultsById: Record, imagesById key: 'error', label: stripAnsi(groupLabel), resultIds: [], - browserIds: [] + browserIds: [], + type: EntityType.Group }; id++; } diff --git a/lib/static/new-ui/components/AdaptiveSelect/index.module.css b/lib/static/new-ui/components/AdaptiveSelect/index.module.css index 96b0d476..8356a638 100644 --- a/lib/static/new-ui/components/AdaptiveSelect/index.module.css +++ b/lib/static/new-ui/components/AdaptiveSelect/index.module.css @@ -1,13 +1,13 @@ -.selectPopup { +.select-popup { width: 150px; font-size: var(--g-text-body-1-font-size); } -.selectPopup :global(.g-select-list__option) { +.select-popup :global(.g-select-list__option) { padding: 0; } -.selectPopup :global(.g-select-list__option .g-select-list__tick-icon) { +.select-popup :global(.g-select-list__option .g-select-list__tick-icon) { display: none; } diff --git a/lib/static/new-ui/features/suites/components/SuitesPage/types.ts b/lib/static/new-ui/features/suites/components/SuitesPage/types.ts index 15078359..d93c2ab1 100644 --- a/lib/static/new-ui/features/suites/components/SuitesPage/types.ts +++ b/lib/static/new-ui/features/suites/components/SuitesPage/types.ts @@ -2,9 +2,9 @@ import {TestStatus} from '@/constants'; import {ImageEntity} from '@/static/new-ui/types/store'; export enum EntityType { - Group, - Suite, - Browser, + Group = 'group', + Suite = 'suite', + Browser = 'browser', } export interface TreeViewItemData { diff --git a/lib/static/new-ui/features/suites/components/SuitesTreeView/selectors.ts b/lib/static/new-ui/features/suites/components/SuitesTreeView/selectors.ts index 60920a7e..be07b9a1 100644 --- a/lib/static/new-ui/features/suites/components/SuitesTreeView/selectors.ts +++ b/lib/static/new-ui/features/suites/components/SuitesTreeView/selectors.ts @@ -1,16 +1,4 @@ import {createSelector} from 'reselect'; -import {last} from 'lodash'; -import { - BrowserEntity, - GroupEntity, - isBrowserEntity, - isResultEntityError, - isSuiteEntity, - SortDirection, - SortType, - SuiteEntity, - TreeViewMode -} from '@/static/new-ui/types/store'; import { getAllRootGroupIds, getBrowsers, @@ -22,11 +10,8 @@ import { getSuites, getTreeViewMode } from '@/static/new-ui/store/selectors'; -import {isErrorStatus, isFailStatus, trimArray} from '@/common-utils'; -import {isAcceptable} from '@/static/modules/utils'; -import {EntityType, TreeNode, TreeRoot} from '@/static/new-ui/features/suites/components/SuitesPage/types'; -import {getEntityType} from '@/static/new-ui/features/suites/utils'; -import {NEW_ISSUE_LINK} from '@/constants'; +import {TreeNode} from '@/static/new-ui/features/suites/components/SuitesPage/types'; +import {buildTreeBottomUp, collectTreeLeafIds, formatEntityToTreeNodeData, sortTreeNodes} from './utils'; interface TreeViewData { tree: TreeNode[]; @@ -38,310 +23,31 @@ interface TreeViewData { export const getTreeViewItems = createSelector( [getGroups, getSuites, getAllRootGroupIds, getBrowsers, getBrowsersState, getResults, getImages, getTreeViewMode, getSortTestsData], (groups, suites, rootGroupIds, browsers, browsersState, results, images, treeViewMode, sortTestsData): TreeViewData => { - const getTitlePath = (entity: SuiteEntity | BrowserEntity | undefined): string[] => { - if (!entity) { - return []; - } - - if (isSuiteEntity(entity)) { - return entity.suitePath; - } - - return [...getTitlePath(suites[entity.parentId]), entity.name]; - }; - - const formatEntityToTreeNodeData = (entity: SuiteEntity | BrowserEntity, id: string, parentData?: TreeNode['data']): TreeNode['data'] => { - if (isSuiteEntity(entity)) { - return { - id, - entityType: getEntityType(entity), - entityId: entity.id, - title: [entity.name], - status: entity.status, - parentData, - tags: [] - }; - } - - const lastResult = results[last(entity.resultIds) as string]; - - const resultImages = lastResult.imageIds - .map(imageId => images[imageId]) - .filter(imageEntity => isAcceptable(imageEntity)); - - let errorTitle, errorStack; - if (isResultEntityError(lastResult) && lastResult.error?.stack) { - errorTitle = lastResult.error?.name; - - const stackLines = trimArray(lastResult.error.stack.split('\n')); - errorStack = stackLines.slice(0, 3).join('\n'); - } - - return { - id, - entityType: getEntityType(entity), - entityId: entity.id, - title: treeViewMode === TreeViewMode.Tree ? [entity.name] : getTitlePath(entity), - status: lastResult.status, - images: resultImages, - errorTitle, - errorStack, - parentData, - skipReason: lastResult.skipReason, - tags: [] - }; - }; - - const buildTreeBottomUp = (entities: (SuiteEntity | BrowserEntity)[], rootData?: TreeNode['data']): TreeRoot => { - const TREE_ROOT = Symbol(); - const cache: Record = {}; - - const createTreeRoot = (): TreeRoot => ({ - isRoot: true, - data: rootData - }); - - const build = (entity: SuiteEntity | BrowserEntity): TreeNode | TreeRoot => { - let parentNode: TreeNode | TreeRoot; - - const {parentId} = entity; - if (treeViewMode === TreeViewMode.Tree && parentId) { - const parentEntity = (suites[parentId] as SuiteEntity | undefined) ?? browsers[parentId]; - parentNode = build(parentEntity); - } else { - if (!cache[TREE_ROOT]) { - cache[TREE_ROOT] = createTreeRoot(); - } - parentNode = cache[TREE_ROOT]; - } - - if (isBrowserEntity(entity) && !browsersState[entity.id].shouldBeShown) { - return parentNode; - } - - const nodePartialId = treeViewMode === TreeViewMode.Tree ? - (isBrowserEntity(entity) ? entity.name : entity.suitePath[entity.suitePath.length - 1]) : - entity.id; - const currentId = parentNode.data ? `${parentNode.data.id}/${nodePartialId}` : nodePartialId; - if (cache[currentId]) { - return cache[currentId]; - } - - const currentNode: TreeNode = { - parentNode, - data: formatEntityToTreeNodeData(entity, currentId, parentNode.data) - }; - cache[currentId] = currentNode; - - if (parentNode) { - if (!parentNode.children) { - parentNode.children = []; - } - - parentNode.children.push(currentNode); - } - - return currentNode; - }; - - for (const entity of entities) { - build(entity); - } - - return cache[TREE_ROOT] as TreeRoot ?? createTreeRoot(); - }; - - const formatGroup = (groupEntity: GroupEntity): TreeNode => { - const groupBrowserIds = groupEntity.browserIds.filter(browserId => browsersState[browserId].shouldBeShown); - const browserEntities = groupBrowserIds.map(browserId => browsers[browserId]); - - const testsCount = groupBrowserIds.length; - const retriesCount = groupEntity.resultIds.filter(resultId => browserEntities.find(browser => browser.resultIds.includes(resultId))).length; - - const groupNodeData: TreeNode['data'] = { - id: groupEntity.id, - entityType: EntityType.Group, - entityId: groupEntity.id, - prefix: `${groupEntity.key}:`, - title: [groupEntity.label], - status: null, - tags: [ - `${testsCount} ${testsCount > 1 ? ' tests' : 'test'}`, - `${retriesCount} ${retriesCount > 1 ? ' retries' : 'retry'}` - ] - }; - - const suitesTreeRoot = buildTreeBottomUp(browserEntities, groupNodeData); - - return { - data: groupNodeData, - children: suitesTreeRoot.children - }; - }; - - const allTreeNodeIds: string[] = []; - const visibleTreeNodeIds: string[] = []; - - const collectVisibleBrowserIds = (node: TreeNode | TreeRoot): void => { - if (node.data && node.data.id) { - allTreeNodeIds.push(node.data.id); - } - - if (!node.children) { - return; - } - - for (const childNode of node.children) { - if (childNode.data.entityType === EntityType.Browser) { - visibleTreeNodeIds.push(childNode.data.id); - } else if (childNode.children?.length) { - collectVisibleBrowserIds(childNode); - } - } - }; - const currentSortDirection = sortTestsData.currentDirection; const currentSortExpression = sortTestsData.availableExpressions.find(expr => expr.id === sortTestsData.currentExpressionIds[0]); - const sortTreeNodes = (treeNodes: TreeNode[]): TreeNode[] => { - type TreeNodeWeight = number[] | string[]; - interface TreeWeightedSortResult { - sortedTreeNodes: TreeNode[]; - weight: TreeNodeWeight; - } - - const extractWeight = (treeNode: TreeNode, childrenWeight?: TreeNodeWeight): TreeNodeWeight => { - const notifyOfUnsuccessfulWeightComputation = (): void => { - console.warn('Failed to determine suite weight for tree node listed below. Please let us now at ' + NEW_ISSUE_LINK); - console.warn(treeNode); - }; - - switch (treeNode.data.entityType) { - case EntityType.Group: { - const group = groups[treeNode.data.entityId]; - - return [group.browserIds.length, group.resultIds.length]; - } - case EntityType.Suite: { - if (!currentSortExpression || currentSortExpression.type === SortType.ByName) { - return [treeNode.data.title.join(' ')]; - } else if (currentSortExpression.type === SortType.ByRetries) { - if (!childrenWeight) { - notifyOfUnsuccessfulWeightComputation(); - return [0]; - } - - return childrenWeight; - } - break; - } - case EntityType.Browser: { - if (!currentSortExpression || currentSortExpression.type === SortType.ByName) { - return [treeNode.data.title.join(' ')]; - } else if (currentSortExpression.type === SortType.ByRetries) { - const browser = browsers[treeNode.data.entityId]; - return [browser.resultIds.filter(resultId => isFailStatus(results[resultId].status) || isErrorStatus(results[resultId].status)).length]; - } - break; - } - } - - notifyOfUnsuccessfulWeightComputation(); - return [0]; - }; - - const aggregateWeights = (weights: TreeNodeWeight[]): TreeNodeWeight => { - if (!currentSortExpression || currentSortExpression.type === SortType.ByName) { - return [0]; - } - - if (currentSortExpression.type === SortType.ByRetries) { - return weights.reduce((acc, weight) => { - const newAcc = acc.slice(0); - for (let i = 0; i < weight.length; i++) { - newAcc[i] = (acc[i] ?? 0) + weight[i]; - } - return newAcc; - }, new Array(weights[0]?.length)); - } - - return [0]; - }; - - const sortAndGetWeight = (treeNodes: TreeNode[]): TreeWeightedSortResult => { - const treeNodesCopy = treeNodes.slice(0); - const weights: Record = {}; + const entitiesContext = {results, images, suites, treeViewMode, browsersState, browsers, groups, currentSortDirection, currentSortExpression}; - treeNodesCopy.forEach((treeNode, index) => { - if (treeNode.data.entityType === EntityType.Group && treeNode.children?.length) { - treeNodesCopy[index] = Object.assign({}, treeNode, { - children: sortAndGetWeight(treeNode.children).sortedTreeNodes - }); - - weights[treeNode.data.id] = extractWeight(treeNode); - } else if (treeNode.data.entityType === EntityType.Suite && treeNode.children?.length) { - const sortResult = sortAndGetWeight(treeNode.children); - const newTreeNode = Object.assign({}, treeNode, { - children: sortResult.sortedTreeNodes - }); - - const retriesCount = Number(sortResult.weight[0]); - if (currentSortExpression?.type === SortType.ByRetries && retriesCount > 0) { - newTreeNode.data.tags.push(`${retriesCount} ${retriesCount > 1 ? 'failed retries' : 'failed retry'}`); - } - treeNodesCopy[index] = newTreeNode; - - weights[treeNode.data.id] = extractWeight(treeNode, sortResult.weight); - } else if (treeNode.data.entityType === EntityType.Browser) { - const newTreeNode = Object.assign({}, treeNode); - - weights[treeNode.data.id] = extractWeight(treeNode); - - const retriesCount = weights[treeNode.data.id][0] as number; - if (currentSortExpression?.type === SortType.ByRetries && retriesCount > 0) { - newTreeNode.data.tags.push(`${retriesCount} ${retriesCount > 1 ? 'failed retries' : 'failed retry'}`); - } - - treeNodesCopy[index] = newTreeNode; - } - }); - - const sortedTreeNodes = treeNodesCopy.sort((a, b): number => { - const direction = currentSortDirection === SortDirection.Desc || a.data.entityType === EntityType.Group ? -1 : 1; - - for (let i = 0; i < weights[a.data.id].length; i++) { - const aWeight = weights[a.data.id][i]; - const bWeight = weights[b.data.id][i]; - if (aWeight === bWeight) { - continue; - } - - if (typeof aWeight === 'string' || typeof bWeight === 'string') { - return aWeight.toString().localeCompare(bWeight.toString()) * direction; - } - - return (aWeight - bWeight) * direction; - } - - return 0; - }); + const isGroupingEnabled = rootGroupIds.length > 0; + if (isGroupingEnabled) { + const treeNodes = rootGroupIds + .map(rootId => { + const groupEntity = groups[rootId]; - return { - sortedTreeNodes: sortedTreeNodes, - weight: aggregateWeights(Object.values(weights)) - }; - }; + const browserEntities = groupEntity.browserIds.flatMap(browserId => browsersState[browserId].shouldBeShown ? [browsers[browserId]] : []); + const groupNodeData = formatEntityToTreeNodeData(entitiesContext, groupEntity, groupEntity.id); - return sortAndGetWeight(treeNodes).sortedTreeNodes; - }; + const suitesTreeRoot = buildTreeBottomUp(entitiesContext, browserEntities, groupNodeData); - if (rootGroupIds.length > 0) { - const treeNodes = rootGroupIds - .map(rootId => formatGroup(groups[rootId])) + return { + data: groupNodeData, + children: suitesTreeRoot.children + }; + }) .filter(treeNode => treeNode.children?.length); - const sortedTreeNodes = sortTreeNodes(treeNodes); - sortedTreeNodes.forEach(treeNode => collectVisibleBrowserIds(treeNode)); + const sortedTreeNodes = sortTreeNodes(entitiesContext, treeNodes); + const {allTreeNodeIds, visibleTreeNodeIds} = collectTreeLeafIds(sortedTreeNodes); return { tree: sortedTreeNodes, @@ -350,13 +56,13 @@ export const getTreeViewItems = createSelector( }; } - const suitesTreeRoot = buildTreeBottomUp(Object.values(browsers).filter(browser => browsersState[browser.id].shouldBeShown)); - suitesTreeRoot.children = sortTreeNodes(suitesTreeRoot.children ?? []); - collectVisibleBrowserIds(suitesTreeRoot); + const suitesTreeRoot = buildTreeBottomUp(entitiesContext, Object.values(browsers).filter(browser => browsersState[browser.id].shouldBeShown)); + suitesTreeRoot.children = sortTreeNodes(entitiesContext, suitesTreeRoot.children ?? []); + const {allTreeNodeIds, visibleTreeNodeIds} = collectTreeLeafIds([suitesTreeRoot]); return { - visibleTreeNodeIds, allTreeNodeIds, + visibleTreeNodeIds, tree: suitesTreeRoot.children ?? [] }; }); diff --git a/lib/static/new-ui/features/suites/components/SuitesTreeView/utils.ts b/lib/static/new-ui/features/suites/components/SuitesTreeView/utils.ts new file mode 100644 index 00000000..234235d5 --- /dev/null +++ b/lib/static/new-ui/features/suites/components/SuitesTreeView/utils.ts @@ -0,0 +1,330 @@ +import { + BrowserEntity, BrowserState, GroupEntity, ImageEntity, isBrowserEntity, isGroupEntity, + isResultEntityError, + isSuiteEntity, ResultEntity, SortByExpression, SortDirection, SortType, + SuiteEntity, + TreeViewMode +} from '@/static/new-ui/types/store'; +import {EntityType, TreeNode, TreeRoot} from '@/static/new-ui/features/suites/components/SuitesPage/types'; +import {getEntityType} from '@/static/new-ui/features/suites/utils'; +import {last} from 'lodash'; +import {isAcceptable} from '@/static/modules/utils'; +import {isErrorStatus, isFailStatus, trimArray} from '@/common-utils'; +import {NEW_ISSUE_LINK} from '@/constants'; + +export const getTitlePath = (suites: Record, entity: SuiteEntity | BrowserEntity | undefined): string[] => { + if (!entity) { + return []; + } + + if (isSuiteEntity(entity)) { + return entity.suitePath; + } + + return [...getTitlePath(suites, suites[entity.parentId]), entity.name]; +}; + +interface EntitiesContext { + browsers: Record; + browsersState: Record; + results: Record; + images: Record; + suites: Record; + groups: Record; + treeViewMode: TreeViewMode; + currentSortDirection: SortDirection; + currentSortExpression: SortByExpression | undefined; +} + +export const formatEntityToTreeNodeData = ({browsers, browsersState, results, images, suites, treeViewMode}: EntitiesContext, entity: SuiteEntity | BrowserEntity | GroupEntity, id: string, parentData?: TreeNode['data']): TreeNode['data'] => { + if (isSuiteEntity(entity)) { + return { + id, + entityType: getEntityType(entity), + entityId: entity.id, + title: [entity.name], + status: entity.status, + parentData, + tags: [] + }; + } + + if (isGroupEntity(entity)) { + const browserEntities = entity.browserIds.flatMap(browserId => browsersState[browserId].shouldBeShown ? [browsers[browserId]] : []); + + const testsCount = browserEntities.length; + const retriesCount = entity.resultIds.filter(resultId => browserEntities.find(browser => browser.resultIds.includes(resultId))).length; + + return { + id: entity.id, + entityType: EntityType.Group, + entityId: entity.id, + prefix: `${entity.key}:`, + title: [entity.label], + status: null, + tags: [ + `${testsCount} ${testsCount > 1 ? ' tests' : 'test'}`, + `${retriesCount} ${retriesCount > 1 ? ' retries' : 'retry'}` + ] + }; + } + + // Otherwise, it's BrowserEntity + const lastResult = results[last(entity.resultIds) as string]; + + const resultImages = lastResult.imageIds + .map(imageId => images[imageId]) + .filter(imageEntity => isAcceptable(imageEntity)); + + let errorTitle, errorStack; + if (isResultEntityError(lastResult) && lastResult.error?.stack) { + errorTitle = lastResult.error?.name; + + const stackLines = trimArray(lastResult.error.stack.split('\n')); + errorStack = stackLines.slice(0, 3).join('\n'); + } + + return { + id, + entityType: getEntityType(entity), + entityId: entity.id, + title: treeViewMode === TreeViewMode.Tree ? [entity.name] : getTitlePath(suites, entity), + status: lastResult.status, + images: resultImages, + errorTitle, + errorStack, + parentData, + skipReason: lastResult.skipReason, + tags: [] + }; +}; + +export const buildTreeBottomUp = (entitiesContext: EntitiesContext, entities: (SuiteEntity | BrowserEntity)[], rootData?: TreeNode['data']): TreeRoot => { + const {browsers, suites, browsersState, treeViewMode} = entitiesContext; + + const TREE_ROOT = Symbol(); + const cache: Record = {}; + + const createTreeRoot = (): TreeRoot => ({ + isRoot: true, + data: rootData + }); + + const build = (entity: SuiteEntity | BrowserEntity): TreeNode | TreeRoot => { + let parentNode: TreeNode | TreeRoot; + + const {parentId} = entity; + if (treeViewMode === TreeViewMode.Tree && parentId) { + const parentEntity = (suites[parentId] as SuiteEntity | undefined) ?? browsers[parentId]; + parentNode = build(parentEntity); + } else { + if (!cache[TREE_ROOT]) { + cache[TREE_ROOT] = createTreeRoot(); + } + parentNode = cache[TREE_ROOT]; + } + + if (isBrowserEntity(entity) && !browsersState[entity.id].shouldBeShown) { + return parentNode; + } + + const nodePartialId = treeViewMode === TreeViewMode.Tree ? + (isBrowserEntity(entity) ? entity.name : entity.suitePath[entity.suitePath.length - 1]) : + entity.id; + const currentId = parentNode.data ? `${parentNode.data.id}/${nodePartialId}` : nodePartialId; + if (cache[currentId]) { + return cache[currentId]; + } + + const currentNode: TreeNode = { + parentNode, + data: formatEntityToTreeNodeData(entitiesContext, entity, currentId, parentNode.data) + }; + cache[currentId] = currentNode; + + if (parentNode) { + if (!parentNode.children) { + parentNode.children = []; + } + + parentNode.children.push(currentNode); + } + + return currentNode; + }; + + for (const entity of entities) { + build(entity); + } + + return cache[TREE_ROOT] as TreeRoot ?? createTreeRoot(); +}; + +export const collectTreeLeafIds = (treeNodes: (TreeNode | TreeRoot)[]): {allTreeNodeIds: string[], visibleTreeNodeIds: string[]} => { + const allTreeNodeIds: string[] = []; + const visibleTreeNodeIds: string[] = []; + + const collectBrowserIdsInternal = (node: TreeNode | TreeRoot): void => { + if (node.data && node.data.id) { + allTreeNodeIds.push(node.data.id); + } + + if (!node.children) { + return; + } + + for (const childNode of node.children) { + if (childNode.data.entityType === EntityType.Browser) { + visibleTreeNodeIds.push(childNode.data.id); + } else if (childNode.children?.length) { + collectBrowserIdsInternal(childNode); + } + } + }; + + treeNodes.forEach(treeNode => collectBrowserIdsInternal(treeNode)); + + return {allTreeNodeIds, visibleTreeNodeIds}; +}; + +export const sortTreeNodes = (entitiesContext: EntitiesContext, treeNodes: TreeNode[]): TreeNode[] => { + const {groups, results, currentSortExpression, currentSortDirection, browsers} = entitiesContext; + + // Weight of a single node is an array, because sorting may be performed by multiple fields at once + // For example, sort by tests count, but if tests counts are equal, compare retries counts + // In this case weight for each node is [testsCount, retriesCount] + type TreeNodeWeight = number[] | string[]; + + interface TreeWeightedSortResult { + sortedTreeNodes: TreeNode[]; + weight: TreeNodeWeight; + } + + const extractWeight = (treeNode: TreeNode, childrenWeight?: TreeNodeWeight): TreeNodeWeight => { + const notifyOfUnsuccessfulWeightComputation = (): void => { + console.warn('Failed to determine suite weight for tree node listed below. Please let us now at ' + NEW_ISSUE_LINK); + console.warn(treeNode); + }; + + switch (treeNode.data.entityType) { + case EntityType.Group: { + const group = groups[treeNode.data.entityId]; + + return [group.browserIds.length, group.resultIds.length]; + } + case EntityType.Suite: { + if (!currentSortExpression || currentSortExpression.type === SortType.ByName) { + return [treeNode.data.title.join(' ')]; + } else if (currentSortExpression.type === SortType.ByRetries) { + if (!childrenWeight) { + notifyOfUnsuccessfulWeightComputation(); + return [0]; + } + + return childrenWeight; + } + break; + } + case EntityType.Browser: { + if (!currentSortExpression || currentSortExpression.type === SortType.ByName) { + return [treeNode.data.title.join(' ')]; + } else if (currentSortExpression.type === SortType.ByRetries) { + const browser = browsers[treeNode.data.entityId]; + return [browser.resultIds.filter(resultId => isFailStatus(results[resultId].status) || isErrorStatus(results[resultId].status)).length]; + } + break; + } + } + + notifyOfUnsuccessfulWeightComputation(); + return [0]; + }; + + const aggregateWeights = (weights: TreeNodeWeight[]): TreeNodeWeight => { + if (!currentSortExpression || currentSortExpression.type === SortType.ByName) { + return [0]; + } + + if (currentSortExpression.type === SortType.ByRetries) { + return weights.reduce((acc, weight) => { + const newAcc = acc.slice(0); + for (let i = 0; i < weight.length; i++) { + newAcc[i] = (acc[i] ?? 0) + weight[i]; + } + return newAcc; + }, new Array(weights[0]?.length)); + } + + return [0]; + }; + + // Recursive tree sort. At each level of the tree, it does the following: + // 1. Compute weights of the current branch + // 2. Sort current level according to weights + // 3. Return sorted current level and aggregate of all weights at current level + const sortAndGetWeight = (treeNodes: TreeNode[]): TreeWeightedSortResult => { + const treeNodesCopy = treeNodes.slice(0); + const weights: Record = {}; + + treeNodesCopy.forEach((treeNode, index) => { + if (treeNode.data.entityType === EntityType.Group && treeNode.children?.length) { + treeNodesCopy[index] = Object.assign({}, treeNode, { + children: sortAndGetWeight(treeNode.children).sortedTreeNodes + }); + + weights[treeNode.data.id] = extractWeight(treeNode); + } else if (treeNode.data.entityType === EntityType.Suite && treeNode.children?.length) { + const sortResult = sortAndGetWeight(treeNode.children); + const newTreeNode = Object.assign({}, treeNode, { + children: sortResult.sortedTreeNodes + }); + + const retriesCount = Number(sortResult.weight[0]); + if (currentSortExpression?.type === SortType.ByRetries && retriesCount > 0) { + newTreeNode.data.tags.push(`${retriesCount} ${retriesCount > 1 ? 'failed retries' : 'failed retry'}`); + } + treeNodesCopy[index] = newTreeNode; + + weights[treeNode.data.id] = extractWeight(treeNode, sortResult.weight); + } else if (treeNode.data.entityType === EntityType.Browser) { + const newTreeNode = Object.assign({}, treeNode); + + weights[treeNode.data.id] = extractWeight(treeNode); + + const retriesCount = weights[treeNode.data.id][0] as number; + if (currentSortExpression?.type === SortType.ByRetries && retriesCount > 0) { + newTreeNode.data.tags.push(`${retriesCount} ${retriesCount > 1 ? 'failed retries' : 'failed retry'}`); + } + + treeNodesCopy[index] = newTreeNode; + } + }); + + const sortedTreeNodes = treeNodesCopy.sort((a, b): number => { + const direction = currentSortDirection === SortDirection.Desc || a.data.entityType === EntityType.Group ? -1 : 1; + + for (let i = 0; i < weights[a.data.id].length; i++) { + const aWeight = weights[a.data.id][i]; + const bWeight = weights[b.data.id][i]; + if (aWeight === bWeight) { + continue; + } + + if (typeof aWeight === 'string' || typeof bWeight === 'string') { + return aWeight.toString().localeCompare(bWeight.toString()) * direction; + } + + return (aWeight - bWeight) * direction; + } + + return 0; + }); + + return { + sortedTreeNodes: sortedTreeNodes, + weight: aggregateWeights(Object.values(weights)) + }; + }; + + return sortAndGetWeight(treeNodes).sortedTreeNodes; +}; diff --git a/lib/static/new-ui/types/store.ts b/lib/static/new-ui/types/store.ts index 18cc9eb1..a3c8339b 100644 --- a/lib/static/new-ui/types/store.ts +++ b/lib/static/new-ui/types/store.ts @@ -12,6 +12,7 @@ import {CoordBounds} from 'looks-same'; import {Point} from '@/static/new-ui/types/index'; import {AcceptableImage} from '@/static/modules/static-image-accepter'; import {CheckStatus} from '@/constants/checked-statuses'; +import {EntityType} from '@/static/new-ui/features/suites/components/SuitesPage/types'; export interface GroupEntity { id: string; @@ -21,6 +22,7 @@ export interface GroupEntity { label: string; resultIds: string[]; browserIds: string[]; + type: EntityType.Group; } export interface SuiteEntityNode { @@ -53,8 +55,9 @@ export interface BrowserEntity { parentId: string; } -export const isSuiteEntity = (entity: SuiteEntity | BrowserEntity): entity is SuiteEntity => Boolean((entity as SuiteEntity).suitePath); +export const isSuiteEntity = (entity: SuiteEntity | BrowserEntity | GroupEntity): entity is SuiteEntity => Boolean((entity as SuiteEntity).suitePath); export const isBrowserEntity = (entity: SuiteEntity | BrowserEntity): entity is BrowserEntity => Boolean((entity as BrowserEntity).resultIds); +export const isGroupEntity = (entity: SuiteEntity | BrowserEntity | GroupEntity): entity is GroupEntity => (entity as GroupEntity).type === EntityType.Group; export interface ResultEntityCommon { id: string; From 159d2ca37d1ab5fd541ab04e277d33d4306952fe Mon Sep 17 00:00:00 2001 From: shadowusr Date: Tue, 3 Dec 2024 12:50:11 +0300 Subject: [PATCH 3/7] fix: make toolbar select popups stay on top of tree view --- .../suites/components/TreeActionsToolbar/index.module.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/static/new-ui/features/suites/components/TreeActionsToolbar/index.module.css b/lib/static/new-ui/features/suites/components/TreeActionsToolbar/index.module.css index 48feb159..c3f4b944 100644 --- a/lib/static/new-ui/features/suites/components/TreeActionsToolbar/index.module.css +++ b/lib/static/new-ui/features/suites/components/TreeActionsToolbar/index.module.css @@ -6,6 +6,8 @@ gap: 4px; position: relative; container-type: inline-size; + /* This makes toolbar select popups stay on top of tree view */ + z-index: 1; } .buttons-container { From e87947cbb9713a9d3fb10cd586ced77003dc7deb Mon Sep 17 00:00:00 2001 From: shadowusr Date: Wed, 4 Dec 2024 02:02:27 +0300 Subject: [PATCH 4/7] fix: adaptive select redesign, group by message fixes, perf optimisation --- .../reducers/new-ui-grouped-tests/index.ts | 4 +- .../reducers/new-ui-grouped-tests/utils.ts | 24 +++--- lib/static/modules/reducers/sort-tests.ts | 10 ++- .../AdaptiveSelect/index.module.css | 69 ++-------------- .../components/AdaptiveSelect/index.tsx | 68 ++++++---------- .../suites/components/GroupBySelect/index.tsx | 68 +++++++++------- .../components/SortBySelect/index.module.css | 10 +++ .../suites/components/SortBySelect/index.tsx | 78 ++++++++++++------- .../suites/components/SuitesTreeView/utils.ts | 2 +- .../TreeActionsToolbar/index.module.css | 1 + lib/static/new-ui/types/store.ts | 4 +- 11 files changed, 160 insertions(+), 178 deletions(-) diff --git a/lib/static/modules/reducers/new-ui-grouped-tests/index.ts b/lib/static/modules/reducers/new-ui-grouped-tests/index.ts index 74950527..024c91fc 100644 --- a/lib/static/modules/reducers/new-ui-grouped-tests/index.ts +++ b/lib/static/modules/reducers/new-ui-grouped-tests/index.ts @@ -16,10 +16,10 @@ export default (state: State, action: SomeAction): State => { case actionNames.INIT_STATIC_REPORT: { const availableSections: GroupBySection[] = [{ id: 'meta', - label: 'meta' + label: 'Meta' }, { id: 'error', - label: 'error' + label: 'Error' }]; const availableExpressions: GroupByExpression[] = []; diff --git a/lib/static/modules/reducers/new-ui-grouped-tests/utils.ts b/lib/static/modules/reducers/new-ui-grouped-tests/utils.ts index 138d64d4..a62d982a 100644 --- a/lib/static/modules/reducers/new-ui-grouped-tests/utils.ts +++ b/lib/static/modules/reducers/new-ui-grouped-tests/utils.ts @@ -88,7 +88,8 @@ const groupTestsByMeta = (expr: GroupByMetaExpression, resultsById: Record, imagesById: Record, errorPatterns: State['config']['errorPatterns']): Record => { - const groups: Record = {}; + const groupsById: Record = {}; + const groupingKeyToId: Record = {}; const results = Object.values(resultsById); let id = 1; @@ -109,26 +110,31 @@ const groupTestsByError = (resultsById: Record, imagesById groupingKey = `${GroupByType.Error}__${errorText}`; } - if (!groups[groupingKey]) { - groups[groupingKey] = { - id: id.toString(), + if (!groupingKeyToId[groupingKey]) { + groupingKeyToId[groupingKey] = id.toString(); + id++; + } + + const groupId = groupingKeyToId[groupingKey]; + if (!groupsById[groupId]) { + groupsById[groupId] = { + id: groupId, key: 'error', label: stripAnsi(groupLabel), resultIds: [], browserIds: [], type: EntityType.Group }; - id++; } - groups[groupingKey].resultIds.push(result.id); - if (!groups[groupingKey].browserIds.includes(result.parentId)) { - groups[groupingKey].browserIds.push(result.parentId); + groupsById[groupId].resultIds.push(result.id); + if (!groupsById[groupId].browserIds.includes(result.parentId)) { + groupsById[groupId].browserIds.push(result.parentId); } } } - return groups; + return groupsById; }; export const groupTests = (groupByExpressions: GroupByExpression[], resultsById: Record, imagesById: Record, errorPatterns: State['config']['errorPatterns']): Record => { diff --git a/lib/static/modules/reducers/sort-tests.ts b/lib/static/modules/reducers/sort-tests.ts index 74d640d8..fc9db2f9 100644 --- a/lib/static/modules/reducers/sort-tests.ts +++ b/lib/static/modules/reducers/sort-tests.ts @@ -1,4 +1,4 @@ -import {SortByExpression, SortType, State} from '@/static/new-ui/types/store'; +import {SortByExpression, SortDirection, SortType, State} from '@/static/new-ui/types/store'; import {SomeAction} from '@/static/modules/actions/types'; import actionNames from '@/static/modules/action-names'; import {applyStateUpdate} from '@/static/modules/utils'; @@ -8,14 +8,16 @@ export default (state: State, action: SomeAction): State => { case actionNames.INIT_STATIC_REPORT: case actionNames.INIT_GUI_REPORT: { const availableExpressions: SortByExpression[] = [ - {id: 'by-name', label: 'name', type: SortType.ByName}, - {id: 'by-retries', label: 'failed retries', type: SortType.ByRetries} + {id: 'by-name', label: 'Name', type: SortType.ByName}, + {id: 'by-retries', label: 'Failed retries', type: SortType.ByRetries} ]; return applyStateUpdate(state, { app: { sortTestsData: { - availableExpressions + availableExpressions, + currentDirection: SortDirection.Asc, + currentExpressionIds: [availableExpressions[0].id] } } }); diff --git a/lib/static/new-ui/components/AdaptiveSelect/index.module.css b/lib/static/new-ui/components/AdaptiveSelect/index.module.css index 8356a638..be45856f 100644 --- a/lib/static/new-ui/components/AdaptiveSelect/index.module.css +++ b/lib/static/new-ui/components/AdaptiveSelect/index.module.css @@ -1,41 +1,22 @@ .select-popup { - width: 150px; - font-size: var(--g-text-body-1-font-size); -} - -.select-popup :global(.g-select-list__option) { - padding: 0; -} + --g-color-text-info: #000; -.select-popup :global(.g-select-list__option .g-select-list__tick-icon) { - display: none; + font-size: var(--g-text-body-1-font-size); } -.option-container { - display: flex; - align-items: center; - width: 100%; - height: 100%; - padding: 0 10px; - gap: 4px; +.select :global(.g-select-control__label) { + margin-inline-end: 0; } -.current-option-icons-container { - margin-left: auto; - display: flex; - gap: 4px; +.selected-option { + margin-inline-start: 4px; } .label-icons-container { - display: none; position: relative; padding-right: 2px; } -.label-icon-right { - margin-left: -6px; -} - .label-dot { display: none; position: absolute; @@ -47,47 +28,11 @@ background-color: var(--g-color-private-red-600-solid); } -.direction-icon { - margin-left: auto; -} - -.clear-button { - display: none; - margin-left: auto; -} - -.clear-button:hover { - opacity: 0.7; - cursor: pointer; -} - -.container div:has(.tooltip) { - display: none; -} - -@container (max-width: 500px) { - .label-text { - display: none; - } - - .container div:has(.tooltip) { - display: block; - } - - .label-icons-container { - display: block; - } -} - @container (max-width: 450px) { - .select :global(.g-select-control__option-text), .select :global(.g-select-clear) { + .select :global(.g-select-control__option-text) { display: none; } - .clear-button { - display: block; - } - .label-dot { display: block; } diff --git a/lib/static/new-ui/components/AdaptiveSelect/index.tsx b/lib/static/new-ui/components/AdaptiveSelect/index.tsx index 42a998a5..08ab513a 100644 --- a/lib/static/new-ui/components/AdaptiveSelect/index.tsx +++ b/lib/static/new-ui/components/AdaptiveSelect/index.tsx @@ -1,49 +1,29 @@ -import {Xmark} from '@gravity-ui/icons'; -import {Icon, Select, SelectOption, SelectOptionGroup, Tooltip} from '@gravity-ui/uikit'; -import React, {ReactNode} from 'react'; +import {Select, SelectProps, Tooltip} from '@gravity-ui/uikit'; +import React, {ReactElement, ReactNode, useRef} from 'react'; import styles from './index.module.css'; interface AdaptiveSelectProps { - options: SelectOptionGroup[] | SelectOption[]; - currentValue: string; + currentValue: string[]; label: string; + // Determines whether select should show dot in its compact view + showDot?: boolean; labelIcon: ReactNode; autoClose?: boolean; - onClear?: () => void; - onOptionClick?: (value: string) => void; - currentOptionIcon?: ReactNode; + multiple?: boolean; + children: SelectProps['children']; } -/* This component implements a select that has 3 states: - - Full size, just like regular select - - Medium size, when label turns into icon - - Compact size, when only icon is displayed */ +/* This component implements a select that has 2 states: + - Full size: icon + selected value + - Compact size: only icon is displayed */ export function AdaptiveSelect(props: AdaptiveSelectProps): ReactNode { - const onUpdate = (ids: string[]): void => { - if (ids.length === 0) { - props.onClear?.(); - } - }; - - const renderOption = (option: SelectOption): React.JSX.Element => { - const onOptionClick = (e: React.MouseEvent): void => { - e.stopPropagation(); - props.onOptionClick?.(option.value); - }; + const selectRef = useRef(null); - const onClearButtonClick = (e: React.MouseEvent): void => { - e.stopPropagation(); - onUpdate([]); - }; - - return
- {option.content} - {props.currentValue === option.value &&
- {props.currentOptionIcon} -
-
} -
; + const onUpdate = (): void => { + if (props.autoClose) { + selectRef.current?.click(); + } }; return
@@ -57,24 +37,24 @@ export function AdaptiveSelect(props: AdaptiveSelectProps): ReactNode { {/* This wrapper is crucial for the tooltip to position correctly */}
; diff --git a/lib/static/new-ui/features/suites/components/GroupBySelect/index.tsx b/lib/static/new-ui/features/suites/components/GroupBySelect/index.tsx index ccfdad09..ef79fee5 100644 --- a/lib/static/new-ui/features/suites/components/GroupBySelect/index.tsx +++ b/lib/static/new-ui/features/suites/components/GroupBySelect/index.tsx @@ -1,6 +1,6 @@ import {Cubes3Overlap} from '@gravity-ui/icons'; -import {Icon, SelectOptionGroup} from '@gravity-ui/uikit'; -import React, {ReactNode} from 'react'; +import {Icon, Select} from '@gravity-ui/uikit'; +import React, {ReactNode, useState} from 'react'; import {useDispatch, useSelector} from 'react-redux'; import {GroupByType} from '@/static/new-ui/types/store'; @@ -10,44 +10,56 @@ import {AdaptiveSelect} from '@/static/new-ui/components/AdaptiveSelect'; export function GroupBySelect(): ReactNode { const dispatch = useDispatch(); + const NO_GROUPING = 'no-grouping'; + const groupByExpressionId = useSelector((state) => state.app.groupTestsData.currentExpressionIds)[0]; const groupBySections = useSelector((state) => state.app.groupTestsData.availableSections); const groupByExpressions = useSelector((state) => state.app.groupTestsData.availableExpressions); - const groupByOptions = groupBySections - .map((section): SelectOptionGroup => ({ - label: section.label, - options: groupByExpressions.filter(expr => expr.sectionId === section.id).map(expr => { - if (expr.type === GroupByType.Meta) { - return { - content: expr.key, - value: expr.id - }; - } - - return { - content: 'message', - value: expr.id - }; - }) - })); + + const [selectValue, setSelectValue] = useState(NO_GROUPING); const onOptionClick = (newGroupByExpressionId: string): void => { - if (newGroupByExpressionId !== groupByExpressionId) { + if (newGroupByExpressionId === NO_GROUPING) { + setSelectValue(NO_GROUPING); + dispatch(setCurrentGroupByExpression({expressionIds: []})); + } else if (newGroupByExpressionId !== groupByExpressionId) { + setSelectValue(newGroupByExpressionId); dispatch(setCurrentGroupByExpression({expressionIds: [newGroupByExpressionId]})); } }; - const onClear = (): void => { - dispatch(setCurrentGroupByExpression({expressionIds: []})); - }; + const groupByOptions = [ + +
onOptionClick(NO_GROUPING)}>Nothing
+
+
]; + + groupByOptions.push(...groupBySections.map((section): React.JSX.Element => + + {groupByExpressions + .filter(expr => expr.sectionId === section.id) + .map(expr => { + const title = expr.type === GroupByType.Meta ? expr.key : 'message'; + + return onOptionClick(expr.id)}>{title}
} + />; + })} + + )); return } - onClear={onClear} - onOptionClick={onOptionClick} - />; + showDot={selectValue !== NO_GROUPING} + > + {groupByOptions} + ; } diff --git a/lib/static/new-ui/features/suites/components/SortBySelect/index.module.css b/lib/static/new-ui/features/suites/components/SortBySelect/index.module.css index 126537d6..867579c7 100644 --- a/lib/static/new-ui/features/suites/components/SortBySelect/index.module.css +++ b/lib/static/new-ui/features/suites/components/SortBySelect/index.module.css @@ -1,3 +1,13 @@ .label-icon-right { margin-left: -6px; } + +.option-content { + display: flex; + align-items: center; + gap: 6px; +} + +.option-icon { + color: var(--g-color-private-black-450-solid); +} diff --git a/lib/static/new-ui/features/suites/components/SortBySelect/index.tsx b/lib/static/new-ui/features/suites/components/SortBySelect/index.tsx index f66c6e8f..920d9bfc 100644 --- a/lib/static/new-ui/features/suites/components/SortBySelect/index.tsx +++ b/lib/static/new-ui/features/suites/components/SortBySelect/index.tsx @@ -1,27 +1,37 @@ import { - ArrowDown, - ArrowUp, - BarsAscendingAlignLeftArrowDown, - BarsDescendingAlignLeftArrowDown + ArrowRotateLeft, + BarsAscendingAlignLeftArrowUp, + BarsDescendingAlignLeftArrowDown, + FontCase } from '@gravity-ui/icons'; -import {Icon, SelectOption} from '@gravity-ui/uikit'; +import {Icon, Select, SelectProps} from '@gravity-ui/uikit'; import React, {ReactNode} from 'react'; import {useDispatch, useSelector} from 'react-redux'; -import {SortDirection} from '@/static/new-ui/types/store'; +import {SortByExpression, SortDirection, SortType} from '@/static/new-ui/types/store'; import {setCurrentSortByExpression, setSortByDirection} from '@/static/modules/actions/sort-tests'; import {AdaptiveSelect} from '@/static/new-ui/components/AdaptiveSelect'; import styles from './index.module.css'; +const getSortIcon = (sortByExpression: SortByExpression): ReactNode => { + let iconData; + switch (sortByExpression.type) { + case SortType.ByName: + iconData = FontCase; + break; + case SortType.ByRetries: + iconData = ArrowRotateLeft; + break; + } + return ; +}; + export function SortBySelect(): ReactNode { const dispatch = useDispatch(); const sortByExpressionId = useSelector((state) => state.app.sortTestsData.currentExpressionIds)[0]; const currentDirection = useSelector((state) => state.app.sortTestsData.currentDirection); - const sortByExpressions = useSelector(state => state.app.sortTestsData.availableExpressions) - .map((expr): SelectOption => ({content: expr.label, value: expr.id})); - const onOptionClick = (newExpressionId: string): void => { if (sortByExpressionId !== newExpressionId) { dispatch(setCurrentSortByExpression({expressionIds: [newExpressionId]})); @@ -30,26 +40,42 @@ export function SortBySelect(): ReactNode { } }; - const onClear = (): void => { - dispatch(setCurrentSortByExpression({expressionIds: []})); - dispatch(setSortByDirection({direction: SortDirection.Asc})); + const onSetDirection = (direction: SortDirection): void => { + if (currentDirection !== direction) { + dispatch(setSortByDirection({direction})); + } }; + const options: SelectProps['children'] = [ + + {useSelector(state => state.app.sortTestsData.availableExpressions) + .map((expr, index): React.JSX.Element => ( + onOptionClick(expr.id)}> + {getSortIcon(expr)} + {expr.label} +
} value={expr.id}/>)) + } + , + + onSetDirection(SortDirection.Asc)}> + + Ascending + }/> + onSetDirection(SortDirection.Desc)}> + + Descending + }/> + + ]; + return - - - } - options={sortByExpressions} - currentValue={sortByExpressionId} - onClear={onClear} - onOptionClick={onOptionClick} + labelIcon={} + currentValue={[sortByExpressionId, currentDirection]} autoClose={false} - currentOptionIcon={<> - - } - />; + multiple={true} + > + {options} + ; } diff --git a/lib/static/new-ui/features/suites/components/SuitesTreeView/utils.ts b/lib/static/new-ui/features/suites/components/SuitesTreeView/utils.ts index 234235d5..7df3e533 100644 --- a/lib/static/new-ui/features/suites/components/SuitesTreeView/utils.ts +++ b/lib/static/new-ui/features/suites/components/SuitesTreeView/utils.ts @@ -53,7 +53,7 @@ export const formatEntityToTreeNodeData = ({browsers, browsersState, results, im const browserEntities = entity.browserIds.flatMap(browserId => browsersState[browserId].shouldBeShown ? [browsers[browserId]] : []); const testsCount = browserEntities.length; - const retriesCount = entity.resultIds.filter(resultId => browserEntities.find(browser => browser.resultIds.includes(resultId))).length; + const retriesCount = entity.resultIds.filter(resultId => browsersState[results[resultId].parentId].shouldBeShown).length; return { id: entity.id, diff --git a/lib/static/new-ui/features/suites/components/TreeActionsToolbar/index.module.css b/lib/static/new-ui/features/suites/components/TreeActionsToolbar/index.module.css index c3f4b944..7a56bb45 100644 --- a/lib/static/new-ui/features/suites/components/TreeActionsToolbar/index.module.css +++ b/lib/static/new-ui/features/suites/components/TreeActionsToolbar/index.module.css @@ -1,4 +1,5 @@ .container { + --g-color-text-info: #000; --divider-color: rgba(0, 0, 0, 0.1); --g-text-accent-font-weight: 500; diff --git a/lib/static/new-ui/types/store.ts b/lib/static/new-ui/types/store.ts index a3c8339b..acbecbc2 100644 --- a/lib/static/new-ui/types/store.ts +++ b/lib/static/new-ui/types/store.ts @@ -215,8 +215,8 @@ export enum SortType { } export enum SortDirection { - Asc, - Desc + Asc = 'asc', + Desc = 'desc' } export interface SortByExpression { From d914fa48e4a6d73dfd6d5b7cf61225d259394e11 Mon Sep 17 00:00:00 2001 From: shadowusr Date: Wed, 4 Dec 2024 19:13:58 +0300 Subject: [PATCH 5/7] fix: make sorting groups differently possible --- lib/constants/sort-tests.ts | 5 + lib/static/modules/reducers/sort-tests.ts | 29 +++- .../suites/components/SortBySelect/index.tsx | 8 +- .../components/SuitesTreeView/selectors.ts | 4 +- .../suites/components/SuitesTreeView/utils.ts | 131 +++++++++++++----- .../TreeViewItemTitle/index.module.css | 1 + .../components/TreeViewItemTitle/index.tsx | 6 +- lib/static/new-ui/types/store.ts | 3 +- 8 files changed, 141 insertions(+), 46 deletions(-) create mode 100644 lib/constants/sort-tests.ts diff --git a/lib/constants/sort-tests.ts b/lib/constants/sort-tests.ts new file mode 100644 index 00000000..633e15d9 --- /dev/null +++ b/lib/constants/sort-tests.ts @@ -0,0 +1,5 @@ +import {SortByExpression, SortType} from '@/static/new-ui/types/store'; + +export const SORT_BY_NAME: SortByExpression = {id: 'by-name', label: 'Name', type: SortType.ByName}; +export const SORT_BY_FAILED_RETRIES: SortByExpression = {id: 'by-retries', label: 'Failed retries', type: SortType.ByFailedRetries}; +export const SORT_BY_TESTS_COUNT: SortByExpression = {id: 'by-tests-count', label: 'Tests count', type: SortType.ByTestsCount}; diff --git a/lib/static/modules/reducers/sort-tests.ts b/lib/static/modules/reducers/sort-tests.ts index fc9db2f9..c66509b9 100644 --- a/lib/static/modules/reducers/sort-tests.ts +++ b/lib/static/modules/reducers/sort-tests.ts @@ -1,15 +1,16 @@ -import {SortByExpression, SortDirection, SortType, State} from '@/static/new-ui/types/store'; +import {SortByExpression, SortDirection, State} from '@/static/new-ui/types/store'; import {SomeAction} from '@/static/modules/actions/types'; import actionNames from '@/static/modules/action-names'; import {applyStateUpdate} from '@/static/modules/utils'; +import {SORT_BY_FAILED_RETRIES, SORT_BY_NAME, SORT_BY_TESTS_COUNT} from '@/constants/sort-tests'; export default (state: State, action: SomeAction): State => { switch (action.type) { case actionNames.INIT_STATIC_REPORT: case actionNames.INIT_GUI_REPORT: { const availableExpressions: SortByExpression[] = [ - {id: 'by-name', label: 'Name', type: SortType.ByName}, - {id: 'by-retries', label: 'Failed retries', type: SortType.ByRetries} + SORT_BY_NAME, + SORT_BY_FAILED_RETRIES ]; return applyStateUpdate(state, { @@ -40,6 +41,28 @@ export default (state: State, action: SomeAction): State => { } }); } + case actionNames.GROUP_TESTS_SET_CURRENT_EXPRESSION: { + let availableExpressions: SortByExpression[]; + if (action.payload.expressionIds.length > 0) { + availableExpressions = [ + SORT_BY_NAME, + SORT_BY_FAILED_RETRIES, + SORT_BY_TESTS_COUNT + ]; + } else { + availableExpressions = [ + SORT_BY_NAME, + SORT_BY_FAILED_RETRIES + ]; + } + return applyStateUpdate(state, { + app: { + sortTestsData: { + availableExpressions + } + } + }); + } default: return state; } diff --git a/lib/static/new-ui/features/suites/components/SortBySelect/index.tsx b/lib/static/new-ui/features/suites/components/SortBySelect/index.tsx index 920d9bfc..c01d2de4 100644 --- a/lib/static/new-ui/features/suites/components/SortBySelect/index.tsx +++ b/lib/static/new-ui/features/suites/components/SortBySelect/index.tsx @@ -2,7 +2,8 @@ import { ArrowRotateLeft, BarsAscendingAlignLeftArrowUp, BarsDescendingAlignLeftArrowDown, - FontCase + FontCase, + SquareLetterT } from '@gravity-ui/icons'; import {Icon, Select, SelectProps} from '@gravity-ui/uikit'; import React, {ReactNode} from 'react'; @@ -19,9 +20,12 @@ const getSortIcon = (sortByExpression: SortByExpression): ReactNode => { case SortType.ByName: iconData = FontCase; break; - case SortType.ByRetries: + case SortType.ByFailedRetries: iconData = ArrowRotateLeft; break; + case SortType.ByTestsCount: + iconData = SquareLetterT; + break; } return ; }; diff --git a/lib/static/new-ui/features/suites/components/SuitesTreeView/selectors.ts b/lib/static/new-ui/features/suites/components/SuitesTreeView/selectors.ts index be07b9a1..c2646044 100644 --- a/lib/static/new-ui/features/suites/components/SuitesTreeView/selectors.ts +++ b/lib/static/new-ui/features/suites/components/SuitesTreeView/selectors.ts @@ -24,7 +24,9 @@ export const getTreeViewItems = createSelector( [getGroups, getSuites, getAllRootGroupIds, getBrowsers, getBrowsersState, getResults, getImages, getTreeViewMode, getSortTestsData], (groups, suites, rootGroupIds, browsers, browsersState, results, images, treeViewMode, sortTestsData): TreeViewData => { const currentSortDirection = sortTestsData.currentDirection; - const currentSortExpression = sortTestsData.availableExpressions.find(expr => expr.id === sortTestsData.currentExpressionIds[0]); + const currentSortExpression = sortTestsData.availableExpressions + .find(expr => expr.id === sortTestsData.currentExpressionIds[0]) + ?? sortTestsData.availableExpressions[0]; const entitiesContext = {results, images, suites, treeViewMode, browsersState, browsers, groups, currentSortDirection, currentSortExpression}; diff --git a/lib/static/new-ui/features/suites/components/SuitesTreeView/utils.ts b/lib/static/new-ui/features/suites/components/SuitesTreeView/utils.ts index 7df3e533..110be1ce 100644 --- a/lib/static/new-ui/features/suites/components/SuitesTreeView/utils.ts +++ b/lib/static/new-ui/features/suites/components/SuitesTreeView/utils.ts @@ -1,16 +1,26 @@ +import {last} from 'lodash'; + +import {isErrorStatus, isFailStatus, trimArray} from '@/common-utils'; +import {NEW_ISSUE_LINK} from '@/constants'; import { - BrowserEntity, BrowserState, GroupEntity, ImageEntity, isBrowserEntity, isGroupEntity, + BrowserEntity, + BrowserState, + GroupEntity, + ImageEntity, + isBrowserEntity, + isGroupEntity, isResultEntityError, - isSuiteEntity, ResultEntity, SortByExpression, SortDirection, SortType, + isSuiteEntity, + ResultEntity, + SortByExpression, + SortDirection, + SortType, SuiteEntity, TreeViewMode } from '@/static/new-ui/types/store'; import {EntityType, TreeNode, TreeRoot} from '@/static/new-ui/features/suites/components/SuitesPage/types'; -import {getEntityType} from '@/static/new-ui/features/suites/utils'; -import {last} from 'lodash'; +import {getEntityType, getGroupId} from '@/static/new-ui/features/suites/utils'; import {isAcceptable} from '@/static/modules/utils'; -import {isErrorStatus, isFailStatus, trimArray} from '@/common-utils'; -import {NEW_ISSUE_LINK} from '@/constants'; export const getTitlePath = (suites: Record, entity: SuiteEntity | BrowserEntity | undefined): string[] => { if (!entity) { @@ -33,10 +43,10 @@ interface EntitiesContext { groups: Record; treeViewMode: TreeViewMode; currentSortDirection: SortDirection; - currentSortExpression: SortByExpression | undefined; + currentSortExpression: SortByExpression; } -export const formatEntityToTreeNodeData = ({browsers, browsersState, results, images, suites, treeViewMode}: EntitiesContext, entity: SuiteEntity | BrowserEntity | GroupEntity, id: string, parentData?: TreeNode['data']): TreeNode['data'] => { +export const formatEntityToTreeNodeData = ({results, images, suites, treeViewMode}: EntitiesContext, entity: SuiteEntity | BrowserEntity | GroupEntity, id: string, parentData?: TreeNode['data']): TreeNode['data'] => { if (isSuiteEntity(entity)) { return { id, @@ -50,11 +60,6 @@ export const formatEntityToTreeNodeData = ({browsers, browsersState, results, im } if (isGroupEntity(entity)) { - const browserEntities = entity.browserIds.flatMap(browserId => browsersState[browserId].shouldBeShown ? [browsers[browserId]] : []); - - const testsCount = browserEntities.length; - const retriesCount = entity.resultIds.filter(resultId => browsersState[results[resultId].parentId].shouldBeShown).length; - return { id: entity.id, entityType: EntityType.Group, @@ -62,10 +67,7 @@ export const formatEntityToTreeNodeData = ({browsers, browsersState, results, im prefix: `${entity.key}:`, title: [entity.label], status: null, - tags: [ - `${testsCount} ${testsCount > 1 ? ' tests' : 'test'}`, - `${retriesCount} ${retriesCount > 1 ? ' retries' : 'retry'}` - ] + tags: [] }; } @@ -188,12 +190,12 @@ export const collectTreeLeafIds = (treeNodes: (TreeNode | TreeRoot)[]): {allTree }; export const sortTreeNodes = (entitiesContext: EntitiesContext, treeNodes: TreeNode[]): TreeNode[] => { - const {groups, results, currentSortExpression, currentSortDirection, browsers} = entitiesContext; + const {groups, results, currentSortExpression, currentSortDirection, browsers, browsersState} = entitiesContext; // Weight of a single node is an array, because sorting may be performed by multiple fields at once // For example, sort by tests count, but if tests counts are equal, compare retries counts // In this case weight for each node is [testsCount, retriesCount] - type TreeNodeWeight = number[] | string[]; + type TreeNodeWeight = (number | string)[]; interface TreeWeightedSortResult { sortedTreeNodes: TreeNode[]; @@ -210,12 +212,37 @@ export const sortTreeNodes = (entitiesContext: EntitiesContext, treeNodes: TreeN case EntityType.Group: { const group = groups[treeNode.data.entityId]; - return [group.browserIds.length, group.resultIds.length]; + const browserEntities = group.browserIds.flatMap(browserId => browsersState[browserId].shouldBeShown ? [browsers[browserId]] : []); + + const testsCount = browserEntities.length; + const retriesCount = group.resultIds.filter(resultId => browsersState[results[resultId].parentId].shouldBeShown).length; + + if (currentSortExpression.type === SortType.ByTestsCount) { + return [0, testsCount, retriesCount]; + } else if (currentSortExpression.type === SortType.ByName) { + return [treeNode.data.title.join(' '), testsCount, retriesCount]; + } else if (currentSortExpression.type === SortType.ByFailedRetries) { + if (!childrenWeight) { + notifyOfUnsuccessfulWeightComputation(); + return [0, 0, 0]; + } + + // For now, we assume there are no nested groups and suite/test weights are always 1 dimensional + return [childrenWeight[0], testsCount, retriesCount]; + } + break; } case EntityType.Suite: { - if (!currentSortExpression || currentSortExpression.type === SortType.ByName) { + if (currentSortExpression.type === SortType.ByName) { return [treeNode.data.title.join(' ')]; - } else if (currentSortExpression.type === SortType.ByRetries) { + } else if (currentSortExpression.type === SortType.ByFailedRetries) { + if (!childrenWeight) { + notifyOfUnsuccessfulWeightComputation(); + return [0]; + } + + return childrenWeight; + } else if (currentSortExpression.type === SortType.ByTestsCount) { if (!childrenWeight) { notifyOfUnsuccessfulWeightComputation(); return [0]; @@ -226,11 +253,22 @@ export const sortTreeNodes = (entitiesContext: EntitiesContext, treeNodes: TreeN break; } case EntityType.Browser: { - if (!currentSortExpression || currentSortExpression.type === SortType.ByName) { + if (currentSortExpression.type === SortType.ByName) { return [treeNode.data.title.join(' ')]; - } else if (currentSortExpression.type === SortType.ByRetries) { + } else if (currentSortExpression.type === SortType.ByFailedRetries) { + const browser = browsers[treeNode.data.entityId]; + const groupId = getGroupId(treeNode.data); + + return [browser.resultIds.filter(resultId => + (isFailStatus(results[resultId].status) || isErrorStatus(results[resultId].status)) && + (!groupId || groups[groupId].resultIds.includes(resultId)) + ).length]; + } else if (currentSortExpression.type === SortType.ByTestsCount) { const browser = browsers[treeNode.data.entityId]; - return [browser.resultIds.filter(resultId => isFailStatus(results[resultId].status) || isErrorStatus(results[resultId].status)).length]; + const groupId = getGroupId(treeNode.data); + const retriesCount = groupId ? browser.resultIds.filter(resultId => groups[groupId].resultIds.includes(resultId)).length : browser.resultIds.length; + + return [1, retriesCount]; } break; } @@ -245,7 +283,7 @@ export const sortTreeNodes = (entitiesContext: EntitiesContext, treeNodes: TreeN return [0]; } - if (currentSortExpression.type === SortType.ByRetries) { + if (currentSortExpression.type === SortType.ByFailedRetries || currentSortExpression.type === SortType.ByTestsCount) { return weights.reduce((acc, weight) => { const newAcc = acc.slice(0); for (let i = 0; i < weight.length; i++) { @@ -268,32 +306,53 @@ export const sortTreeNodes = (entitiesContext: EntitiesContext, treeNodes: TreeN treeNodesCopy.forEach((treeNode, index) => { if (treeNode.data.entityType === EntityType.Group && treeNode.children?.length) { - treeNodesCopy[index] = Object.assign({}, treeNode, { - children: sortAndGetWeight(treeNode.children).sortedTreeNodes + const sortResult = sortAndGetWeight(treeNode.children); + + weights[treeNode.data.id] = extractWeight(treeNode, sortResult.weight); + + const newTreeNode = Object.assign({}, treeNode, { + children: sortResult.sortedTreeNodes }); - weights[treeNode.data.id] = extractWeight(treeNode); + const testsCount = weights[treeNode.data.id][1] as number; + const retriesCount = weights[treeNode.data.id][2] as number; + newTreeNode.data.tags.push(`${testsCount} ${(testsCount === 1 ? 'test' : ' tests')}`); + newTreeNode.data.tags.push(`${retriesCount} ${(retriesCount === 1 ? 'retry' : ' retries')}`); + + if (currentSortExpression.type === SortType.ByFailedRetries) { + const failedRetriesCount = weights[treeNode.data.id][0] as number; + newTreeNode.data.tags.push(`${failedRetriesCount} ${(failedRetriesCount === 1 ? 'failed retry' : ' failed retries')}`); + } + + treeNodesCopy[index] = newTreeNode; } else if (treeNode.data.entityType === EntityType.Suite && treeNode.children?.length) { const sortResult = sortAndGetWeight(treeNode.children); + + weights[treeNode.data.id] = extractWeight(treeNode, sortResult.weight); + const newTreeNode = Object.assign({}, treeNode, { children: sortResult.sortedTreeNodes }); const retriesCount = Number(sortResult.weight[0]); - if (currentSortExpression?.type === SortType.ByRetries && retriesCount > 0) { - newTreeNode.data.tags.push(`${retriesCount} ${retriesCount > 1 ? 'failed retries' : 'failed retry'}`); + if (currentSortExpression?.type === SortType.ByFailedRetries && retriesCount > 0) { + newTreeNode.data.tags.push(`${retriesCount} ${(retriesCount === 1 ? 'failed retry' : 'failed retries')}`); + } else if (currentSortExpression.type === SortType.ByTestsCount) { + const testsCount = weights[treeNode.data.id][0] as number; + const retriesCount = weights[treeNode.data.id][1] as number; + newTreeNode.data.tags.push(`${testsCount} ${(testsCount === 1 ? 'test' : ' tests')}`); + newTreeNode.data.tags.push(`${retriesCount} ${(retriesCount === 1 ? 'retry' : ' retries')}`); } - treeNodesCopy[index] = newTreeNode; - weights[treeNode.data.id] = extractWeight(treeNode, sortResult.weight); + treeNodesCopy[index] = newTreeNode; } else if (treeNode.data.entityType === EntityType.Browser) { const newTreeNode = Object.assign({}, treeNode); weights[treeNode.data.id] = extractWeight(treeNode); const retriesCount = weights[treeNode.data.id][0] as number; - if (currentSortExpression?.type === SortType.ByRetries && retriesCount > 0) { - newTreeNode.data.tags.push(`${retriesCount} ${retriesCount > 1 ? 'failed retries' : 'failed retry'}`); + if (currentSortExpression?.type === SortType.ByFailedRetries && retriesCount > 0) { + newTreeNode.data.tags.push(`${retriesCount} ${(retriesCount === 1 ? 'failed retry' : 'failed retries')}`); } treeNodesCopy[index] = newTreeNode; @@ -301,7 +360,7 @@ export const sortTreeNodes = (entitiesContext: EntitiesContext, treeNodes: TreeN }); const sortedTreeNodes = treeNodesCopy.sort((a, b): number => { - const direction = currentSortDirection === SortDirection.Desc || a.data.entityType === EntityType.Group ? -1 : 1; + const direction = currentSortDirection === SortDirection.Desc ? -1 : 1; for (let i = 0; i < weights[a.data.id].length; i++) { const aWeight = weights[a.data.id][i]; diff --git a/lib/static/new-ui/features/suites/components/TreeViewItemTitle/index.module.css b/lib/static/new-ui/features/suites/components/TreeViewItemTitle/index.module.css index 10cee25a..10d5c247 100644 --- a/lib/static/new-ui/features/suites/components/TreeViewItemTitle/index.module.css +++ b/lib/static/new-ui/features/suites/components/TreeViewItemTitle/index.module.css @@ -40,6 +40,7 @@ .tags-container { display: inline-block; + height: 20px; } .tag { diff --git a/lib/static/new-ui/features/suites/components/TreeViewItemTitle/index.tsx b/lib/static/new-ui/features/suites/components/TreeViewItemTitle/index.tsx index 7d920cd8..c7908efe 100644 --- a/lib/static/new-ui/features/suites/components/TreeViewItemTitle/index.tsx +++ b/lib/static/new-ui/features/suites/components/TreeViewItemTitle/index.tsx @@ -63,10 +63,10 @@ export function TreeViewItemTitle({item}: TreeViewItemTitleProps): React.JSX.Ele
{item.prefix && {item.prefix}} - {headTitleParts.map((titlePart, index) => <> - {titlePart} + {headTitleParts.map((titlePart, index) => + {titlePart} - )} + )} 0 ? styles.titlePart : ''}>{tailTitlePart}
diff --git a/lib/static/new-ui/types/store.ts b/lib/static/new-ui/types/store.ts index acbecbc2..de765643 100644 --- a/lib/static/new-ui/types/store.ts +++ b/lib/static/new-ui/types/store.ts @@ -211,7 +211,8 @@ export type GroupByExpression = GroupByMetaExpression | GroupByErrorExpression; export enum SortType { ByName, - ByRetries + ByFailedRetries, + ByTestsCount } export enum SortDirection { From cdea2022046b6033d22436d4da898d604b9f9384 Mon Sep 17 00:00:00 2001 From: shadowusr Date: Wed, 4 Dec 2024 19:18:27 +0300 Subject: [PATCH 6/7] fix: fix imports --- lib/{ => static}/constants/sort-tests.ts | 0 lib/static/modules/reducers/sort-tests.ts | 2 +- lib/static/new-ui/types/store.ts | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename lib/{ => static}/constants/sort-tests.ts (100%) diff --git a/lib/constants/sort-tests.ts b/lib/static/constants/sort-tests.ts similarity index 100% rename from lib/constants/sort-tests.ts rename to lib/static/constants/sort-tests.ts diff --git a/lib/static/modules/reducers/sort-tests.ts b/lib/static/modules/reducers/sort-tests.ts index c66509b9..b0c19b56 100644 --- a/lib/static/modules/reducers/sort-tests.ts +++ b/lib/static/modules/reducers/sort-tests.ts @@ -2,7 +2,7 @@ import {SortByExpression, SortDirection, State} from '@/static/new-ui/types/stor import {SomeAction} from '@/static/modules/actions/types'; import actionNames from '@/static/modules/action-names'; import {applyStateUpdate} from '@/static/modules/utils'; -import {SORT_BY_FAILED_RETRIES, SORT_BY_NAME, SORT_BY_TESTS_COUNT} from '@/constants/sort-tests'; +import {SORT_BY_FAILED_RETRIES, SORT_BY_NAME, SORT_BY_TESTS_COUNT} from '@/static/constants/sort-tests'; export default (state: State, action: SomeAction): State => { switch (action.type) { diff --git a/lib/static/new-ui/types/store.ts b/lib/static/new-ui/types/store.ts index de765643..4a34ee7c 100644 --- a/lib/static/new-ui/types/store.ts +++ b/lib/static/new-ui/types/store.ts @@ -1,3 +1,4 @@ +import {CoordBounds} from 'looks-same'; import {DiffModeId, Feature, TestStatus, ViewMode} from '@/constants'; import { BrowserItem, @@ -8,7 +9,6 @@ import { TestStepCompressed } from '@/types'; import {HtmlReporterValues} from '@/plugin-api'; -import {CoordBounds} from 'looks-same'; import {Point} from '@/static/new-ui/types/index'; import {AcceptableImage} from '@/static/modules/static-image-accepter'; import {CheckStatus} from '@/constants/checked-statuses'; From 117a268423179c562074b71bbdbc9f58097498b0 Mon Sep 17 00:00:00 2001 From: shadowusr Date: Mon, 9 Dec 2024 00:47:59 +0300 Subject: [PATCH 7/7] refactor: introduce tree node weight metadata, move tags logic to a helper --- lib/static/constants/sort-tests.ts | 2 +- lib/static/modules/reducers/sort-tests.ts | 2 + lib/static/modules/reducers/suites-page.ts | 3 +- .../suites/components/SortBySelect/index.tsx | 2 +- .../components/SuitesTreeView/index.tsx | 9 +- .../suites/components/SuitesTreeView/utils.ts | 158 +++++++++++------- .../TreeViewItemTitle/index.module.css | 4 +- lib/static/new-ui/types/store.ts | 2 +- 8 files changed, 109 insertions(+), 73 deletions(-) diff --git a/lib/static/constants/sort-tests.ts b/lib/static/constants/sort-tests.ts index 633e15d9..ec5e7c2a 100644 --- a/lib/static/constants/sort-tests.ts +++ b/lib/static/constants/sort-tests.ts @@ -1,5 +1,5 @@ import {SortByExpression, SortType} from '@/static/new-ui/types/store'; export const SORT_BY_NAME: SortByExpression = {id: 'by-name', label: 'Name', type: SortType.ByName}; -export const SORT_BY_FAILED_RETRIES: SortByExpression = {id: 'by-retries', label: 'Failed retries', type: SortType.ByFailedRetries}; +export const SORT_BY_FAILED_RETRIES: SortByExpression = {id: 'by-failed-runs', label: 'Failed runs count', type: SortType.ByFailedRuns}; export const SORT_BY_TESTS_COUNT: SortByExpression = {id: 'by-tests-count', label: 'Tests count', type: SortType.ByTestsCount}; diff --git a/lib/static/modules/reducers/sort-tests.ts b/lib/static/modules/reducers/sort-tests.ts index b0c19b56..1e772233 100644 --- a/lib/static/modules/reducers/sort-tests.ts +++ b/lib/static/modules/reducers/sort-tests.ts @@ -43,6 +43,7 @@ export default (state: State, action: SomeAction): State => { } case actionNames.GROUP_TESTS_SET_CURRENT_EXPRESSION: { let availableExpressions: SortByExpression[]; + if (action.payload.expressionIds.length > 0) { availableExpressions = [ SORT_BY_NAME, @@ -55,6 +56,7 @@ export default (state: State, action: SomeAction): State => { SORT_BY_FAILED_RETRIES ]; } + return applyStateUpdate(state, { app: { sortTestsData: { diff --git a/lib/static/modules/reducers/suites-page.ts b/lib/static/modules/reducers/suites-page.ts index 009eb8f7..d7067663 100644 --- a/lib/static/modules/reducers/suites-page.ts +++ b/lib/static/modules/reducers/suites-page.ts @@ -10,10 +10,11 @@ export default (state: State, action: SomeAction): State => { case actionNames.INIT_STATIC_REPORT: case actionNames.INIT_GUI_REPORT: case actionNames.SUITES_PAGE_SET_TREE_VIEW_MODE: + case actionNames.CHANGE_VIEW_MODE as any: // eslint-disable-line @typescript-eslint/no-explicit-any case actionNames.GROUP_TESTS_SET_CURRENT_EXPRESSION: { const {allTreeNodeIds} = getTreeViewItems(state); - const expandedTreeNodesById: Record = {}; + const expandedTreeNodesById: Record = Object.assign({}, state.ui.suitesPage.expandedTreeNodesById); for (const nodeId of allTreeNodeIds) { expandedTreeNodesById[nodeId] = true; diff --git a/lib/static/new-ui/features/suites/components/SortBySelect/index.tsx b/lib/static/new-ui/features/suites/components/SortBySelect/index.tsx index c01d2de4..6f9382fa 100644 --- a/lib/static/new-ui/features/suites/components/SortBySelect/index.tsx +++ b/lib/static/new-ui/features/suites/components/SortBySelect/index.tsx @@ -20,7 +20,7 @@ const getSortIcon = (sortByExpression: SortByExpression): ReactNode => { case SortType.ByName: iconData = FontCase; break; - case SortType.ByFailedRetries: + case SortType.ByFailedRuns: iconData = ArrowRotateLeft; break; case SortType.ByTestsCount: diff --git a/lib/static/new-ui/features/suites/components/SuitesTreeView/index.tsx b/lib/static/new-ui/features/suites/components/SuitesTreeView/index.tsx index cd036e8e..6cfb29f1 100644 --- a/lib/static/new-ui/features/suites/components/SuitesTreeView/index.tsx +++ b/lib/static/new-ui/features/suites/components/SuitesTreeView/index.tsx @@ -68,7 +68,10 @@ export const SuitesTreeView = forwardRef 73px in total // Regular items on average take 1 line -> 32px // Providing more precise estimates here greatly improves scrolling performance - return item.entityType === EntityType.Group ? 73 : 32; + const GROUP_ROW_HEIGHT = 73; + const REGULAR_ROW_HEIGHT = 32; + + return item.entityType === EntityType.Group ? GROUP_ROW_HEIGHT : REGULAR_ROW_HEIGHT; }, getItemKey: useCallback((index: number) => list.structure.visibleFlattenIds[index], [list]), overscan: 50 @@ -153,8 +156,8 @@ export const SuitesTreeView = forwardRef; + } interface TreeWeightedSortResult { sortedTreeNodes: TreeNode[]; weight: TreeNodeWeight; } + const createWeight = (value: TreeNodeWeightValue, metadata?: Partial): TreeNodeWeight => ({ + value, + metadata: metadata ?? {} + }); + const extractWeight = (treeNode: TreeNode, childrenWeight?: TreeNodeWeight): TreeNodeWeight => { const notifyOfUnsuccessfulWeightComputation = (): void => { console.warn('Failed to determine suite weight for tree node listed below. Please let us now at ' + NEW_ISSUE_LINK); @@ -215,37 +230,37 @@ export const sortTreeNodes = (entitiesContext: EntitiesContext, treeNodes: TreeN const browserEntities = group.browserIds.flatMap(browserId => browsersState[browserId].shouldBeShown ? [browsers[browserId]] : []); const testsCount = browserEntities.length; - const retriesCount = group.resultIds.filter(resultId => browsersState[results[resultId].parentId].shouldBeShown).length; + const runsCount = group.resultIds.filter(resultId => browsersState[results[resultId].parentId].shouldBeShown).length; if (currentSortExpression.type === SortType.ByTestsCount) { - return [0, testsCount, retriesCount]; + return createWeight([0, testsCount, runsCount], {testsCount, runsCount}); } else if (currentSortExpression.type === SortType.ByName) { - return [treeNode.data.title.join(' '), testsCount, retriesCount]; - } else if (currentSortExpression.type === SortType.ByFailedRetries) { + return createWeight([treeNode.data.title.join(' '), testsCount, runsCount], {testsCount, runsCount}); + } else if (currentSortExpression.type === SortType.ByFailedRuns) { if (!childrenWeight) { notifyOfUnsuccessfulWeightComputation(); - return [0, 0, 0]; + return createWeight([0, 0, 0]); } // For now, we assume there are no nested groups and suite/test weights are always 1 dimensional - return [childrenWeight[0], testsCount, retriesCount]; + return createWeight([childrenWeight.value[0], testsCount, runsCount], Object.assign({}, {testsCount, runsCount}, childrenWeight.metadata)); } break; } case EntityType.Suite: { if (currentSortExpression.type === SortType.ByName) { - return [treeNode.data.title.join(' ')]; - } else if (currentSortExpression.type === SortType.ByFailedRetries) { + return createWeight([treeNode.data.title.join(' ')]); + } else if (currentSortExpression.type === SortType.ByFailedRuns) { if (!childrenWeight) { notifyOfUnsuccessfulWeightComputation(); - return [0]; + return createWeight([0]); } return childrenWeight; } else if (currentSortExpression.type === SortType.ByTestsCount) { if (!childrenWeight) { notifyOfUnsuccessfulWeightComputation(); - return [0]; + return createWeight([0]); } return childrenWeight; @@ -254,46 +269,80 @@ export const sortTreeNodes = (entitiesContext: EntitiesContext, treeNodes: TreeN } case EntityType.Browser: { if (currentSortExpression.type === SortType.ByName) { - return [treeNode.data.title.join(' ')]; - } else if (currentSortExpression.type === SortType.ByFailedRetries) { + return createWeight([treeNode.data.title.join(' ')]); + } else if (currentSortExpression.type === SortType.ByFailedRuns) { const browser = browsers[treeNode.data.entityId]; const groupId = getGroupId(treeNode.data); - return [browser.resultIds.filter(resultId => + const failedRunsCount = browser.resultIds.filter(resultId => (isFailStatus(results[resultId].status) || isErrorStatus(results[resultId].status)) && (!groupId || groups[groupId].resultIds.includes(resultId)) - ).length]; + ).length; + + return createWeight([failedRunsCount], {failedRunsCount}); } else if (currentSortExpression.type === SortType.ByTestsCount) { const browser = browsers[treeNode.data.entityId]; const groupId = getGroupId(treeNode.data); - const retriesCount = groupId ? browser.resultIds.filter(resultId => groups[groupId].resultIds.includes(resultId)).length : browser.resultIds.length; + const runsCount = groupId ? browser.resultIds.filter(resultId => groups[groupId].resultIds.includes(resultId)).length : browser.resultIds.length; - return [1, retriesCount]; + return createWeight([1, runsCount], {runsCount}); } break; } } notifyOfUnsuccessfulWeightComputation(); - return [0]; + return createWeight([0]); }; const aggregateWeights = (weights: TreeNodeWeight[]): TreeNodeWeight => { if (!currentSortExpression || currentSortExpression.type === SortType.ByName) { - return [0]; + return createWeight([0]); } - if (currentSortExpression.type === SortType.ByFailedRetries || currentSortExpression.type === SortType.ByTestsCount) { - return weights.reduce((acc, weight) => { - const newAcc = acc.slice(0); - for (let i = 0; i < weight.length; i++) { - newAcc[i] = (acc[i] ?? 0) + weight[i]; + if (currentSortExpression.type === SortType.ByFailedRuns || currentSortExpression.type === SortType.ByTestsCount) { + return weights.reduce((accWeight, weight) => { + const newAccWeight = createWeight(accWeight.value.slice(0), accWeight.metadata); + for (let i = 0; i < weight.value.length; i++) { + newAccWeight.value[i] = Number(accWeight.value[i] ?? 0) + Number(weight.value[i]); + } + + if (weight.metadata.testsCount !== undefined) { + newAccWeight.metadata.testsCount = (newAccWeight.metadata.testsCount ?? 0) + weight.metadata.testsCount; } - return newAcc; - }, new Array(weights[0]?.length)); + if (weight.metadata.runsCount !== undefined) { + newAccWeight.metadata.runsCount = (newAccWeight.metadata.runsCount ?? 0) + weight.metadata.runsCount; + } + if (weight.metadata.failedRunsCount !== undefined) { + newAccWeight.metadata.failedRunsCount = (newAccWeight.metadata.failedRunsCount ?? 0) + weight.metadata.failedRunsCount; + } + + return newAccWeight; + }, createWeight(new Array(weights[0]?.value?.length))); + } + + return createWeight([0]); + }; + + const generateTagsForWeight = (weight: TreeNodeWeight): string[] => { + const tags: string[] = []; + + const testsCount = weight.metadata.testsCount; + if (testsCount !== undefined) { + tags.push(`${testsCount} ${(testsCount === 1 ? 'test' : 'tests')}`); + } + + const runsCount = weight.metadata.runsCount; + if (runsCount !== undefined) { + tags.push(`${runsCount} ${(runsCount === 1 ? 'run' : 'runs')}`); + } + + const failedRunsCount = weight.metadata.failedRunsCount; + if (failedRunsCount !== undefined) { + tags.push(`${failedRunsCount} ${(failedRunsCount === 1 ? 'failed run' : 'failed runs')}`); } - return [0]; + return tags; }; // Recursive tree sort. At each level of the tree, it does the following: @@ -308,53 +357,34 @@ export const sortTreeNodes = (entitiesContext: EntitiesContext, treeNodes: TreeN if (treeNode.data.entityType === EntityType.Group && treeNode.children?.length) { const sortResult = sortAndGetWeight(treeNode.children); - weights[treeNode.data.id] = extractWeight(treeNode, sortResult.weight); + const weight = extractWeight(treeNode, sortResult.weight); const newTreeNode = Object.assign({}, treeNode, { children: sortResult.sortedTreeNodes }); + newTreeNode.data.tags.push(...generateTagsForWeight(weight)); - const testsCount = weights[treeNode.data.id][1] as number; - const retriesCount = weights[treeNode.data.id][2] as number; - newTreeNode.data.tags.push(`${testsCount} ${(testsCount === 1 ? 'test' : ' tests')}`); - newTreeNode.data.tags.push(`${retriesCount} ${(retriesCount === 1 ? 'retry' : ' retries')}`); - - if (currentSortExpression.type === SortType.ByFailedRetries) { - const failedRetriesCount = weights[treeNode.data.id][0] as number; - newTreeNode.data.tags.push(`${failedRetriesCount} ${(failedRetriesCount === 1 ? 'failed retry' : ' failed retries')}`); - } - + weights[treeNode.data.id] = weight; treeNodesCopy[index] = newTreeNode; } else if (treeNode.data.entityType === EntityType.Suite && treeNode.children?.length) { const sortResult = sortAndGetWeight(treeNode.children); - weights[treeNode.data.id] = extractWeight(treeNode, sortResult.weight); + const weight = extractWeight(treeNode, sortResult.weight); const newTreeNode = Object.assign({}, treeNode, { children: sortResult.sortedTreeNodes }); + newTreeNode.data.tags.push(...generateTagsForWeight(weight)); - const retriesCount = Number(sortResult.weight[0]); - if (currentSortExpression?.type === SortType.ByFailedRetries && retriesCount > 0) { - newTreeNode.data.tags.push(`${retriesCount} ${(retriesCount === 1 ? 'failed retry' : 'failed retries')}`); - } else if (currentSortExpression.type === SortType.ByTestsCount) { - const testsCount = weights[treeNode.data.id][0] as number; - const retriesCount = weights[treeNode.data.id][1] as number; - newTreeNode.data.tags.push(`${testsCount} ${(testsCount === 1 ? 'test' : ' tests')}`); - newTreeNode.data.tags.push(`${retriesCount} ${(retriesCount === 1 ? 'retry' : ' retries')}`); - } - + weights[treeNode.data.id] = weight; treeNodesCopy[index] = newTreeNode; } else if (treeNode.data.entityType === EntityType.Browser) { - const newTreeNode = Object.assign({}, treeNode); + const weight = extractWeight(treeNode); - weights[treeNode.data.id] = extractWeight(treeNode); - - const retriesCount = weights[treeNode.data.id][0] as number; - if (currentSortExpression?.type === SortType.ByFailedRetries && retriesCount > 0) { - newTreeNode.data.tags.push(`${retriesCount} ${(retriesCount === 1 ? 'failed retry' : 'failed retries')}`); - } + const newTreeNode = Object.assign({}, treeNode); + newTreeNode.data.tags.push(...generateTagsForWeight(weight)); + weights[treeNode.data.id] = weight; treeNodesCopy[index] = newTreeNode; } }); @@ -362,9 +392,9 @@ export const sortTreeNodes = (entitiesContext: EntitiesContext, treeNodes: TreeN const sortedTreeNodes = treeNodesCopy.sort((a, b): number => { const direction = currentSortDirection === SortDirection.Desc ? -1 : 1; - for (let i = 0; i < weights[a.data.id].length; i++) { - const aWeight = weights[a.data.id][i]; - const bWeight = weights[b.data.id][i]; + for (let i = 0; i < weights[a.data.id].value.length; i++) { + const aWeight = weights[a.data.id].value[i]; + const bWeight = weights[b.data.id].value[i]; if (aWeight === bWeight) { continue; } diff --git a/lib/static/new-ui/features/suites/components/TreeViewItemTitle/index.module.css b/lib/static/new-ui/features/suites/components/TreeViewItemTitle/index.module.css index 10d5c247..7b456274 100644 --- a/lib/static/new-ui/features/suites/components/TreeViewItemTitle/index.module.css +++ b/lib/static/new-ui/features/suites/components/TreeViewItemTitle/index.module.css @@ -53,12 +53,12 @@ padding: 0 4px; } -:global(.error) .tag { +:global(.error-tree-node) .tag { background: var(--g-color-private-red-50); color: var(--g-color-private-red-500-solid); } -:global(.current) .tag { +:global(.current-tree-node) .tag { background-color: rgba(255, 255, 255, .15); color: rgba(255, 255, 255, .7); } diff --git a/lib/static/new-ui/types/store.ts b/lib/static/new-ui/types/store.ts index 4a34ee7c..80746980 100644 --- a/lib/static/new-ui/types/store.ts +++ b/lib/static/new-ui/types/store.ts @@ -211,7 +211,7 @@ export type GroupByExpression = GroupByMetaExpression | GroupByErrorExpression; export enum SortType { ByName, - ByFailedRetries, + ByFailedRuns, ByTestsCount }