From 8691f2847c055482fdf1f489b09ca3a48beb13b3 Mon Sep 17 00:00:00 2001 From: shadowusr Date: Fri, 15 Nov 2024 02:21:51 +0300 Subject: [PATCH] feat: implement tests grouping --- lib/db-utils/common.ts | 12 +- lib/gui/server.ts | 14 +- lib/static/components/gui.jsx | 2 +- lib/static/modules/action-names.ts | 3 +- lib/static/modules/actions/group-tests.ts | 11 + lib/static/modules/actions/index.js | 96 +----- lib/static/modules/actions/lifecycle.ts | 134 ++++++++ lib/static/modules/actions/suites-page.ts | 18 +- lib/static/modules/actions/types.ts | 12 + lib/static/modules/default-state.ts | 12 +- .../modules/reducers/grouped-tests/index.js | 73 ---- .../modules/reducers/grouped-tests/index.ts | 138 ++++++++ .../modules/reducers/grouped-tests/types.ts | 0 .../modules/reducers/grouped-tests/utils.ts | 74 ++++ lib/static/modules/reducers/suites-page.ts | 45 ++- lib/static/modules/reducers/tree/index.js | 5 + lib/static/modules/utils/index.js | 29 +- .../modules/utils/{state.js => state.ts} | 39 +-- lib/static/new-ui/app/gui.tsx | 4 +- lib/static/new-ui/app/report.tsx | 4 +- .../components/AttemptPickerItem/index.tsx | 10 +- .../suites/components/SuitesPage/index.tsx | 4 +- .../suites/components/SuitesPage/types.ts | 60 +++- .../components/SuitesTreeView/index.tsx | 82 +++-- .../components/SuitesTreeView/selectors.ts | 316 +++++++++++++----- .../suites/components/SuitesTreeView/utils.ts | 10 + .../components/TreeActionsToolbar/index.tsx | 50 ++- .../components/TreeViewItemSubtitle/index.tsx | 8 +- .../components/TreeViewItemTitle/index.tsx | 18 +- .../components/TreeViewItemTitle/selectors.ts | 22 ++ lib/static/new-ui/store/selectors.ts | 4 +- lib/static/new-ui/types/index.ts | 5 - lib/static/new-ui/types/store.ts | 63 +++- lib/tests-tree-builder/base.ts | 2 +- lib/tests-tree-builder/static.ts | 11 +- test/unit/lib/static/modules/actions.js | 14 +- tsconfig.common.json | 4 +- 37 files changed, 1001 insertions(+), 407 deletions(-) create mode 100644 lib/static/modules/actions/group-tests.ts create mode 100644 lib/static/modules/actions/lifecycle.ts delete mode 100644 lib/static/modules/reducers/grouped-tests/index.js create mode 100644 lib/static/modules/reducers/grouped-tests/index.ts create mode 100644 lib/static/modules/reducers/grouped-tests/types.ts create mode 100644 lib/static/modules/reducers/grouped-tests/utils.ts rename lib/static/modules/utils/{state.js => state.ts} (52%) create mode 100644 lib/static/new-ui/features/suites/components/SuitesTreeView/utils.ts create mode 100644 lib/static/new-ui/features/suites/components/TreeViewItemTitle/selectors.ts diff --git a/lib/db-utils/common.ts b/lib/db-utils/common.ts index 599d4a0e9..d89ff6eeb 100644 --- a/lib/db-utils/common.ts +++ b/lib/db-utils/common.ts @@ -2,7 +2,7 @@ import _ from 'lodash'; import {logger} from '../common-utils'; import {DB_MAX_AVAILABLE_PAGE_SIZE, DB_SUITES_TABLE_NAME, SUITES_TABLE_COLUMNS, DB_COLUMN_INDEXES} from '../constants'; import {DbUrlsJsonData, RawSuitesRow, ReporterConfig} from '../types'; -import type {Database, Statement} from 'better-sqlite3'; +import type {Database as BetterSqlite3Database, Statement} from 'better-sqlite3'; import {ReadonlyDeep} from 'type-fest'; export const selectAllQuery = (tableName: string): string => `SELECT * FROM ${tableName}`; @@ -16,10 +16,18 @@ export const compareDatabaseRowsByTimestamp = (row1: RawSuitesRow, row2: RawSuit return (row1[DB_COLUMN_INDEXES.timestamp] as number) - (row2[DB_COLUMN_INDEXES.timestamp] as number); }; +export interface Database {} + export interface DbLoadResult { url: string; status: string; data: null | unknown } +export interface DbDetails { + url: string; + status: string; + success: boolean; +} + export interface HandleDatabasesOptions { pluginConfig: ReporterConfig; loadDbJsonUrl: (dbJsonUrl: string) => Promise<{data: DbUrlsJsonData | null; status?: string}>; @@ -59,7 +67,7 @@ export const handleDatabases = async (dbJsonUrls: string[], opts: HandleDatabase ); }; -export const mergeTables = ({db, dbPaths, getExistingTables = (): string[] => []}: { db: Database, dbPaths: string[], getExistingTables?: (getTablesStatement: Statement<[]>) => string[] }): void => { +export const mergeTables = ({db, dbPaths, getExistingTables = (): string[] => []}: { db: BetterSqlite3Database, dbPaths: string[], getExistingTables?: (getTablesStatement: Statement<[]>) => string[] }): void => { db.prepare(`PRAGMA page_size = ${DB_MAX_AVAILABLE_PAGE_SIZE}`).run(); for (const dbPath of dbPaths) { diff --git a/lib/gui/server.ts b/lib/gui/server.ts index 8cdbb2c9a..c7ca9b5d4 100644 --- a/lib/gui/server.ts +++ b/lib/gui/server.ts @@ -13,6 +13,16 @@ import {ServerArgs} from './index'; import {ServerReadyData} from './api'; import {ToolName} from '../constants'; import type {TestplaneToolAdapter} from '../adapters/tool/testplane'; +import {ToolRunnerTree} from '@/gui/tool-runner'; + +interface CustomGuiError { + response: { + status: number; + data: string; + } +} + +export type GetInitResponse = (ToolRunnerTree & {customGuiError?: CustomGuiError}) | null; export const start = async (args: ServerArgs): Promise => { const {toolAdapter} = args; @@ -54,7 +64,7 @@ export const start = async (args: ServerArgs): Promise => { await (toolAdapter as TestplaneToolAdapter).initGuiHandler(); } - res.json(app.data); + res.json(app.data satisfies GetInitResponse); } catch (e: unknown) { const error = e as Error; if (!app.data) { @@ -68,7 +78,7 @@ export const start = async (args: ServerArgs): Promise => { data: `Error while trying to initialize custom GUI: ${error.message}` } } - }); + } satisfies GetInitResponse); } }); diff --git a/lib/static/components/gui.jsx b/lib/static/components/gui.jsx index c1276a511..be2857401 100644 --- a/lib/static/components/gui.jsx +++ b/lib/static/components/gui.jsx @@ -30,7 +30,7 @@ class Gui extends Component { }; componentDidMount() { - this.props.actions.initGuiReport(); + this.props.actions.thunkInitGuiReport(); this._subscribeToEvents(); } diff --git a/lib/static/modules/action-names.ts b/lib/static/modules/action-names.ts index 7cd1e4073..77c2b5753 100644 --- a/lib/static/modules/action-names.ts +++ b/lib/static/modules/action-names.ts @@ -60,10 +60,11 @@ export default { TOGGLE_SUITE_CHECKBOX: 'TOGGLE_SUITE_CHECKBOX', TOGGLE_GROUP_CHECKBOX: 'TOGGLE_GROUP_CHECKBOX', UPDATE_BOTTOM_PROGRESS_BAR: 'UPDATE_BOTTOM_PROGRESS_BAR', - GROUP_TESTS_BY_KEY: 'GROUP_TESTS_BY_KEY', + GROUP_TESTS_SET_CURRENT_EXPRESSION: 'GROUP_TESTS_SET_CURRENT_EXPRESSION', TOGGLE_BROWSER_CHECKBOX: 'TOGGLE_BROWSER_CHECKBOX', SUITES_PAGE_SET_CURRENT_SUITE: 'SUITES_PAGE_SET_CURRENT_SUITE', SUITES_PAGE_SET_SECTION_EXPANDED: 'SUITES_PAGE_SET_SECTION_EXPANDED', + SUITES_PAGE_SET_TREE_NODE_EXPANDED: 'SUITES_PAGE_SET_TREE_NODE_EXPANDED', SUITES_PAGE_SET_STEPS_EXPANDED: 'SUITES_PAGE_SET_STEPS_EXPANDED', VISUAL_CHECKS_PAGE_SET_CURRENT_NAMED_IMAGE: 'VISUAL_CHECKS_PAGE_SET_CURRENT_NAMED_IMAGE', UPDATE_LOADING_PROGRESS: 'UPDATE_LOADING_PROGRESS', diff --git a/lib/static/modules/actions/group-tests.ts b/lib/static/modules/actions/group-tests.ts new file mode 100644 index 000000000..6a4443706 --- /dev/null +++ b/lib/static/modules/actions/group-tests.ts @@ -0,0 +1,11 @@ +import actionNames from '@/static/modules/action-names'; +import {Action} from '@/static/modules/actions/types'; + +type SetCurrentGroupByExpressionAction = Action; + +export const setCurrentGroupByExpression = (payload: SetCurrentGroupByExpressionAction['payload']): SetCurrentGroupByExpressionAction => + ({type: actionNames.GROUP_TESTS_SET_CURRENT_EXPRESSION, payload}); + +export type GroupTestsAction = SetCurrentGroupByExpressionAction; diff --git a/lib/static/modules/actions/index.js b/lib/static/modules/actions/index.js index c663a3896..d9a54e48e 100644 --- a/lib/static/modules/actions/index.js +++ b/lib/static/modules/actions/index.js @@ -9,9 +9,9 @@ import {DiffModes} from '../../../constants/diff-modes'; import {getHttpErrorMessage} from '../utils'; import {fetchDataFromDatabases, mergeDatabases, connectToDatabase, getMainDatabaseUrl, getSuitesTableRows} from '../../../db-utils/client'; import {setFilteredBrowsers} from '../query-params'; -import * as plugins from '../plugins'; -import performanceMarks from '../../../constants/performance-marks'; +export * from './lifecycle'; +export * from './group-tests'; export * from './static-accepter'; export * from './suites-page'; export * from './suites'; @@ -35,97 +35,6 @@ export const createNotificationError = (id, error, props = {dismissAfter: 0}) => export const dismissNotification = dismissNotify; -export const initGuiReport = () => { - return async (dispatch) => { - performance?.mark?.(performanceMarks.JS_EXEC); - try { - const appState = await axios.get('/init'); - - const mainDatabaseUrl = getMainDatabaseUrl(); - const db = await connectToDatabase(mainDatabaseUrl.href); - - performance?.mark?.(performanceMarks.DBS_LOADED); - - await plugins.loadAll(appState.data.config); - - performance?.mark?.(performanceMarks.PLUGINS_LOADED); - - dispatch({ - type: actionNames.INIT_GUI_REPORT, - payload: {...appState.data, db} - }); - - const {customGuiError} = appState.data; - - if (customGuiError) { - dispatch(createNotificationError('initGuiReport', {...customGuiError})); - delete appState.data.customGuiError; - } - } catch (e) { - dispatch(createNotificationError('initGuiReport', e)); - } - }; -}; - -export const initStaticReport = () => { - return async dispatch => { - performance?.mark?.(performanceMarks.JS_EXEC); - const dataFromStaticFile = window.data || {}; - let fetchDbDetails = []; - let db = null; - - try { - const mainDatabaseUrls = new URL('databaseUrls.json', window.location.href); - const fetchDbResponses = await fetchDataFromDatabases([mainDatabaseUrls.href], (dbUrl, progress) => { - dispatch({ - type: actionNames.UPDATE_LOADING_PROGRESS, - payload: {[dbUrl]: progress} - }); - }); - - performance?.mark?.(performanceMarks.DBS_LOADED); - - plugins.preloadAll(dataFromStaticFile.config); - - fetchDbDetails = fetchDbResponses.map(({url, status, data}) => ({url, status, success: !!data})); - - const dataForDbs = fetchDbResponses.map(({data}) => data).filter(data => data); - - db = await mergeDatabases(dataForDbs); - - performance?.mark?.(performanceMarks.DBS_MERGED); - } catch (e) { - dispatch(createNotificationError('initStaticReport', e)); - } - - await plugins.loadAll(dataFromStaticFile.config); - - performance?.mark?.(performanceMarks.PLUGINS_LOADED); - const testsTreeBuilder = StaticTestsTreeBuilder.create(); - - if (!db || isEmpty(fetchDbDetails)) { - return dispatch({ - type: actionNames.INIT_STATIC_REPORT, - payload: {...dataFromStaticFile, db, fetchDbDetails, tree: testsTreeBuilder.build([]).tree, stats: {}, skips: [], browsers: []} - }); - } - - const suitesRows = getSuitesTableRows(db); - - performance?.mark?.(performanceMarks.DB_EXTRACTED_ROWS); - - const {tree, stats, skips, browsers} = testsTreeBuilder.build(suitesRows); - - dispatch({ - type: actionNames.INIT_STATIC_REPORT, - payload: {...dataFromStaticFile, db, fetchDbDetails, tree, stats, skips, browsers} - }); - }; -}; - -export const finGuiReport = () => ({type: actionNames.FIN_GUI_REPORT}); -export const finStaticReport = () => ({type: actionNames.FIN_STATIC_REPORT}); - const runTests = ({tests = [], action = {}} = {}) => { return async (dispatch) => { try { @@ -266,7 +175,6 @@ export const toggleSuiteCheckbox = (payload) => ({type: actionNames.TOGGLE_SUITE export const toggleGroupCheckbox = (payload) => ({type: actionNames.TOGGLE_GROUP_CHECKBOX, payload}); export const updateBottomProgressBar = (payload) => ({type: actionNames.UPDATE_BOTTOM_PROGRESS_BAR, payload}); export const toggleTestsGroup = (payload) => ({type: actionNames.TOGGLE_TESTS_GROUP, payload}); -export const groupTestsByKey = (payload) => ({type: actionNames.GROUP_TESTS_BY_KEY, payload}); export const changeViewMode = (payload) => ({type: actionNames.CHANGE_VIEW_MODE, payload}); export const runCustomGuiAction = (payload) => { diff --git a/lib/static/modules/actions/lifecycle.ts b/lib/static/modules/actions/lifecycle.ts new file mode 100644 index 000000000..6df9db467 --- /dev/null +++ b/lib/static/modules/actions/lifecycle.ts @@ -0,0 +1,134 @@ +import axios from 'axios'; +import {isEmpty} from 'lodash'; + +import performanceMarks from '@/constants/performance-marks'; +import { + connectToDatabase, Database, DbDetails, DbLoadResult, + fetchDataFromDatabases, + getMainDatabaseUrl, + getSuitesTableRows, + mergeDatabases +} from '@/db-utils/client'; +import * as plugins from '@/static/modules/plugins'; +import actionNames from '@/static/modules/action-names'; +import {FinalStats, SkipItem, StaticTestsTreeBuilder} from '@/tests-tree-builder/static'; +import {createNotificationError} from '@/static/modules/actions/index'; +import {Action, AppThunk} from '@/static/modules/actions/types'; +import {DataForStaticFile} from '@/server-utils'; +import {GetInitResponse} from '@/gui/server'; +import {Tree} from '@/tests-tree-builder/base'; +import {BrowserItem} from '@/types'; + +export type InitGuiReportAction = Action; +const initGuiReport = (payload: InitGuiReportAction['payload']): InitGuiReportAction => + ({type: actionNames.INIT_GUI_REPORT, payload}); + +export const thunkInitGuiReport = (): AppThunk => { + return async (dispatch) => { + performance?.mark?.(performanceMarks.JS_EXEC); + try { + const appState = await axios.get('/init'); + + if (!appState.data) { + throw new Error('Could not load app data. The report might be broken. Please check your project settings or try deleting results folder and relaunching UI server.'); + } + + const mainDatabaseUrl = getMainDatabaseUrl(); + const db = await connectToDatabase(mainDatabaseUrl.href); + + performance?.mark?.(performanceMarks.DBS_LOADED); + + await plugins.loadAll(appState.data.config); + + performance?.mark?.(performanceMarks.PLUGINS_LOADED); + + dispatch(initGuiReport({...appState.data, db})); + + if (appState.data.customGuiError) { + const {customGuiError} = appState.data; + + dispatch(createNotificationError('initGuiReport', {...customGuiError})); + delete appState.data.customGuiError; + } + } catch (e) { + dispatch(createNotificationError('initGuiReport', e)); + } + }; +}; + +export type InitStaticReportAction = Action & { + db: Database; + fetchDbDetails: DbDetails[], + tree: Tree; + stats: FinalStats | null; + skips: SkipItem[]; + browsers: BrowserItem[]; +}>; +const initStaticReport = (payload: InitStaticReportAction['payload']): InitStaticReportAction => + ({type: actionNames.INIT_STATIC_REPORT, payload}); + +export const thunkInitStaticReport = (): AppThunk => { + return async dispatch => { + performance?.mark?.(performanceMarks.JS_EXEC); + const dataFromStaticFile = (window as {data?: DataForStaticFile}).data || {} as Partial; + + let fetchDbDetails: DbDetails[] = []; + let db = null; + + try { + const mainDatabaseUrls = new URL('databaseUrls.json', window.location.href); + const fetchDbResponses = await fetchDataFromDatabases([mainDatabaseUrls.href], (dbUrl: string, progress: number) => { + dispatch({ + type: actionNames.UPDATE_LOADING_PROGRESS, + payload: {[dbUrl]: progress} + }); + }) as DbLoadResult[]; + + performance?.mark?.(performanceMarks.DBS_LOADED); + + plugins.preloadAll(dataFromStaticFile.config); + + fetchDbDetails = fetchDbResponses.map(({url, status, data}) => ({url, status, success: !!data})); + + const dataForDbs = fetchDbResponses.map(({data}) => data).filter(data => data); + + db = await mergeDatabases(dataForDbs); + + performance?.mark?.(performanceMarks.DBS_MERGED); + } catch (e) { + dispatch(createNotificationError('thunkInitStaticReport', e)); + } + + await plugins.loadAll(dataFromStaticFile.config); + + performance?.mark?.(performanceMarks.PLUGINS_LOADED); + const testsTreeBuilder = StaticTestsTreeBuilder.create(); + + if (!db || isEmpty(fetchDbDetails)) { + dispatch(initStaticReport({...dataFromStaticFile, db, fetchDbDetails, tree: testsTreeBuilder.build([]).tree, stats: null, skips: [], browsers: []})); + + return; + } + + const suitesRows = getSuitesTableRows(db); + + performance?.mark?.(performanceMarks.DB_EXTRACTED_ROWS); + + const {tree, stats, skips, browsers} = testsTreeBuilder.build(suitesRows); + + dispatch(initStaticReport({...dataFromStaticFile, db, fetchDbDetails, tree, stats, skips, browsers})); + }; +}; + +export type FinGuiReportAction = Action; +export const finGuiReport = (): FinGuiReportAction => ({type: actionNames.FIN_GUI_REPORT}); + +export type FinStaticReportAction = Action; +export const finStaticReport = (): FinStaticReportAction => ({type: actionNames.FIN_STATIC_REPORT}); + +export type LifecycleAction = + | InitGuiReportAction + | InitStaticReportAction + | FinGuiReportAction + | FinStaticReportAction; diff --git a/lib/static/modules/actions/suites-page.ts b/lib/static/modules/actions/suites-page.ts index 4c7cd52fa..591af86e4 100644 --- a/lib/static/modules/actions/suites-page.ts +++ b/lib/static/modules/actions/suites-page.ts @@ -1,14 +1,25 @@ import actionNames from '@/static/modules/action-names'; import {Action} from '@/static/modules/actions/types'; +import {TreeViewItemData} from '@/static/new-ui/features/suites/components/SuitesPage/types'; export type SuitesPageSetCurrentSuiteAction = Action; -export const suitesPageSetCurrentSuite = (suiteId: string): SuitesPageSetCurrentSuiteAction => { - return {type: actionNames.SUITES_PAGE_SET_CURRENT_SUITE, payload: {suiteId}}; +export const setCurrentTreeNode = (payload: SuitesPageSetCurrentSuiteAction['payload']): SuitesPageSetCurrentSuiteAction => { + return {type: actionNames.SUITES_PAGE_SET_CURRENT_SUITE, payload}; }; +type SetTreeNodeExpandedStateAction = Action; + +export const setTreeNodeExpandedState = (payload: SetTreeNodeExpandedStateAction['payload']): SetTreeNodeExpandedStateAction => + ({type: actionNames.SUITES_PAGE_SET_TREE_NODE_EXPANDED, payload}); + type SetSectionExpandedStateAction = Action = Payload extends void ? ReduxAction : ReduxAction & {payload: Payload}; + +export type AppThunk = ThunkAction; + +export type SomeAction = + | GroupTestsAction + | LifecycleAction + | SuitesPageAction; diff --git a/lib/static/modules/default-state.ts b/lib/static/modules/default-state.ts index a10e83cd7..b6d2b9907 100644 --- a/lib/static/modules/default-state.ts +++ b/lib/static/modules/default-state.ts @@ -25,6 +25,10 @@ export default Object.assign({config: configDefaults}, { } }, tree: { + groups: { + byId: {}, + allRootIds: [] + }, suites: { byId: {}, allIds: [], @@ -109,12 +113,18 @@ export default Object.assign({config: configDefaults}, { }, staticImageAccepterModal: { commitMessage: 'chore: update screenshot references' + }, + groupTestsData: { + availableSections: [], + availableExpressions: [], + currentExpressionIds: [] } }, ui: { suitesPage: { expandedSectionsById: {}, - expandedStepsByResultId: {} + expandedStepsByResultId: {}, + expandedTreeNodesById: {} }, staticImageAccepterToolbar: { offset: {x: 0, y: 0} diff --git a/lib/static/modules/reducers/grouped-tests/index.js b/lib/static/modules/reducers/grouped-tests/index.js deleted file mode 100644 index de2a98cf3..000000000 --- a/lib/static/modules/reducers/grouped-tests/index.js +++ /dev/null @@ -1,73 +0,0 @@ -import actionNames from '../../action-names'; -import {groupMeta} from './by/meta'; -import {groupResult} from './by/result'; -import {SECTIONS} from '../../../../constants/group-tests'; -import {parseKeyToGroupTestsBy} from '../../utils'; -import {applyStateUpdate, ensureDiffProperty} from '../../utils/state'; - -export default (state, action) => { - switch (action.type) { - case actionNames.INIT_GUI_REPORT: - case actionNames.INIT_STATIC_REPORT: - case actionNames.GROUP_TESTS_BY_KEY: - case actionNames.TESTS_END: - case actionNames.BROWSERS_SELECTED: - case actionNames.VIEW_UPDATE_FILTER_BY_NAME: - case actionNames.VIEW_SET_STRICT_MATCH_FILTER: - case actionNames.CHANGE_VIEW_MODE: - case actionNames.ACCEPT_SCREENSHOT: - case actionNames.ACCEPT_OPENED_SCREENSHOTS: - case actionNames.APPLY_DELAYED_TEST_RESULTS: { - const { - tree, groupedTests, - view: {keyToGroupTestsBy, viewMode, filteredBrowsers, testNameFilter, strictMatchFilter} - } = state; - const viewArgs = {viewMode, filteredBrowsers, testNameFilter, strictMatchFilter}; - const diff = {groupedTests: {meta: {}}}; - - if (!keyToGroupTestsBy) { - groupMeta({tree, group: groupedTests.meta, diff: diff.groupedTests.meta, ...viewArgs}); - - return applyStateUpdate(state, diff); - } - - const [groupSection, groupKey] = parseKeyToGroupTestsBy(keyToGroupTestsBy); - ensureDiffProperty(diff, ['groupedTests', groupSection]); - - const group = groupedTests[groupSection]; - const groupDiff = diff.groupedTests[groupSection]; - - if (groupSection === SECTIONS.RESULT) { - const {config: {errorPatterns}} = state; - - groupResult({ - tree, - group, - groupKey, - errorPatterns, - diff: groupDiff, - ...viewArgs - }); - - return applyStateUpdate(state, diff); - } - - if (groupSection === SECTIONS.META) { - groupMeta({ - tree, - group, - groupKey, - diff: groupDiff, - ...viewArgs - }); - - return applyStateUpdate(state, diff); - } - - return state; - } - - default: - return state; - } -}; diff --git a/lib/static/modules/reducers/grouped-tests/index.ts b/lib/static/modules/reducers/grouped-tests/index.ts new file mode 100644 index 000000000..c0e5a2631 --- /dev/null +++ b/lib/static/modules/reducers/grouped-tests/index.ts @@ -0,0 +1,138 @@ +import actionNames from '../../action-names'; +import {applyStateUpdate} from '../../utils/state'; +import { + GroupByErrorExpression, + GroupByExpression, + GroupByMetaExpression, GroupBySection, + GroupByType, + State +} from '@/static/new-ui/types/store'; +import {SomeAction} from '@/static/modules/actions/types'; +import {groupTests} from './utils'; + +export default (state: State, action: SomeAction): State => { + switch (action.type) { + case actionNames.INIT_GUI_REPORT: + case actionNames.INIT_STATIC_REPORT: { + const availableSections: GroupBySection[] = [{ + id: 'meta', + label: 'meta' + }, { + id: 'error', + label: 'error' + }]; + + const availableExpressions: GroupByExpression[] = []; + + const availableMetaKeys = new Set(); + for (const result of Object.values(state.tree.results.byId)) { + Object.keys(result.metaInfo).forEach(key => availableMetaKeys.add(key)); + } + const availableMetaExpressions = Array.from(availableMetaKeys.values()).map((metaKey): GroupByMetaExpression => ({ + id: metaKey, + type: GroupByType.Meta, + sectionId: 'meta', + key: metaKey + })); + availableExpressions.push(...availableMetaExpressions); + + availableExpressions.push({ + id: 'error-message', + type: GroupByType.Error, + sectionId: 'error' + } satisfies GroupByErrorExpression); + + return applyStateUpdate(state, { + app: { + groupTestsData: { + availableExpressions, + currentExpressionIds: [], + availableSections + } + } + }); + } + + case actionNames.GROUP_TESTS_SET_CURRENT_EXPRESSION: { + const newExpressionIds = action.payload.expressionIds; + + const groupByExpressions = newExpressionIds + .map(id => state.app.groupTestsData.availableExpressions.find(expr => expr.id === id) as GroupByExpression); + const groupsById = groupTests(groupByExpressions, Object.values(state.tree.results.byId)); + + return applyStateUpdate(state, { + tree: { + groups: { + byId: groupsById, + allRootIds: Object.keys(groupsById) + } + }, + app: { + groupTestsData: { + currentExpressionIds: newExpressionIds + } + } + }); + } + // case actionNames.GROUP_TESTS_BY_KEY: + // case actionNames.TESTS_END: + // case actionNames.BROWSERS_SELECTED: + // case actionNames.VIEW_UPDATE_FILTER_BY_NAME: + // case actionNames.VIEW_SET_STRICT_MATCH_FILTER: + // case actionNames.CHANGE_VIEW_MODE: + // case actionNames.ACCEPT_SCREENSHOT: + // case actionNames.ACCEPT_OPENED_SCREENSHOTS: + // case actionNames.APPLY_DELAYED_TEST_RESULTS: { + // const { + // tree, groupedTests, + // view: {keyToGroupTestsBy, viewMode, filteredBrowsers, testNameFilter, strictMatchFilter} + // } = state; + // const viewArgs = {viewMode, filteredBrowsers, testNameFilter, strictMatchFilter}; + // const diff = {groupedTests: {meta: {}}}; + // + // if (!keyToGroupTestsBy) { + // groupMeta({tree, group: groupedTests.meta, diff: diff.groupedTests.meta, ...viewArgs}); + // + // return applyStateUpdate(state, diff); + // } + // + // const [groupSection, groupKey] = parseKeyToGroupTestsBy(keyToGroupTestsBy); + // ensureDiffProperty(diff, ['groupedTests', groupSection]); + // + // const group = groupedTests[groupSection]; + // const groupDiff = diff.groupedTests[groupSection]; + // + // if (groupSection === SECTIONS.RESULT) { + // const {config: {errorPatterns}} = state; + // + // groupResult({ + // tree, + // group, + // groupKey, + // errorPatterns, + // diff: groupDiff, + // ...viewArgs + // }); + // + // return applyStateUpdate(state, diff); + // } + // + // if (groupSection === SECTIONS.META) { + // groupMeta({ + // tree, + // group, + // groupKey, + // diff: groupDiff, + // ...viewArgs + // }); + // + // return applyStateUpdate(state, diff); + // } + // + // return state; + // } + + default: + return state; + } +}; diff --git a/lib/static/modules/reducers/grouped-tests/types.ts b/lib/static/modules/reducers/grouped-tests/types.ts new file mode 100644 index 000000000..e69de29bb diff --git a/lib/static/modules/reducers/grouped-tests/utils.ts b/lib/static/modules/reducers/grouped-tests/utils.ts new file mode 100644 index 000000000..1fbfbe784 --- /dev/null +++ b/lib/static/modules/reducers/grouped-tests/utils.ts @@ -0,0 +1,74 @@ +import { + GroupByExpression, + GroupByMetaExpression, + GroupByType, + GroupEntity, + ResultEntity +} from '@/static/new-ui/types/store'; +import {isNull, isObject, isString, isUndefined, toString} from 'lodash'; + +const stringify = (value: unknown): string => { + if (isString(value)) { + return value; + } + + if (isObject(value)) { + return JSON.stringify(value); + } + + if (isNull(value)) { + return 'null'; + } + + if (isUndefined(value)) { + return 'undefined'; + } + + return toString(value); +}; + +const groupTestsByMeta = (expr: GroupByMetaExpression, results: ResultEntity[]): Record => { + const DEFAULT_GROUP = Symbol(); + const groups: Record = {}; + let id = 1; + + for (const result of results) { + let groupingKey: string | symbol; + if (!result.metaInfo || !result.metaInfo[expr.key]) { + groupingKey = DEFAULT_GROUP; + } else { + groupingKey = `${GroupByType.Meta}__${expr.key}__${stringify(result.metaInfo[expr.key])}`; + } + + if (!groups[groupingKey]) { + groups[groupingKey] = { + id: id.toString(), + label: stringify(result.metaInfo[expr.key]), + resultIds: [], + browserIds: [] + }; + id++; + } + + groups[groupingKey].resultIds.push(result.id); + if (!groups[groupingKey].browserIds.includes(result.parentId)) { + groups[groupingKey].browserIds.push(result.parentId); + } + } + + return groups; +}; + +export const groupTests = (groupByExpressions: GroupByExpression[], results: ResultEntity[]): Record => { + const currentGroupByExpression = groupByExpressions[0]; + + if (!currentGroupByExpression) { + return {}; + } + + if (currentGroupByExpression.type === GroupByType.Meta) { + return groupTestsByMeta(currentGroupByExpression, results); + } + + return {}; +}; diff --git a/lib/static/modules/reducers/suites-page.ts b/lib/static/modules/reducers/suites-page.ts index 910e5d7a7..9e5763080 100644 --- a/lib/static/modules/reducers/suites-page.ts +++ b/lib/static/modules/reducers/suites-page.ts @@ -1,12 +1,51 @@ import {State} from '@/static/new-ui/types/store'; -import {SuitesPageAction} from '@/static/modules/actions/suites-page'; import actionNames from '@/static/modules/action-names'; import {applyStateUpdate} from '@/static/modules/utils/state'; +import {SomeAction} from '@/static/modules/actions/types'; +import {getTreeViewItems} from '@/static/new-ui/features/suites/components/SuitesTreeView/selectors'; -export default (state: State, action: SuitesPageAction): State => { +export default (state: State, action: SomeAction): State => { switch (action.type) { + case actionNames.INIT_STATIC_REPORT: + case actionNames.INIT_GUI_REPORT: { + const {allTreeNodeIds} = getTreeViewItems(state); + + const expandedTreeNodesById: Record = {}; + + for (const nodeId of allTreeNodeIds) { + // TODO: perhaps we should respect currently selected expanded mode here? + expandedTreeNodesById[nodeId] = true; + } + + return applyStateUpdate(state, { + ui: { + suitesPage: { + expandedTreeNodesById + } + } + }); + } case actionNames.SUITES_PAGE_SET_CURRENT_SUITE: - return applyStateUpdate(state, {app: {suitesPage: {currentBrowserId: action.payload.suiteId}}}) as State; + return applyStateUpdate(state, { + app: { + suitesPage: { + currentTreeNodeId: action.payload.treeNodeId, + currentBrowserId: action.payload.browserId, + currentGroupId: action.payload.groupId, + } + } + }) as State; + case actionNames.SUITES_PAGE_SET_TREE_NODE_EXPANDED: { + return applyStateUpdate(state, { + ui: { + suitesPage: { + expandedTreeNodesById: { + [action.payload.nodeId]: action.payload.isExpanded + } + } + } + }) as State; + } case actionNames.SUITES_PAGE_SET_SECTION_EXPANDED: { return applyStateUpdate(state, { ui: { diff --git a/lib/static/modules/reducers/tree/index.js b/lib/static/modules/reducers/tree/index.js index 89834ba94..6efb4ba00 100644 --- a/lib/static/modules/reducers/tree/index.js +++ b/lib/static/modules/reducers/tree/index.js @@ -38,6 +38,11 @@ export default ((state, action) => { tree.results.stateById = {}; tree.images.stateById = {}; + tree.groups = { + byId: {}, + allRootIds: [], + }; + updateAllSuitesStatus(tree, filteredBrowsers); initNodesStates({tree, view: state.view}); resolveUpdatedStatuses(tree.results.byId, tree.images.byId, tree.suites.byId); diff --git a/lib/static/modules/utils/index.js b/lib/static/modules/utils/index.js index 056cf977c..b68b3997a 100644 --- a/lib/static/modules/utils/index.js +++ b/lib/static/modules/utils/index.js @@ -14,13 +14,12 @@ import { isInvalidRefImageError } from '../../../common-utils'; import {ViewMode, SECTIONS, RESULT_KEYS, KEY_DELIMITER} from '../../../constants'; -import default_ from './state'; -const {applyStateUpdate, ensureDiffProperty, getUpdatedProperty} = default_; - -const AVAILABLE_GROUP_SECTIONS = Object.values(SECTIONS); +import {applyStateUpdate, ensureDiffProperty, getUpdatedProperty} from './state'; export {applyStateUpdate, ensureDiffProperty, getUpdatedProperty}; +const AVAILABLE_GROUP_SECTIONS = Object.values(SECTIONS); + export function isSuiteIdle(suite) { return isIdleStatus(suite.status); } @@ -177,25 +176,3 @@ export function getBlob(url) { xhr.send(); }); } - -export default { - applyStateUpdate, - ensureDiffProperty, - getUpdatedProperty, - isSuiteIdle, - isSuiteSuccessful, - isNodeFailed, - isNodeStaged, - isNodeSuccessful, - isAcceptable, - isScreenRevertable, - dateToLocaleString, - getHttpErrorMessage, - isTestNameMatchFilters, - isBrowserMatchViewMode, - shouldShowBrowser, - iterateSuites, - parseKeyToGroupTestsBy, - preloadImage, - getBlob -}; diff --git a/lib/static/modules/utils/state.js b/lib/static/modules/utils/state.ts similarity index 52% rename from lib/static/modules/utils/state.js rename to lib/static/modules/utils/state.ts index 3deb89b8a..c3a919e14 100644 --- a/lib/static/modules/utils/state.js +++ b/lib/static/modules/utils/state.ts @@ -1,17 +1,13 @@ import {get, isPlainObject, isUndefined} from 'lodash'; +import {State} from '@/static/new-ui/types/store'; +import {DeepPartial} from 'redux'; -/** - * Create new state from old state and diff object - * @param {Object} state - * @param {Object} diff - * @returns {Object} new state, created by overlaying diff to state - */ -export function applyStateUpdate(state, diff) { +function copyAndMerge(state: any, diff: any): unknown { const result = {...state}; for (const key in diff) { if (isPlainObject(diff[key]) && isPlainObject(state[key])) { - result[key] = applyStateUpdate(state[key], diff[key]); + result[key] = copyAndMerge(state[key], diff[key]); } else if (diff[key] !== undefined) { result[key] = diff[key]; } else { @@ -22,39 +18,32 @@ export function applyStateUpdate(state, diff) { return result; } +/** + * Create new state from old state and diff object + */ +export const applyStateUpdate = (state: State, diff: DeepPartial): State => copyAndMerge(state, diff) as State; + /** * Ensure diff has an object by given path * Usually it is being used to pass nested diff property to a helper function - * @param {Object} diff - * @param {Array} path */ -export function ensureDiffProperty(diff, path) { - let state = diff; +export function ensureDiffProperty(diff: object, path: string[]): void { + let state = diff as Record; for (let i = 0; i < path.length; i++) { const property = path[i]; state[property] = state[property] || {}; - state = state[property]; + state = state[property] as Record; } } /** - * - * @param {Object} state - * @param {Object} diff - * @param {string|Array} path - in _.get style - * @returns result of _.get(diff, path) if exists, _.get(state, path) else + * @returns Result of _.get(diff, path) if it exists, _.get(state, path) otherwise */ -export function getUpdatedProperty(state, diff, path) { +export function getUpdatedProperty(state: State, diff: State, path: string[]): unknown { const diffValue = get(diff, path); return isUndefined(diffValue) ? get(state, path) : diffValue; } - -export default { - applyStateUpdate, - ensureDiffProperty, - getUpdatedProperty -}; diff --git a/lib/static/new-ui/app/gui.tsx b/lib/static/new-ui/app/gui.tsx index 7bf236f85..d57dccefb 100644 --- a/lib/static/new-ui/app/gui.tsx +++ b/lib/static/new-ui/app/gui.tsx @@ -4,7 +4,7 @@ import {createRoot} from 'react-dom/client'; import {ClientEvents} from '@/gui/constants'; import {App} from './App'; import store from '../../modules/store'; -import {finGuiReport, initGuiReport, suiteBegin, testBegin, testResult, testsEnd} from '../../modules/actions'; +import {finGuiReport, thunkInitGuiReport, suiteBegin, testBegin, testResult, testsEnd} from '../../modules/actions'; const rootEl = document.getElementById('app') as HTMLDivElement; const root = createRoot(rootEl); @@ -36,7 +36,7 @@ function Gui(): ReactNode { }; useEffect(() => { - store.dispatch(initGuiReport()); + store.dispatch(thunkInitGuiReport()); subscribeToEvents(); return () => { diff --git a/lib/static/new-ui/app/report.tsx b/lib/static/new-ui/app/report.tsx index 6881b9605..30ddc8a37 100644 --- a/lib/static/new-ui/app/report.tsx +++ b/lib/static/new-ui/app/report.tsx @@ -2,14 +2,14 @@ import React, {ReactNode, useEffect} from 'react'; import {createRoot} from 'react-dom/client'; import {App} from './App'; import store from '../../modules/store'; -import {initStaticReport, finStaticReport} from '../../modules/actions'; +import {thunkInitStaticReport, finStaticReport} from '../../modules/actions'; const rootEl = document.getElementById('app') as HTMLDivElement; const root = createRoot(rootEl); function Report(): ReactNode { useEffect(() => { - store.dispatch(initStaticReport()); + store.dispatch(thunkInitStaticReport()); return () => { store.dispatch(finStaticReport()); diff --git a/lib/static/new-ui/components/AttemptPickerItem/index.tsx b/lib/static/new-ui/components/AttemptPickerItem/index.tsx index 3de45c473..90a60985e 100644 --- a/lib/static/new-ui/components/AttemptPickerItem/index.tsx +++ b/lib/static/new-ui/components/AttemptPickerItem/index.tsx @@ -65,26 +65,26 @@ export interface AttemptPickerItemProps { interface AttemptPickerItemInternalProps extends AttemptPickerItemProps{ status: TestStatus; attempt: number; - keyToGroupTestsBy: string; + // keyToGroupTestsBy: string; matchedSelectedGroup: boolean; } function AttemptPickerItemInternal(props: AttemptPickerItemInternalProps): ReactNode { - const {status, attempt, isActive, onClick, title, keyToGroupTestsBy, matchedSelectedGroup} = props; + const {status, attempt, isActive, onClick, title, matchedSelectedGroup} = props; const buttonStyle = getButtonStyleByStatus(status); const className = classNames( styles.attemptPickerItem, {[styles['attempt-picker-item--active']]: isActive}, {[styles[`attempt-picker-item--${status}`]]: status}, - {[styles['attempt-picker-item--non-matched']]: keyToGroupTestsBy && !matchedSelectedGroup} + // TODO: {[styles['attempt-picker-item--non-matched']]: keyToGroupTestsBy && !matchedSelectedGroup} ); return ; } export const AttemptPickerItem = connect( - ({tree, view: {keyToGroupTestsBy}}: State, {resultId}: AttemptPickerItemProps) => { + ({tree}: State, {resultId}: AttemptPickerItemProps) => { const result = tree.results.byId[resultId]; const matchedSelectedGroup = get(tree.results.stateById[resultId], 'matchedSelectedGroup', false); const {status, attempt} = result; @@ -92,7 +92,7 @@ export const AttemptPickerItem = connect( return { status: isFailStatus(result.status) && hasUnrelatedToScreenshotsErrors((result as ResultEntityError).error) ? TestStatus.FAIL_ERROR : status, attempt, - keyToGroupTestsBy, + // keyToGroupTestsBy, matchedSelectedGroup }; } diff --git a/lib/static/new-ui/features/suites/components/SuitesPage/index.tsx b/lib/static/new-ui/features/suites/components/SuitesPage/index.tsx index 242e43fea..b1e84850d 100644 --- a/lib/static/new-ui/features/suites/components/SuitesPage/index.tsx +++ b/lib/static/new-ui/features/suites/components/SuitesPage/index.tsx @@ -35,8 +35,8 @@ interface SuitesPageProps { function SuitesPageInternal({currentResult, actions, visibleBrowserIds}: SuitesPageProps): ReactNode { const currentIndex = visibleBrowserIds.indexOf(currentResult?.parentId as string); - const onPreviousSuiteHandler = (): void => void actions.suitesPageSetCurrentSuite(visibleBrowserIds[currentIndex - 1]); - const onNextSuiteHandler = (): void => void actions.suitesPageSetCurrentSuite(visibleBrowserIds[currentIndex + 1]); + const onPreviousSuiteHandler = (): void => void actions.setCurrentTreeNode(visibleBrowserIds[currentIndex - 1]); + const onNextSuiteHandler = (): void => void actions.setCurrentTreeNode(visibleBrowserIds[currentIndex + 1]); const {suiteId: suiteIdParam} = useParams(); const isInitialized = useSelector(getIsInitialized); 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 bcb45ce0a..d0aef3293 100644 --- a/lib/static/new-ui/features/suites/components/SuitesPage/types.ts +++ b/lib/static/new-ui/features/suites/components/SuitesPage/types.ts @@ -1,28 +1,62 @@ import {TestStatus} from '@/constants'; import {ImageEntity} from '@/static/new-ui/types/store'; -export enum TreeViewItemType { +export enum EntityType { + Group, Suite, Browser, } -export interface TreeViewSuiteData { - id: string; - type: TreeViewItemType.Suite; - title: string; - fullTitle: string; - status: TestStatus; -} +// export interface TreeViewGroupData { +// id: string; +// type: TreeViewItemType.Group; +// } +// +// export interface TreeViewSuiteData { +// id: string; +// type: TreeViewItemType.Suite; +// title: string; +// // fullTitle: string; +// status: TestStatus; +// } +// +// export interface TreeViewBrowserData { +// id: string; +// type: TreeViewItemType.Browser; +// title: string; +// // fullTitle: string; +// status: TestStatus; +// errorTitle?: string; +// errorStack?: string; +// images?: ImageEntity[]; +// } -export interface TreeViewBrowserData { +export interface TreeViewItemData { id: string; - type: TreeViewItemType.Browser; + entityType: EntityType; + entityId: string; title: string; - fullTitle: string; - status: TestStatus; + status: TestStatus | null; errorTitle?: string; errorStack?: string; images?: ImageEntity[]; } -export type TreeViewData = TreeViewSuiteData | TreeViewBrowserData; +export interface TreeRoot { + data: { + id?: string; + isRoot: true; + }; + // eslint-disable-next-line no-use-before-define + children?: TreeNode[]; +} + +export interface GenericTreeViewItem { + parentNode?: TreeRoot | GenericTreeViewItem; + data: T; + children?: GenericTreeViewItem[]; +} + +export type TreeNode = GenericTreeViewItem; + +export const isTreeRoot = (nodeOrRoot: TreeNode | TreeRoot): nodeOrRoot is TreeRoot => (nodeOrRoot as TreeRoot).data.isRoot; 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 6f8287927..1520b7997 100644 --- a/lib/static/new-ui/features/suites/components/SuitesTreeView/index.tsx +++ b/lib/static/new-ui/features/suites/components/SuitesTreeView/index.tsx @@ -1,3 +1,4 @@ +import {BranchesRight} from '@gravity-ui/icons'; import {Box} from '@gravity-ui/uikit'; import { unstable_getItemRenderState as getItemRenderState, @@ -11,21 +12,18 @@ import React, {forwardRef, ReactNode, useCallback, useEffect, useImperativeHandl import {useDispatch, useSelector} from 'react-redux'; import {useNavigate, useParams} from 'react-router-dom'; -import { - TreeViewItemType -} from '@/static/new-ui/features/suites/components/SuitesPage/types'; +import {EntityType, TreeViewItemData} from '@/static/new-ui/features/suites/components/SuitesPage/types'; import {TreeViewItemTitle} from '@/static/new-ui/features/suites/components/TreeViewItemTitle'; import {TreeViewItemSubtitle} from '@/static/new-ui/features/suites/components/TreeViewItemSubtitle'; import {State} from '@/static/new-ui/types/store'; import { - getTreeViewExpandedById, getTreeViewItems } from '@/static/new-ui/features/suites/components/SuitesTreeView/selectors'; import styles from './index.module.css'; import {TestStatus} from '@/constants'; import {TreeViewItemIcon} from '../../../../components/TreeViewItemIcon'; import {getIconByStatus} from '@/static/new-ui/utils'; -import {setStrictMatchFilter, suitesPageSetCurrentSuite, toggleSuiteSection} from '@/static/modules/actions'; +import {setStrictMatchFilter, setCurrentTreeNode, setTreeNodeExpandedState} from '@/static/modules/actions'; // eslint-disable-next-line @typescript-eslint/no-empty-interface interface SuitesTreeViewProps {} @@ -40,21 +38,23 @@ export const SuitesTreeView = forwardRef state.app.isInitialized); - const currentBrowserId = useSelector((state: State) => state.app.suitesPage.currentBrowserId); - const treeViewItems = useSelector((state: State) => getTreeViewItems(state).tree); - const treeViewExpandedById = useSelector((state: State) => getTreeViewExpandedById(state)); + const currentTreeNodeId = useSelector((state: State) => state.app.suitesPage.currentTreeNodeId); + const treeData = useSelector((state: State) => getTreeViewItems(state)); + const treeViewExpandedById = useSelector((state: State) => state.ui.suitesPage.expandedTreeNodesById); const list = useList({ - items: treeViewItems, + items: treeData.tree, withExpandedState: true, getItemId: item => { - return item.fullTitle; + return (item as TreeViewItemData).id; }, controlledState: { expandedById: treeViewExpandedById } }); + console.log(list); + const parentRef = React.useRef(null); const virtualizer = useVirtualizer({ @@ -74,35 +74,43 @@ export const SuitesTreeView = forwardRef { - if (!isInitialized) { - return; - } - - dispatch(setStrictMatchFilter(false)); - - let timeoutId: NodeJS.Timeout; - if (suiteId) { - dispatch(suitesPageSetCurrentSuite(suiteId)); - timeoutId = setTimeout(() => { - virtualizer.scrollToIndex(list.structure.visibleFlattenIds.indexOf(suiteId), {align: 'center'}); - }, 50); - } - - return () => clearTimeout(timeoutId); - }, [isInitialized]); + // useEffect(() => { + // if (!isInitialized) { + // return; + // } + // + // dispatch(setStrictMatchFilter(false)); + // + // let timeoutId: NodeJS.Timeout; + // if (suiteId) { + // dispatch(setCurrentTreeNode(suiteId)); + // timeoutId = setTimeout(() => { + // virtualizer.scrollToIndex(list.structure.visibleFlattenIds.indexOf(suiteId), {align: 'center'}); + // }, 50); + // } + // + // return () => clearTimeout(timeoutId); + // }, [isInitialized]); // Event handlers const onItemClick = useCallback(({id}: {id: string}): void => { const item = list.structure.itemsById[id]; + console.log('item click!'); + console.log(item); - if (item.type === TreeViewItemType.Suite) { - dispatch(toggleSuiteSection({suiteId: item.fullTitle, shouldBeOpened: !treeViewExpandedById[item.fullTitle]})); - } else if (item.type === TreeViewItemType.Browser) { - dispatch(suitesPageSetCurrentSuite(id)); - - navigate(encodeURIComponent(item.fullTitle)); + if (item.entityType === EntityType.Browser) { + dispatch(setCurrentTreeNode({treeNodeId: item.id, browserId: item.entityId, groupId: null /* TODO */})); + } else { + dispatch(setTreeNodeExpandedState({nodeId: item.id, isExpanded: !(list.state.expandedById as Record)[item.id]})); } + + // if (item.type === TreeViewItemType.Suite) { + // dispatch(toggleSuiteSection({suiteId: item.fullTitle, shouldBeOpened: !treeViewExpandedById[item.fullTitle]})); + // } else if (item.type === TreeViewItemType.Browser) { + // dispatch(suitesPageSetCurrentSuite(id)); + // + // navigate(encodeURIComponent(item.fullTitle)); + // } }, [list, treeViewExpandedById]); if (list.structure.visibleFlattenIds.length === 0) { @@ -123,13 +131,13 @@ export const SuitesTreeView = forwardRef {virtualizedItems.map((virtualRow) => { const item = list.structure.itemsById[virtualRow.key]; - const isSelected = item.fullTitle === currentBrowserId; + const isSelected = item.id === currentTreeNodeId; const classes = [ styles['tree-view__item'], { [styles['tree-view__item--current']]: isSelected, - [styles['tree-view__item--browser']]: item.type === TreeViewItemType.Browser, - [styles['tree-view__item--error']]: item.type === TreeViewItemType.Browser && (item.status === TestStatus.FAIL || item.status === TestStatus.ERROR) + [styles['tree-view__item--browser']]: item.entityType === EntityType.Browser, + [styles['tree-view__item--error']]: item.entityType === EntityType.Browser && (item.status === TestStatus.FAIL || item.status === TestStatus.ERROR) } ]; @@ -148,7 +156,7 @@ export const SuitesTreeView = forwardRef { return { - startSlot: {getIconByStatus(x.status)}, + startSlot: {x.status ? getIconByStatus(x.status) : }, title: , subtitle: }; 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 e0c264fa1..4f5fcb930 100644 --- a/lib/static/new-ui/features/suites/components/SuitesTreeView/selectors.ts +++ b/lib/static/new-ui/features/suites/components/SuitesTreeView/selectors.ts @@ -1,123 +1,289 @@ import {createSelector} from 'reselect'; import {last} from 'lodash'; import { - isResultEntityError, - hasBrowsers, - hasSuites, BrowserEntity, + GroupEntity, + isBrowserEntity, + isResultEntityError, SuiteEntity } from '@/static/new-ui/types/store'; import { - TreeViewBrowserData, - TreeViewItemType, - TreeViewSuiteData -} from '@/static/new-ui/features/suites/components/SuitesPage/types'; -import {TestStatus} from '@/constants'; -import { - getAllRootSuiteIds, - getBrowsers, getBrowsersState, + getAllRootGroupIds, + getBrowsers, + getBrowsersState, + getGroups, getImages, getResults, getSuites, getSuitesState } from '@/static/new-ui/store/selectors'; import {trimArray} from '@/common-utils'; -import {getFullTitleByTitleParts} from '@/static/new-ui/utils'; -import {TreeViewItem} from '@/static/new-ui/types'; import {isAcceptable} from '@/static/modules/utils'; +import {EntityType, TreeRoot, TreeNode} from '@/static/new-ui/features/suites/components/SuitesPage/types'; +import {getEntityType} from '@/static/new-ui/features/suites/components/SuitesTreeView/utils'; interface TreeViewData { - tree: TreeViewItem[]; + tree: TreeNode[]; visibleBrowserIds: string[]; + allTreeNodeIds: string[]; } // Converts the existing store structure to the one that can be consumed by GravityUI export const getTreeViewItems = createSelector( - [getSuites, getSuitesState, getAllRootSuiteIds, getBrowsers, getBrowsersState, getResults, getImages], - (suites, suitesState, rootSuiteIds, browsers, browsersState, results, images): TreeViewData => { - const EMPTY_SUITE: TreeViewSuiteData = { - id: '', - type: TreeViewItemType.Suite, - title: '', - fullTitle: '', - status: TestStatus.IDLE - }; + [getGroups, getSuites, getAllRootGroupIds, getBrowsers, getBrowsersState, getResults, getImages], + (groups, suites, rootGroupIds, browsers, browsersState, results, images): TreeViewData => { + // const EMPTY_SUITE: TreeViewSuiteData = { + // id: '', + // type: TreeViewItemType.Suite, + // title: '', + // fullTitle: '', + // status: TestStatus.IDLE + // }; + // + // const visibleBrowserIds: string[] = []; + // + // const formatBrowser = (browserData: BrowserEntity, parentSuite: TreeViewSuiteData): TreeNode | null => { + // // Assuming test in concrete browser always has at least one result, even never launched (idle result) + // const lastResult = results[last(browserData.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'); + // } + // + // const data: TreeViewBrowserData = { + // id: browserData.id, + // type: TreeViewItemType.Browser, + // title: browserData.name, + // fullTitle: getFullTitleByTitleParts([parentSuite.fullTitle, browserData.name]), + // status: lastResult.status, + // errorTitle, + // errorStack, + // images: resultImages + // }; + // + // if (!browsersState[browserData.id].shouldBeShown) { + // return null; + // } + // + // visibleBrowserIds.push(data.fullTitle); + // + // return {data}; + // }; + // + // const formatSuite = (suiteData: SuiteEntity, parentSuite: TreeViewSuiteData): TreeNode | null => { + // /* + // * So, at the moment of selection, a tree node needs to know to which groups it belongs. This can be implemented in two ways: + // * - pass down groups context + // * - try to store reference to parent at each tree node. is this viable? I think it should be more convenient and universal. + // * */ + // + // const data: TreeViewSuiteData = { + // id: suiteData.id, + // type: TreeViewItemType.Suite, + // title: suiteData.name, + // fullTitle: getFullTitleByTitleParts([parentSuite.fullTitle, suiteData.name]), + // status: suiteData.status + // }; + // + // if (!suitesState[suiteData.id].shouldBeShown) { + // return null; + // } + // + // let children: TreeNode[] = []; + // if (hasBrowsers(suiteData)) { + // children = suiteData.browserIds + // .map((browserId) => formatBrowser(browsers[browserId], data)) + // .filter(Boolean) as TreeNode[]; + // } + // if (hasSuites(suiteData)) { + // children = suiteData.suiteIds + // .map((suiteId) => formatSuite(suites[suiteId], data)) + // .filter(Boolean) as TreeNode[]; + // } + // + // return {data, children}; + // }; - const visibleBrowserIds: string[] = []; + const formatEntityToTreeNodeData = (entity: SuiteEntity | BrowserEntity, id: string): TreeNode['data'] => { + if (isBrowserEntity(entity)) { + const lastResult = results[last(entity.resultIds) as string]; - const formatBrowser = (browserData: BrowserEntity, parentSuite: TreeViewSuiteData): TreeViewItem | null => { - // Assuming test in concrete browser always has at least one result, even never launched (idle result) - const lastResult = results[last(browserData.resultIds) as string]; + const resultImages = lastResult.imageIds + .map(imageId => images[imageId]) + .filter(imageEntity => isAcceptable(imageEntity)); - 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; - 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'); + } - const stackLines = trimArray(lastResult.error.stack.split('\n')); - errorStack = stackLines.slice(0, 3).join('\n'); + return { + id, + entityType: getEntityType(entity), + entityId: entity.id, + title: entity.name, + status: lastResult.status, + images: resultImages, + errorTitle, + errorStack + }; } - const data: TreeViewBrowserData = { - id: browserData.id, - type: TreeViewItemType.Browser, - title: browserData.name, - fullTitle: getFullTitleByTitleParts([parentSuite.fullTitle, browserData.name]), - status: lastResult.status, - errorTitle, - errorStack, - images: resultImages + return { + id, + entityType: getEntityType(entity), + entityId: entity.id, + title: entity.name, + status: entity.status }; + }; - if (!browsersState[browserData.id].shouldBeShown) { - return null; - } + // const getTreeRoot = (node: TreeNode | TreeRoot): TreeRoot => { + // let root = node; + // while (!(root as TreeRoot).data.isRoot) { + // root = (root as TreeNode).parentNode as TreeNode | TreeRoot; + // } + // + // return root as TreeRoot; + // }; - visibleBrowserIds.push(data.fullTitle); + const buildTreeBottomUp = (entities: (SuiteEntity | BrowserEntity)[], rootId?: string): TreeRoot => { + const TREE_ROOT = Symbol(); + const cache: Record = {}; - return {data}; - }; + const createTreeRoot = (): TreeRoot => ({ + data: { + id: rootId, + isRoot: true + } + }); - const formatSuite = (suiteData: SuiteEntity, parentSuite: TreeViewSuiteData): TreeViewItem | null => { - const data: TreeViewSuiteData = { - id: suiteData.id, - type: TreeViewItemType.Suite, - title: suiteData.name, - fullTitle: getFullTitleByTitleParts([parentSuite.fullTitle, suiteData.name]), - status: suiteData.status + const build = (entity: SuiteEntity | BrowserEntity): TreeNode | TreeRoot => { + let parentNode: TreeNode | TreeRoot; + + const {parentId} = entity; + if (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 = isBrowserEntity(entity) ? entity.name : entity.suitePath[entity.suitePath.length - 1]; + const currentId = parentNode.data.id ? `${parentNode.data.id}/${nodePartialId}` : nodePartialId; + if (cache[currentId]) { + return cache[currentId]; + } + + const currentNode: TreeNode = { + parentNode, + data: formatEntityToTreeNodeData(entity, currentId) + }; + cache[currentId] = currentNode; + + if (parentNode) { + if (!parentNode.children) { + parentNode.children = []; + } + + parentNode.children.push(currentNode); + } + + return currentNode; }; - if (!suitesState[suiteData.id].shouldBeShown) { - return null; + for (const entity of entities) { + build(entity); } - let children: TreeViewItem[] = []; - if (hasBrowsers(suiteData)) { - children = suiteData.browserIds - .map((browserId) => formatBrowser(browsers[browserId], data)) - .filter(Boolean) as TreeViewItem[]; + return cache[TREE_ROOT] as TreeRoot ?? createTreeRoot(); + }; + + const formatGroup = (groupEntity: GroupEntity): TreeNode => { + // let someTreeNode: TreeNode | TreeRoot = {data: {isRoot: true}}; + // for (const browserId of groupEntity.browserIds) { + // const browserEntity = browsers[browserId]; + // + // someTreeNode = ; + // } + + const browserEntities = groupEntity.browserIds.map(browserId => browsers[browserId]); + const suitesTreeRoot = buildTreeBottomUp(browserEntities, groupEntity.id); + + const groupNode: TreeNode = { + data: { + id: groupEntity.id, + entityType: EntityType.Group, + entityId: groupEntity.id, + title: groupEntity.label, + status: null + }, + children: suitesTreeRoot.children + }; + + return groupNode; + }; + + const allTreeNodeIds: string[] = []; + const visibleBrowserIds: string[] = []; + + const collectVisibleBrowserIds = (node: TreeNode | TreeRoot): void => { + if (node.data.id) { + allTreeNodeIds.push(node.data.id); } - if (hasSuites(suiteData)) { - children = suiteData.suiteIds - .map((suiteId) => formatSuite(suites[suiteId], data)) - .filter(Boolean) as TreeViewItem[]; + + if (!node.children) { + return; } - return {data, children}; + for (const childNode of node.children) { + if (childNode.data.entityType === EntityType.Browser) { + visibleBrowserIds.push(childNode.data.id); + } else if (childNode.children?.length) { + collectVisibleBrowserIds(childNode); + } + } }; - const tree = rootSuiteIds - .map((rootId) => { - return formatSuite(suites[rootId], EMPTY_SUITE); - }) - .filter(Boolean) as TreeViewItem[]; + if (rootGroupIds.length > 0) { + const treeNodes = rootGroupIds + .map(rootId => formatGroup(groups[rootId])) + .filter(treeNode => treeNode.children?.length); + + treeNodes.forEach(treeNode => collectVisibleBrowserIds(treeNode)); + + return { + tree: treeNodes, + allTreeNodeIds, + visibleBrowserIds + }; + } + + const suitesTreeRoot = buildTreeBottomUp(Object.values(browsers)); + collectVisibleBrowserIds(suitesTreeRoot); return { visibleBrowserIds, - tree + allTreeNodeIds, + 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 000000000..3ed97e3d1 --- /dev/null +++ b/lib/static/new-ui/features/suites/components/SuitesTreeView/utils.ts @@ -0,0 +1,10 @@ +import {BrowserEntity, isBrowserEntity, SuiteEntity} from '@/static/new-ui/types/store'; +import {EntityType} from '@/static/new-ui/features/suites/components/SuitesPage/types'; + +export const getEntityType = (entity: SuiteEntity | BrowserEntity): EntityType => { + if (isBrowserEntity(entity)) { + return EntityType.Browser; + } + + return EntityType.Suite; +}; 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 3f0ba2af0..2e4119c53 100644 --- a/lib/static/new-ui/features/suites/components/TreeActionsToolbar/index.tsx +++ b/lib/static/new-ui/features/suites/components/TreeActionsToolbar/index.tsx @@ -1,15 +1,15 @@ -import {Icon} from '@gravity-ui/uikit'; +import {Icon, Select, SelectOptionGroup} from '@gravity-ui/uikit'; import classNames from 'classnames'; import { - SquareCheck, - ChevronsExpandVertical, + ArrowUturnCcwLeft, + Check, ChevronsCollapseVertical, - SquareDashed, - Square, + ChevronsExpandVertical, CircleInfo, Play, - Check, - ArrowUturnCcwLeft + Square, + SquareCheck, + SquareDashed } from '@gravity-ui/icons'; import React, {ReactNode, useMemo} from 'react'; import {useDispatch, useSelector} from 'react-redux'; @@ -21,10 +21,12 @@ import { deselectAll, expandAll, retrySuite, - selectAll, staticAccepterStageScreenshot, staticAccepterUnstageScreenshot, + selectAll, setCurrentGroupByExpression, + staticAccepterStageScreenshot, + staticAccepterUnstageScreenshot, undoAcceptImages } from '@/static/modules/actions'; -import {ImageEntity, State} from '@/static/new-ui/types/store'; +import {GroupByType, ImageEntity, State} from '@/static/new-ui/types/store'; import {CHECKED, INDETERMINATE} from '@/constants/checked-statuses'; import {IconButton} from '@/static/new-ui/components/IconButton'; import { @@ -169,7 +171,37 @@ export function TreeActionsToolbar(props: TreeActionsToolbarProps): ReactNode { } tooltip={isSelectedAll ? 'Deselect all' : 'Select all'} view={'flat'} onClick={handleSelectAll} disabled={!isInitialized}/> ; + const groupByExpressionId = useSelector((state: State) => state.app.groupTestsData.currentExpressionIds)[0]; + + const groupBySections = useSelector((state: State) => state.app.groupTestsData.availableSections); + const groupByExpressions = useSelector((state: 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] : []})); + } + }; + return
+