diff --git a/lib/static/constants/sort-tests.ts b/lib/static/constants/sort-tests.ts new file mode 100644 index 00000000..ec5e7c2a --- /dev/null +++ b/lib/static/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-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/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 { 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 7e571d9e..a62d982a 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(); @@ -48,7 +49,8 @@ const extractErrors = (result: ResultEntity, images: ImageEntity[]): string[] => 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,28 +61,35 @@ 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; @@ -101,25 +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: [] + 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 new file mode 100644 index 00000000..1e772233 --- /dev/null +++ b/lib/static/modules/reducers/sort-tests.ts @@ -0,0 +1,71 @@ +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 '@/static/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[] = [ + SORT_BY_NAME, + SORT_BY_FAILED_RETRIES + ]; + + return applyStateUpdate(state, { + app: { + sortTestsData: { + availableExpressions, + currentDirection: SortDirection.Asc, + currentExpressionIds: [availableExpressions[0].id] + } + } + }); + } + 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 + } + } + }); + } + 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/modules/reducers/suites-page.ts b/lib/static/modules/reducers/suites-page.ts index d03394ae..d7067663 100644 --- a/lib/static/modules/reducers/suites-page.ts +++ b/lib/static/modules/reducers/suites-page.ts @@ -9,10 +9,12 @@ 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.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; @@ -20,7 +22,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 +35,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 +48,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..be45856f --- /dev/null +++ b/lib/static/new-ui/components/AdaptiveSelect/index.module.css @@ -0,0 +1,39 @@ +.select-popup { + --g-color-text-info: #000; + + font-size: var(--g-text-body-1-font-size); +} + +.select :global(.g-select-control__label) { + margin-inline-end: 0; +} + +.selected-option { + margin-inline-start: 4px; +} + +.label-icons-container { + position: relative; + padding-right: 2px; +} + +.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); +} + +@container (max-width: 450px) { + .select :global(.g-select-control__option-text) { + display: none; + } + + .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..08ab513a --- /dev/null +++ b/lib/static/new-ui/components/AdaptiveSelect/index.tsx @@ -0,0 +1,61 @@ +import {Select, SelectProps, Tooltip} from '@gravity-ui/uikit'; +import React, {ReactElement, ReactNode, useRef} from 'react'; + +import styles from './index.module.css'; + +interface AdaptiveSelectProps { + currentValue: string[]; + label: string; + // Determines whether select should show dot in its compact view + showDot?: boolean; + labelIcon: ReactNode; + autoClose?: boolean; + multiple?: boolean; + children: SelectProps['children']; +} + +/* 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 selectRef = useRef(null); + + const onUpdate = (): void => { + if (props.autoClose) { + selectRef.current?.click(); + } + }; + + return
+ + {/* This wrapper is crucial for the tooltip to position correctly */} +
+ +
+
+
; +} diff --git a/lib/static/new-ui/components/AttemptPickerItem/index.tsx b/lib/static/new-ui/components/AttemptPickerItem/index.tsx index 77075e3d..93096010 100644 --- a/lib/static/new-ui/components/AttemptPickerItem/index.tsx +++ b/lib/static/new-ui/components/AttemptPickerItem/index.tsx @@ -5,7 +5,7 @@ import {connect} from 'react-redux'; import {hasUnrelatedToScreenshotsErrors, isFailStatus} from '@/common-utils'; import {TestStatus} from '@/constants'; -import {ResultEntityError, State} from '@/static/new-ui/types/store'; +import {GroupEntity, ResultEntityError, State} from '@/static/new-ui/types/store'; import styles from './index.module.css'; import {get} from 'lodash'; @@ -86,7 +86,7 @@ function AttemptPickerItemInternal(props: AttemptPickerItemInternalProps): React export const AttemptPickerItem = connect( ({tree, view: {keyToGroupTestsBy}, app: {isNewUi, suitesPage: {currentGroupId}}}: State, {resultId}: AttemptPickerItemProps) => { const result = tree.results.byId[resultId]; - const group = Object.values(tree.groups.byId).find(group => group.id === currentGroupId); + const group = tree.groups.byId[currentGroupId ?? ''] as GroupEntity | undefined; const matchedSelectedGroup = isNewUi ? Boolean(group?.resultIds.includes(resultId)) : get(tree.results.stateById[resultId], 'matchedSelectedGroup', false); 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 a7f978de..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,40 +1,65 @@ +import {Cubes3Overlap} from '@gravity-ui/icons'; +import {Icon, Select} from '@gravity-ui/uikit'; +import React, {ReactNode, useState} from 'react'; import {useDispatch, useSelector} from 'react-redux'; -import {Select, SelectOptionGroup, SelectProps} from '@gravity-ui/uikit'; + import {GroupByType} from '@/static/new-ui/types/store'; import {setCurrentGroupByExpression} from '@/static/modules/actions'; -import React, {ReactNode} from 'react'; +import {AdaptiveSelect} from '@/static/new-ui/components/AdaptiveSelect'; -export function GroupBySelect(props: SelectProps): ReactNode { +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 onGroupByUpdate = (value: string[]): void => { - const newGroupByExpressionId = value[0]; - if (newGroupByExpressionId !== groupByExpressionId) { - dispatch(setCurrentGroupByExpression({expressionIds: newGroupByExpressionId ? [newGroupByExpressionId] : []})); + + const [selectValue, setSelectValue] = useState(NO_GROUPING); + + const onOptionClick = (newGroupByExpressionId: string): void => { + if (newGroupByExpressionId === NO_GROUPING) { + setSelectValue(NO_GROUPING); + dispatch(setCurrentGroupByExpression({expressionIds: []})); + } else if (newGroupByExpressionId !== groupByExpressionId) { + setSelectValue(newGroupByExpressionId); + dispatch(setCurrentGroupByExpression({expressionIds: [newGroupByExpressionId]})); } }; - return