diff --git a/.gitignore b/.gitignore index 51c269e1b..8b843d479 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,5 @@ Thumbs.db /doc/en/_build/ /doc/en/html /pip-wheel-metadata +node_modules/ +yarn-error.log diff --git a/testplan/web_ui/testing/.eslintrc.json b/testplan/web_ui/testing/.eslintrc.json index ce2387180..36a8cbdbf 100644 --- a/testplan/web_ui/testing/.eslintrc.json +++ b/testplan/web_ui/testing/.eslintrc.json @@ -24,7 +24,7 @@ "react" ], "rules": { - "max-len": ["error", { "code": 80 }], + "max-len": ["error", { "code": 80, "comments": 120 }], "linebreak-style": [ "error", "unix" diff --git a/testplan/web_ui/testing/package.json b/testplan/web_ui/testing/package.json index 6cb1c9926..1a60cc224 100644 --- a/testplan/web_ui/testing/package.json +++ b/testplan/web_ui/testing/package.json @@ -3,6 +3,7 @@ "version": "0.2.0", "private": true, "resolutions": { + "@babel/core": "^7.0.0", "@babel/preset-env": "^7.8.7", "watchpack": "1.6.1" }, @@ -10,6 +11,7 @@ "@fortawesome/fontawesome-svg-core": "1.2.2", "@fortawesome/free-solid-svg-icons": "5.2.0", "@fortawesome/react-fontawesome": "0.1.3", + "@reduxjs/toolkit": "~1.3.4", "ag-grid-community": "^21.2.1", "ag-grid-react": "^21.2.1", "aphrodite": "2.2.3", @@ -17,18 +19,24 @@ "bootstrap": "4.3.1", "date-fns": "^2.14.0", "eslint-plugin-react": "^7.14.3", - "react": "16.6.0", + "history": "~4.10.1", + "immer": "^6.0.0", + "lodash": "~4.17.15", + "react": "~16.12.0", "react-copy-html-to-clipboard": "6.0.4", "react-custom-scrollbars": "4.2.1", - "react-dom": "16.6.0", + "react-dom": "~16.12.0", "react-portal-tooltip": "2.4.0", + "react-redux": "^7.2.0", + "react-router": "^5.0.0", "react-router-dom": "^5.0.0", "react-scripts": "^3.4.0", "react-spinners": "^0.6.0", "react-syntax-highlighter": "^11.0.2", "react-test-renderer": "16.6.0", "react-vis": "^1.11.7", - "reactstrap": "6.3.0" + "reactstrap": "6.3.0", + "redux": "^4.0.5" }, "devDependencies": { "enzyme": "3.7.0", @@ -41,8 +49,8 @@ "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test", - "lint": "eslint --ext .js src", - "lint:fix": "eslint --ext .js src --fix", + "lint": "eslint --ext .js --ext .jsx src", + "lint:fix": "eslint --ext .js --ext .jsx src --fix", "eject": "react-scripts eject" }, "homepage": "/", @@ -62,6 +70,5 @@ "snapshotSerializers": [ "enzyme-to-json/serializer" ] - }, - "proxy": "http://localhost:4000" + } } diff --git a/testplan/web_ui/testing/public/index.html b/testplan/web_ui/testing/public/index.html index 7cc6f4511..7a4a98d73 100644 --- a/testplan/web_ui/testing/public/index.html +++ b/testplan/web_ui/testing/public/index.html @@ -6,7 +6,15 @@ - + + <% if(process.env.NODE_ENV !== 'production') { %> + <% if(process.env.REACT_APP_DEVTOOLS) { %> + + <% } %> + <% } %> Testplan diff --git a/testplan/web_ui/testing/src/AssertionPane/AssertionTypes/DictAssertions/dictAssertionUtils.js b/testplan/web_ui/testing/src/AssertionPane/AssertionTypes/DictAssertions/dictAssertionUtils.js index 2f7aafd52..e0d60ff72 100644 --- a/testplan/web_ui/testing/src/AssertionPane/AssertionTypes/DictAssertions/dictAssertionUtils.js +++ b/testplan/web_ui/testing/src/AssertionPane/AssertionTypes/DictAssertions/dictAssertionUtils.js @@ -1,3 +1,4 @@ +import _ from 'lodash'; import {any, sorted, domToString} from './../../../Common/utils'; import {DICT_GRID_STYLE} from './../../../Common/defaults'; @@ -51,8 +52,8 @@ function sortFlattenedJSON( } } - const set = new Set(origFlattenedJSON.map(line => line[0])); - const allItemsAreSameLevel = set.size === 1; + const set = _.uniq(origFlattenedJSON.map(line => line[0])); + const allItemsAreSameLevel = set.length === 1; // if all remaining items of the list are on the same depth level, // they can be sorted and returned diff --git a/testplan/web_ui/testing/src/Common/Message.js b/testplan/web_ui/testing/src/Common/Message.js index 52937c0cb..c560f5b64 100644 --- a/testplan/web_ui/testing/src/Common/Message.js +++ b/testplan/web_ui/testing/src/Common/Message.js @@ -13,9 +13,10 @@ class Message extends Component { paddingLeft: this.props.left, paddingTop: '4.5em', }; + const Tag = this.props.tag || 'h1'; return (
-

{this.props.message}

+ {this.props.message}
); } diff --git a/testplan/web_ui/testing/src/Common/__tests__/testUtils.test.js b/testplan/web_ui/testing/src/Common/__tests__/testUtils.test.js new file mode 100644 index 000000000..a5f33b87f --- /dev/null +++ b/testplan/web_ui/testing/src/Common/__tests__/testUtils.test.js @@ -0,0 +1,408 @@ +/** @jest-environment node */ +import { randomSamples } from '../testUtils'; +import { getPaths } from '../testUtils'; +import { reverseMap } from '../testUtils'; +import { filterObjectDeep } from '../testUtils'; +import { deriveURLPathsFromReport } from '../testUtils'; +import { TESTPLAN_REPORT } from '../fakeReport'; + +describe('randomSamples', () => { + + const arr = [ 111, 'bBb', new Map([ [ 'CcC', 33 ] ]), { 'd-d': 4 } ]; + + it('takes the correct number of samples', () => { + // default n=1 + expect(randomSamples(arr)).toHaveLength(1); + expect(randomSamples(arr, 3)).toHaveLength(3); + }); + + it('takes samples of the correct size', () => { + // default n=1, minSz=1, maxSz=`arr.length` + const randArr1 = randomSamples(arr); + expect(randArr1).toHaveLength(1); + expect(randArr1[0]).toBeInstanceOf(Array); + expect(randArr1[0].length).toBeGreaterThanOrEqual(1); + expect(randArr1[0].length).toBeLessThanOrEqual(arr.length); + + const n = 2, minSz = 2, maxSz = 3; + const randArr2 = randomSamples(arr, n, minSz, maxSz); + expect(randArr2).toHaveLength(2); + for(const sample of randArr2) { + expect(sample).toBeInstanceOf(Array); + expect(sample.length).toBeGreaterThanOrEqual(minSz); + expect(sample.length).toBeLessThanOrEqual(maxSz); + } + + }); + +}); + +const TESTPLAN_REPORT_SLIM = filterObjectDeep( + TESTPLAN_REPORT, + [ 'name', 'entries', 'category' ], +); + +it('`filterObjectDeep` can replicate the jsdoc example', () => { + expect(TESTPLAN_REPORT_SLIM).toEqual({ + name: "Sample Testplan", + entries: [ + { + name: "Primary", + category: "multitest", + entries: [ + { + category: "testsuite", + name: "AlphaSuite", + entries: [ + { + name: "test_equality_passing", + category: "testcase", + entries: [ + { + category: "DEFAULT", + }, + ], + }, + { + name: "test_equality_passing2", + category: "testcase", + entries: [ + { + category: "DEFAULT", + }, + ], + }, + ], + }, + { + category: "testsuite", + name: "BetaSuite", + entries: [ + { + name: "test_equality_passing", + category: "testcase", + entries: [ + { + category: "DEFAULT", + }, + ], + }, + ], + }, + ], + }, + { + name: "Secondary", + category: "multitest", + entries: [ + { + category: "testsuite", + name: "GammaSuite", + entries: [ + { + name: "test_equality_passing", + category: "testcase", + entries: [ + { + category: "DEFAULT", + }, + ], + }, + ], + }, + ], + }, + ], + }); +}); + +it('`getPaths` can replicate the jsdoc example', () => { + const TESTPLAN_REPORT_SLIM_PATH_STRINGS = getPaths(TESTPLAN_REPORT_SLIM); + expect(TESTPLAN_REPORT_SLIM_PATH_STRINGS).toEqual([ + 'name', + 'entries', + 'entries[0]', + 'entries[0].name', + 'entries[0].category', + 'entries[0].entries', + 'entries[0].entries[0]', + 'entries[0].entries[0].category', + 'entries[0].entries[0].name', + 'entries[0].entries[0].entries', + 'entries[0].entries[0].entries[0]', + 'entries[0].entries[0].entries[0].name', + 'entries[0].entries[0].entries[0].category', + 'entries[0].entries[0].entries[0].entries', + 'entries[0].entries[0].entries[0].entries[0]', + 'entries[0].entries[0].entries[0].entries[0].category', + 'entries[0].entries[0].entries[1]', + 'entries[0].entries[0].entries[1].name', + 'entries[0].entries[0].entries[1].category', + 'entries[0].entries[0].entries[1].entries', + 'entries[0].entries[0].entries[1].entries[0]', + 'entries[0].entries[0].entries[1].entries[0].category', + 'entries[0].entries[1]', + 'entries[0].entries[1].category', + 'entries[0].entries[1].name', + 'entries[0].entries[1].entries', + 'entries[0].entries[1].entries[0]', + 'entries[0].entries[1].entries[0].name', + 'entries[0].entries[1].entries[0].category', + 'entries[0].entries[1].entries[0].entries', + 'entries[0].entries[1].entries[0].entries[0]', + 'entries[0].entries[1].entries[0].entries[0].category', + 'entries[1]', + 'entries[1].name', + 'entries[1].category', + 'entries[1].entries', + 'entries[1].entries[0]', + 'entries[1].entries[0].category', + 'entries[1].entries[0].name', + 'entries[1].entries[0].entries', + 'entries[1].entries[0].entries[0]', + 'entries[1].entries[0].entries[0].name', + 'entries[1].entries[0].entries[0].category', + 'entries[1].entries[0].entries[0].entries', + 'entries[1].entries[0].entries[0].entries[0]', + 'entries[1].entries[0].entries[0].entries[0].category', + ]); + + const TESTPLAN_REPORT_SLIM_PATH_ARRAYS = getPaths(TESTPLAN_REPORT_SLIM, true); + expect(TESTPLAN_REPORT_SLIM_PATH_ARRAYS).toEqual([ + [ 'name' ], + [ 'entries' ], + [ 'entries', '0' ], + [ 'entries', '0', 'name' ], + [ 'entries', '0', 'category' ], + [ 'entries', '0', 'entries' ], + [ 'entries', '0', 'entries', '0' ], + [ 'entries', '0', 'entries', '0', 'category' ], + [ 'entries', '0', 'entries', '0', 'name' ], + [ 'entries', '0', 'entries', '0', 'entries' ], + [ 'entries', '0', 'entries', '0', 'entries', '0' ], + [ + 'entries', '0', + 'entries', '0', + 'entries', '0', + 'name', + ], + [ 'entries', '0', 'entries', '0', 'entries', '0', 'category' ], + [ + 'entries', '0', + 'entries', '0', + 'entries', '0', + 'entries', + ], + [ + 'entries', '0', + 'entries', '0', + 'entries', '0', + 'entries', '0', + ], + [ + 'entries', '0', + 'entries', '0', + 'entries', '0', + 'entries', '0', + 'category', + ], + [ 'entries', '0', 'entries', '0', 'entries', '1' ], + [ + 'entries', '0', + 'entries', '0', + 'entries', '1', + 'name', + ], + [ 'entries', '0', 'entries', '0', 'entries', '1', 'category' ], + [ + 'entries', '0', + 'entries', '0', + 'entries', '1', + 'entries', + ], + [ + 'entries', '0', + 'entries', '0', + 'entries', '1', + 'entries', '0', + ], + [ + 'entries', '0', + 'entries', '0', + 'entries', '1', + 'entries', '0', + 'category', + ], + [ 'entries', '0', 'entries', '1' ], + [ 'entries', '0', 'entries', '1', 'category' ], + [ 'entries', '0', 'entries', '1', 'name' ], + [ 'entries', '0', 'entries', '1', 'entries' ], + [ 'entries', '0', 'entries', '1', 'entries', '0' ], + [ + 'entries', '0', + 'entries', '1', + 'entries', '0', + 'name', + ], + [ 'entries', '0', 'entries', '1', 'entries', '0', 'category' ], + [ + 'entries', '0', + 'entries', '1', + 'entries', '0', + 'entries', + ], + [ + 'entries', '0', + 'entries', '1', + 'entries', '0', + 'entries', '0', + ], + [ + 'entries', '0', + 'entries', '1', + 'entries', '0', + 'entries', '0', + 'category', + ], + [ 'entries', '1' ], + [ 'entries', '1', 'name' ], + [ 'entries', '1', 'category' ], + [ 'entries', '1', 'entries' ], + [ 'entries', '1', 'entries', '0' ], + [ 'entries', '1', 'entries', '0', 'category' ], + [ 'entries', '1', 'entries', '0', 'name' ], + [ 'entries', '1', 'entries', '0', 'entries' ], + [ 'entries', '1', 'entries', '0', 'entries', '0' ], + [ + 'entries', '1', + 'entries', '0', + 'entries', '0', + 'name', + ], + [ 'entries', '1', 'entries', '0', 'entries', '0', 'category' ], + [ + 'entries', '1', + 'entries', '0', + 'entries', '0', + 'entries', + ], + [ + 'entries', '1', + 'entries', '0', + 'entries', '0', + 'entries', '0', + ], + [ + 'entries', '1', + 'entries', '0', + 'entries', '0', + 'entries', '0', + 'category', + ], + ]); +}); + +describe('reverseMap', () => { + + const aMap = new Map([ + [ 'a', 1 ], + [ 'b', 2 ], + [ 'c', 3 ], + ]); + + const aRevMap = new Map([ + [ 1, 'a' ], + [ 2, 'b' ], + [ 3, 'c' ], + ]); + + it('reverses a map', () => { + expect(reverseMap(aMap)).toStrictEqual(aRevMap); + }); +}); + +describe('deriveURLPathsFromReport', () => { + + it('does the jsdoc example', () => { + const aliasMap = new Map(); + const path2PathArrayMap = new Map(); + const expectedPaths = deriveURLPathsFromReport( + TESTPLAN_REPORT, aliasMap, path2PathArrayMap, + ); + expect(expectedPaths).toEqual([ + '/Sample Testplan', + '/Sample Testplan/Primary', + '/Sample Testplan/Primary/AlphaSuite', + '/Sample Testplan/Primary/AlphaSuite/test_equality_passing', + '/Sample Testplan/Primary/AlphaSuite/test_equality_passing2', + '/Sample Testplan/Primary/BetaSuite', + '/Sample Testplan/Primary/BetaSuite/test_equality_passing', + '/Sample Testplan/Secondary', + '/Sample Testplan/Secondary/GammaSuite', + '/Sample Testplan/Secondary/GammaSuite/test_equality_passing', + ]); + expect(aliasMap).toStrictEqual(new Map([ + [ 'Sample Testplan', 'Sample Testplan' ], + [ 'Primary', 'Primary' ], + [ 'AlphaSuite', 'AlphaSuite' ], + [ 'test_equality_passing', 'test_equality_passing' ], + [ 'test_equality_passing2', 'test_equality_passing2' ], + [ 'BetaSuite', 'BetaSuite' ], + [ 'Secondary', 'Secondary' ], + [ 'GammaSuite', 'GammaSuite' ], + ])); + expect(path2PathArrayMap).toEqual(new Map([ + [ '/Sample Testplan', [ 'Sample Testplan' ] ], + [ '/Sample Testplan/Primary', [ 'Sample Testplan', 'Primary' ] ], + [ + '/Sample Testplan/Primary/AlphaSuite', + [ 'Sample Testplan', 'Primary', 'AlphaSuite' ], + ], + [ + '/Sample Testplan/Primary/AlphaSuite/test_equality_passing', + [ + 'Sample Testplan', + 'Primary', + 'AlphaSuite', + 'test_equality_passing', + ], + ], + [ + '/Sample Testplan/Primary/AlphaSuite/test_equality_passing2', [ + 'Sample Testplan', + 'Primary', + 'AlphaSuite', + 'test_equality_passing2', + ], + ], + [ + '/Sample Testplan/Primary/BetaSuite', + [ 'Sample Testplan', 'Primary', 'BetaSuite' ], + ], + [ + '/Sample Testplan/Primary/BetaSuite/test_equality_passing', [ + 'Sample Testplan', + 'Primary', + 'BetaSuite', + 'test_equality_passing', + ], + ], + [ '/Sample Testplan/Secondary', [ 'Sample Testplan', 'Secondary' ] ], + [ + '/Sample Testplan/Secondary/GammaSuite', + [ 'Sample Testplan', 'Secondary', 'GammaSuite' ], + ], + [ + '/Sample Testplan/Secondary/GammaSuite/test_equality_passing', + [ + 'Sample Testplan', + 'Secondary', + 'GammaSuite', + 'test_equality_passing', + ], + ], + ])); + + }); + +}); diff --git a/testplan/web_ui/testing/src/Common/defaults.js b/testplan/web_ui/testing/src/Common/defaults.js index baaf8a2f2..fd906b1b6 100644 --- a/testplan/web_ui/testing/src/Common/defaults.js +++ b/testplan/web_ui/testing/src/Common/defaults.js @@ -12,6 +12,7 @@ const MEDIUM_GREY = '#D0D0D0'; const DARK_GREY = '#ADADAD'; const BLACK = '#404040'; +export const BOTTOMMOST_ENTRY_CATEGORY = 'testcase'; const COLUMN_WIDTH = 22; // unit: em const MIN_COLUMN_WIDTH = 180; // unit: px const INTERACTIVE_COL_WIDTH = 28; // wider to fit interactive buttons diff --git a/testplan/web_ui/testing/src/Common/filterStates.js b/testplan/web_ui/testing/src/Common/filterStates.js new file mode 100644 index 000000000..f6dcf7fc6 --- /dev/null +++ b/testplan/web_ui/testing/src/Common/filterStates.js @@ -0,0 +1,3 @@ +export const ALL = 'all'; +export const FAILED = 'fail'; +export const PASSED = 'pass'; diff --git a/testplan/web_ui/testing/src/Common/sampleReports.js b/testplan/web_ui/testing/src/Common/sampleReports.js index 8adbfeb72..52f705b56 100644 --- a/testplan/web_ui/testing/src/Common/sampleReports.js +++ b/testplan/web_ui/testing/src/Common/sampleReports.js @@ -356,7 +356,7 @@ const FakeInteractiveReport = { entries: [], logs: [], name: "test_interactive", - name_type_index: new Set(), + name_type_index: [], status: 'unknown', runtime_status: 'ready', status_override: null, @@ -369,7 +369,7 @@ const FakeInteractiveReport = { }], logs: [], name: "Interactive Suite", - name_type_index: new Set(), + name_type_index: [], part: null, status: 'unknown', runtime_status: 'ready', @@ -382,7 +382,7 @@ const FakeInteractiveReport = { }], logs: [], name: "Interactive MTest", - name_type_index: new Set(), + name_type_index: [], part: null, status: 'unknown', runtime_status: 'ready', @@ -395,7 +395,7 @@ const FakeInteractiveReport = { }], meta: {}, name: "Fake Interactive Report", - name_type_index: new Set(), + name_type_index: [], status: 'unknown', runtime_status: 'ready', status_override: null, diff --git a/testplan/web_ui/testing/src/Common/testUtils.js b/testplan/web_ui/testing/src/Common/testUtils.js new file mode 100644 index 000000000..ea8405a55 --- /dev/null +++ b/testplan/web_ui/testing/src/Common/testUtils.js @@ -0,0 +1,500 @@ +/** + * Use this module to store utility functions / types that are *only* used in + * tests. This helps prevent unnecessary bloating of the production bundle. + * + * To use one of these functions in production, move it to + * {@link './../Common/utils.js'} and reexport it from here. + */ +import _ from 'lodash'; +export { reverseMap } from './utils'; + +// `react-scripts test` sets NODE_ENV to "test". This module shouldn't be +// used at any other time since it would needlessly fatten the build. +// Move a function to "../utils.js" to use something here in production. +if(process.env.NODE_ENV !== 'test') { + throw new Error('This module is only to be used during testing'); +} + +/** + * Generate random samples from an array. + * @param {any[]} arr - The array to sample from + * @param {number} [n=1] - The number of samples to take + * @param {number} [minSz=1] - The minimum sample size to take from `arr` + * @param {number} [maxSz=`arr.length`] - The max sample size to take from `arr` + * @returns {Array} + */ +export const randomSamples = (arr, n = 1, minSz = 1, maxSz = arr.length) => + Array.from({ length: n }, () => _.sampleSize(arr, _.random(minSz, maxSz))); + +/** + * Returns a new object with only `keepKeys`. If the corresponding value to one + * of `keepKeys` is an object, that object is similarly filtered down to + * only `keepKeys`. If the corresponding value to one of `keepKeys` is an + * array, + * an attempt is made to run the same filtering operation on each element. + * @example + * > const TESTPLAN_REPORT = + * require('../mocks/documents/TESTPLAN_REPORT_2.json'); + * > const TESTPLAN_REPORT_SLIM = filterObjectDeep(TESTPLAN_REPORT, ['name', + * 'entries', 'category']) + * > TESTPLAN_REPORT_SLIM + * { + * name: "Sample Testplan", + * entries: [ + * { + * name: "Primary", + * category: "multitest", + * entries: [ + * { + * category: "testsuite", + * name: "AlphaSuite", + * entries: [ + * { + * name: "test_equality_passing", + * category: "testcase", + * entries: [ + * { + * category: "DEFAULT" + * } + * ] + * }, + * { + * name: "test_equality_passing2", + * category: "testcase", + * entries: [ + * { + * category: "DEFAULT" + * } + * ] + * } + * ] + * }, + * { + * category: "testsuite", + * name: "BetaSuite", + * entries: [ + * { + * name: "test_equality_passing", + * category: "testcase", + * entries: [ + * { + * category: "DEFAULT" + * } + * ] + * } + * ] + * } + * ] + * }, + * { + * name: "Secondary", + * category: "multitest", + * entries: [ + * { + * category: "testsuite", + * name: "GammaSuite", + * entries: [ + * { + * name: "test_equality_passing", + * category: "testcase", + * entries: [ + * { + * category: "DEFAULT" + * } + * ] + * } + * ] + * } + * ] + * } + * ] + * } + * + * @param {Object.} obj - Object to filter + * @param {string[]} keepKeys - Keys to keep from `obj` + * @returns {Object.} A copy of `obj` that contains only + * `keepKeys` + */ +export const filterObjectDeep = (obj, keepKeys) => + Object.fromEntries(Object.entries(obj) + .filter(([prop]) => keepKeys.includes(prop)) + .map(([prop, val]) => [ + prop, (function handle(v) { + return Array.isArray(v) + ? v.map(_v => handle(_v)) + : _.isObject(v) + ? filterObjectDeep(v, keepKeys) + : v; + })(val) + ]) + ); + +/** + * Adapted from {@link https://stackoverflow.com/a/36128759|this SO answer} - + * Returns an array of all possible paths for an object such that any element + * can be used as the 2nd argument the {@link _.at} funtion. + * @example + * > // using TESTPLAN_REPORT_SLIM from the `filterObjectDeep` jsdoc example + * > const TESTPLAN_REPORT_SLIM_PATH_STRINGS = getPaths(TESTPLAN_REPORT_SLIM); + * > const TESTPLAN_REPORT_SLIM_PATH_ARRAYS = getPaths(TESTPLAN_REPORT_SLIM, + * true); + * > TESTPLAN_REPORT_SLIM_PATH_STRINGS + * [ + * 'name', + * 'entries', + * 'entries[0]', + * 'entries[0].name', + * 'entries[0].category', + * 'entries[0].entries', + * 'entries[0].entries[0]', + * 'entries[0].entries[0].category', + * 'entries[0].entries[0].name', + * 'entries[0].entries[0].entries', + * 'entries[0].entries[0].entries[0]', + * 'entries[0].entries[0].entries[0].name', + * 'entries[0].entries[0].entries[0].category', + * 'entries[0].entries[0].entries[0].entries', + * 'entries[0].entries[0].entries[0].entries[0]', + * 'entries[0].entries[0].entries[0].entries[0].category', + * 'entries[0].entries[0].entries[1]', + * 'entries[0].entries[0].entries[1].name', + * 'entries[0].entries[0].entries[1].category', + * 'entries[0].entries[0].entries[1].entries', + * 'entries[0].entries[0].entries[1].entries[0]', + * 'entries[0].entries[0].entries[1].entries[0].category', + * 'entries[0].entries[1]', + * 'entries[0].entries[1].category', + * 'entries[0].entries[1].name', + * 'entries[0].entries[1].entries', + * 'entries[0].entries[1].entries[0]', + * 'entries[0].entries[1].entries[0].name', + * 'entries[0].entries[1].entries[0].category', + * 'entries[0].entries[1].entries[0].entries', + * 'entries[0].entries[1].entries[0].entries[0]', + * 'entries[0].entries[1].entries[0].entries[0].category', + * 'entries[1]', + * 'entries[1].name', + * 'entries[1].category', + * 'entries[1].entries', + * 'entries[1].entries[0]', + * 'entries[1].entries[0].category', + * 'entries[1].entries[0].name', + * 'entries[1].entries[0].entries', + * 'entries[1].entries[0].entries[0]', + * 'entries[1].entries[0].entries[0].name', + * 'entries[1].entries[0].entries[0].category', + * 'entries[1].entries[0].entries[0].entries', + * 'entries[1].entries[0].entries[0].entries[0]', + * 'entries[1].entries[0].entries[0].entries[0].category' + * ] + * + * > TESTPLAN_REPORT_SLIM_PATH_ARRAYS + * + * [ + * [ 'name' ], + * [ 'entries' ], + * [ 'entries', '0' ], + * [ 'entries', '0', 'name' ], + * [ 'entries', '0', 'category' ], + * [ 'entries', '0', 'entries' ], + * [ 'entries', '0', 'entries', '0' ], + * [ 'entries', '0', 'entries', '0', 'category' ], + * [ 'entries', '0', 'entries', '0', 'name' ], + * [ 'entries', '0', 'entries', '0', 'entries' ], + * [ 'entries', '0', 'entries', '0', 'entries', '0' ], + * [ + * 'entries', '0', + * 'entries', '0', + * 'entries', '0', + * 'name', + * ], + * [ 'entries', '0', 'entries', '0', 'entries', '0', 'category' ], + * [ + * 'entries', '0', + * 'entries', '0', + * 'entries', '0', + * 'entries', + * ], + * [ + * 'entries', '0', + * 'entries', '0', + * 'entries', '0', + * 'entries', '0', + * ], + * [ + * 'entries', '0', + * 'entries', '0', + * 'entries', '0', + * 'entries', '0', + * 'category', + * ], + * [ 'entries', '0', 'entries', '0', 'entries', '1' ], + * [ + * 'entries', '0', + * 'entries', '0', + * 'entries', '1', + * 'name', + * ], + * [ 'entries', '0', 'entries', '0', 'entries', '1', 'category' ], + * [ + * 'entries', '0', + * 'entries', '0', + * 'entries', '1', + * 'entries', + * ], + * [ + * 'entries', '0', + * 'entries', '0', + * 'entries', '1', + * 'entries', '0', + * ], + * [ + * 'entries', '0', + * 'entries', '0', + * 'entries', '1', + * 'entries', '0', + * 'category', + * ], + * [ 'entries', '0', 'entries', '1' ], + * [ 'entries', '0', 'entries', '1', 'category' ], + * [ 'entries', '0', 'entries', '1', 'name' ], + * [ 'entries', '0', 'entries', '1', 'entries' ], + * [ 'entries', '0', 'entries', '1', 'entries', '0' ], + * [ + * 'entries', '0', + * 'entries', '1', + * 'entries', '0', + * 'name', + * ], + * [ 'entries', '0', 'entries', '1', 'entries', '0', 'category' ], + * [ + * 'entries', '0', + * 'entries', '1', + * 'entries', '0', + * 'entries', + * ], + * [ + * 'entries', '0', + * 'entries', '1', + * 'entries', '0', + * 'entries', '0', + * ], + * [ + * 'entries', '0', + * 'entries', '1', + * 'entries', '0', + * 'entries', '0', + * 'category', + * ], + * [ 'entries', '1' ], + * [ 'entries', '1', 'name' ], + * [ 'entries', '1', 'category' ], + * [ 'entries', '1', 'entries' ], + * [ 'entries', '1', 'entries', '0' ], + * [ 'entries', '1', 'entries', '0', 'category' ], + * [ 'entries', '1', 'entries', '0', 'name' ], + * [ 'entries', '1', 'entries', '0', 'entries' ], + * [ 'entries', '1', 'entries', '0', 'entries', '0' ], + * [ + * 'entries', '1', + * 'entries', '0', + * 'entries', '0', + * 'name', + * ], + * [ 'entries', '1', 'entries', '0', 'entries', '0', 'category' ], + * [ + * 'entries', '1', + * 'entries', '0', + * 'entries', '0', + * 'entries', + * ], + * [ + * 'entries', '1', + * 'entries', '0', + * 'entries', '0', + * 'entries', '0', + * ], + * [ + * 'entries', '1', + * 'entries', '0', + * 'entries', '0', + * 'entries', '0', + * 'category', + * ], + * ] + * + * @param {object} obj - A plain object + * @param {boolean} [asArrays=false] - Pass `true` to return array-form paths + * @returns {string[] | Array} + */ +export const getPaths = (obj, asArrays = false) => + asArrays ? getPathArrays(obj) : getPathStrings(obj); + +const getPathStrings = _.memoize(obj => { + const pathStrings = []; + (function walk(subObj, prevPathStr = '') { + for(const [ prop, val ] of Object.entries(subObj)) { + const propPathString = !prevPathStr ? prop : `${prevPathStr}.${prop}`; + pathStrings.push(propPathString); + if(_.isPlainObject(val)) { + walk(val, propPathString); + } else if(Array.isArray(val)) { + val.forEach((v, i) => { + const elementPathString = `${propPathString}[${i}]`; + pathStrings.push(elementPathString); + const elementVal = val[i]; + if(_.isPlainObject(elementVal)) { + walk(elementVal, elementPathString); + } + }); + } + } + })(obj); + return pathStrings; +}); + +const getPathArrays = _.memoize( + obj => getPathStrings(obj).map(v => _.toPath(v)) +); + +/** + * Get the URL paths that could be traversed in a given report + * @example + > const aliasMap = new Map(); + > const path2PathArrayMap = new Map(); + > const expectedPaths = deriveURLPathsFromReport(TESTPLAN_REPORT_1, aliasMap, path2PathArrayMap); + > expectedPaths + [ + '/Sample Testplan', + '/Sample Testplan/Primary', + '/Sample Testplan/Primary/AlphaSuite', + '/Sample Testplan/Primary/AlphaSuite/test_equality_passing', + '/Sample Testplan/Primary/AlphaSuite/test_equality_passing2', + '/Sample Testplan/Primary/BetaSuite', + '/Sample Testplan/Primary/BetaSuite/test_equality_passing', + '/Sample Testplan/Secondary', + '/Sample Testplan/Secondary/GammaSuite', + '/Sample Testplan/Secondary/GammaSuite/test_equality_passing' + ] + > aliasMap + Map { + 'Sample Testplan' => 'Sample Testplan', + 'Primary' => 'Primary', + 'AlphaSuite' => 'AlphaSuite', + 'test_equality_passing' => 'test_equality_passing', + 'test_equality_passing2' => 'test_equality_passing2', + 'BetaSuite' => 'BetaSuite', + 'Secondary' => 'Secondary', + 'GammaSuite' => 'GammaSuite' +} + > path2PathArrayMap + Map { + '/Sample Testplan' => [ 'Sample Testplan' ], + '/Sample Testplan/Primary' => [ 'Sample Testplan', 'Primary' ], + '/Sample Testplan/Primary/AlphaSuite' => [ 'Sample Testplan', 'Primary', 'AlphaSuite' ], + '/Sample Testplan/Primary/AlphaSuite/test_equality_passing' => [ + 'Sample Testplan', + 'Primary', + 'AlphaSuite', + 'test_equality_passing' + ], + '/Sample Testplan/Primary/AlphaSuite/test_equality_passing2' => [ + 'Sample Testplan', + 'Primary', + 'AlphaSuite', + 'test_equality_passing2' + ], + '/Sample Testplan/Primary/BetaSuite' => [ 'Sample Testplan', 'Primary', 'BetaSuite' ], + '/Sample Testplan/Primary/BetaSuite/test_equality_passing' => [ + 'Sample Testplan', + 'Primary', + 'BetaSuite', + 'test_equality_passing' + ], + '/Sample Testplan/Secondary' => [ 'Sample Testplan', 'Secondary' ], + '/Sample Testplan/Secondary/GammaSuite' => [ 'Sample Testplan', 'Secondary', 'GammaSuite' ], + '/Sample Testplan/Secondary/GammaSuite/test_equality_passing' => [ + 'Sample Testplan', + 'Secondary', + 'GammaSuite', + 'test_equality_passing' + ] +} + * @param {object} report + * @param {null | Map} [aliasMap=null] + * @param {null | Map} [path2PathArrayMap=null] + * @param {null | Map} [path2ObjectPathMap=null] + * @returns {string[]} + */ +export function deriveURLPathsFromReport( + report, + aliasMap = null, + path2PathArrayMap = null, + path2ObjectPathMap = null, +) { + const pathMap = new Map(); + return getPaths(report, true) + .filter(arrayPath => arrayPath.slice(-1)[0] === 'name') + .map(arrayPath => { + const + fullPathKey = arrayPath.slice(0, -1).join('.'), + pathBasename = _.get(report, arrayPath), + pathBasenameEncoded = encodeURIComponent(pathBasename), + parentPathKey = arrayPath.slice(0, -3).join('.'), + parentPath = pathMap.get(parentPathKey) || '', + fullPathVal = `${parentPath}/${pathBasenameEncoded}`; + pathMap.set(fullPathKey, fullPathVal); + if(aliasMap !== null) { + aliasMap.set(pathBasenameEncoded, pathBasename); + } + if(path2PathArrayMap !== null) { + const parentPathArr = path2PathArrayMap.get(parentPath) || []; + path2PathArrayMap.set(fullPathVal, parentPathArr.concat(pathBasename)); + } + if(path2ObjectPathMap !== null) { + path2ObjectPathMap.set(fullPathVal, arrayPath); + } + return fullPathVal; + }); +} + +/** + * Like {@link https://lodash.com/docs/4.17.15#matches|this} but recursively. + * @example + > const obj = { + a: 11, + b: 2, + c: { a: 1, x: 'a' }, + d: [ + { a: 1, b: 22 }, + { a: 11, c: 33 }, + { a: 111, d: [ { a: 1, y: 'aa' } ]} + ] + } + > findAllDeep(obj, { a: 1 }, [ 'c', 'd' ]) + [ + { "a": 1, "x": "a" }, + { "a": 1, "b": 22 }, + { "a": 1, "y": "aa" }, + ] + > findAllDeep(obj, { a: 1 }, 'c') + [ { "a": 1, "x": "a" } ] + + * @param {Object.} srcObj - object to run the find on + * @param {Object.} matchObj - partial object to match against + * @param {null | string | string[]} [diveProps=null] - properties that will be searched recursively for matches + * @returns {object[]} array of all found objects + */ +export const findAllDeep = (srcObj, matchObj, diveProps = null) => + [ _.find([ srcObj ], matchObj) ].concat( + [ diveProps ].flat().filter(Boolean).flatMap( + prop => + srcObj[prop] + ? Array.isArray(srcObj[prop]) + ? srcObj[prop].flatMap(el => findAllDeep(el, matchObj, prop)) + : _.isPlainObject(srcObj[prop]) + ? [ _.find([ srcObj[prop] ], matchObj) ] + : [] + : [], + ), + ).filter(Boolean); diff --git a/testplan/web_ui/testing/src/Common/utils.js b/testplan/web_ui/testing/src/Common/utils.js index d24423cc9..271279990 100644 --- a/testplan/web_ui/testing/src/Common/utils.js +++ b/testplan/web_ui/testing/src/Common/utils.js @@ -2,6 +2,7 @@ * Common utility functions. */ import {NAV_ENTRY_DISPLAY_DATA} from "./defaults"; +import _ from 'lodash'; /** * Get the data to be used when displaying the nav entry. @@ -97,3 +98,69 @@ export { hashCode, domToString, }; + +/** + * Reverses a Map. + * @template T, U + * @param {Map} aMap - The map to reverse + * @returns {Map} + */ +export const reverseMap = aMap => new Map( + Array.from(aMap).map(([newVal, newKey]) => [ newKey, newVal ]) +); + +export const isNonemptyArray = x => Array.isArray(x) && x.length; + +export const unindent = (strArr, ...tagsArr) => strArr.slice(1).reduce( + (acc, str, i) => { + return `${acc}${`${tagsArr[i]}${str}`.replace(/^\s+/mg, '')}`; + }, + strArr[0] +).trimLeft(); + +export const flattened = (strArr, ...tagsArr) => { + return _.spread(unindent)([ strArr, ...tagsArr ]) + .replace(/[ \t]*\n/g, ' ') + .trimRight(); +}; + +/** + * The difference between this and lodash.toPlainObject is that this also + * plain-objectifies the object's prototype.too. The _...*In*_ suffix follows + * the lodash naming scheme where the non-_...*In*_ function acts only on own + * properties and the _...*In*_ acts on own and inherited properties. + * + * Only values meeting the redux definition of "plain" types will be returned, + * e.g. the result shallowly omits functions and `symbol`'s. + * + * @example + + > const err = new Error('oops'); + > const errObjRepr = { name: 'Error', message: 'oops', stack: 'Uncaught Error: oops...' }; + > require('lodash').isEqual(toPlainObjectIn(err), errObjRepr); // i.e. deep equals + true + + * @param {object} obj + * @param {boolean} [enumerableOnly=false] - whether or not to skip + * non-enumerable properties like Lodash does. + * @returns {Object.} + */ +export const toPlainObjectIn = (obj, enumerableOnly = false) => { + const objectify = o => { + return Object.fromEntries(Object.entries(o) + .filter(([_, v]) => _.isPlainObject(v.value)) + .filter(([_, v]) => !enumerableOnly || v.enumerable) + .map(([k, v]) => [k, v.value]) + ); + }; + const ownDescriptors = Object.getOwnPropertyDescriptors(obj); + const ownProps = objectify(ownDescriptors); + const proto = Object.getPrototypeOf(obj); + const protoDescriptors = Object.getOwnPropertyDescriptors(proto); + const inheritedProps = objectify(protoDescriptors); + return { ...inheritedProps, ...ownProps }; +}; + +export const joinURLComponent = (base, component) => { + return `${base.replace(/\/+$/, '').trim()}/${component}`; +}; diff --git a/testplan/web_ui/testing/src/Nav/NavBreadcrumbs.js b/testplan/web_ui/testing/src/Nav/NavBreadcrumbs.js index f88d931b7..ef2a638b4 100644 --- a/testplan/web_ui/testing/src/Nav/NavBreadcrumbs.js +++ b/testplan/web_ui/testing/src/Nav/NavBreadcrumbs.js @@ -62,7 +62,7 @@ NavBreadcrumbs.propTypes = { handleNavClick: PropTypes.func, }; -const styles = StyleSheet.create({ +export const styles = StyleSheet.create({ navBreadcrumbs: { top: '2.5em', borderBottom: 'solid 1px rgba(0, 0, 0, 0.1)', diff --git a/testplan/web_ui/testing/src/Nav/__tests__/__snapshots__/InteractiveNav.test.js.snap b/testplan/web_ui/testing/src/Nav/__tests__/__snapshots__/InteractiveNav.test.js.snap index e02afbeae..fb0e87353 100644 --- a/testplan/web_ui/testing/src/Nav/__tests__/__snapshots__/InteractiveNav.test.js.snap +++ b/testplan/web_ui/testing/src/Nav/__tests__/__snapshots__/InteractiveNav.test.js.snap @@ -36,7 +36,7 @@ exports[`InteractiveNav shallow renders and matches snapshot 1`] = ` "entries": Array [], "logs": Array [], "name": "test_interactive", - "name_type_index": Set {}, + "name_type_index": Array [], "runtime_status": "ready", "status": "unknown", "status_override": null, @@ -50,7 +50,7 @@ exports[`InteractiveNav shallow renders and matches snapshot 1`] = ` ], "logs": Array [], "name": "Interactive Suite", - "name_type_index": Set {}, + "name_type_index": Array [], "part": null, "runtime_status": "ready", "status": "unknown", @@ -64,7 +64,7 @@ exports[`InteractiveNav shallow renders and matches snapshot 1`] = ` ], "logs": Array [], "name": "Interactive MTest", - "name_type_index": Set {}, + "name_type_index": Array [], "part": null, "runtime_status": "ready", "status": "unknown", @@ -78,7 +78,7 @@ exports[`InteractiveNav shallow renders and matches snapshot 1`] = ` ], "meta": Object {}, "name": "Fake Interactive Report", - "name_type_index": Set {}, + "name_type_index": Array [], "runtime_status": "ready", "status": "unknown", "status_override": null, @@ -123,7 +123,7 @@ exports[`InteractiveNav shallow renders and matches snapshot 1`] = ` "entries": Array [], "logs": Array [], "name": "test_interactive", - "name_type_index": Set {}, + "name_type_index": Array [], "runtime_status": "ready", "status": "unknown", "status_override": null, @@ -137,7 +137,7 @@ exports[`InteractiveNav shallow renders and matches snapshot 1`] = ` ], "logs": Array [], "name": "Interactive Suite", - "name_type_index": Set {}, + "name_type_index": Array [], "part": null, "runtime_status": "ready", "status": "unknown", @@ -151,7 +151,7 @@ exports[`InteractiveNav shallow renders and matches snapshot 1`] = ` ], "logs": Array [], "name": "Interactive MTest", - "name_type_index": Set {}, + "name_type_index": Array [], "part": null, "runtime_status": "ready", "status": "unknown", diff --git a/testplan/web_ui/testing/src/Nav/navUtils.js b/testplan/web_ui/testing/src/Nav/navUtils.js index d69083283..2cde66b6a 100644 --- a/testplan/web_ui/testing/src/Nav/navUtils.js +++ b/testplan/web_ui/testing/src/Nav/navUtils.js @@ -105,7 +105,7 @@ const applyNamedFilter = (entries, filter) => { } }; -const styles = StyleSheet.create({ +export const styles = StyleSheet.create({ navButton: { position: 'relative', display: 'block', diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/components/AutoSelectRedirect.jsx b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/AutoSelectRedirect.jsx new file mode 100644 index 000000000..0c4757267 --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/AutoSelectRedirect.jsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Redirect } from 'react-router'; +import { connect } from 'react-redux'; +import _ from 'lodash'; +import { setSelectedEntry } from '../state/uiActions'; +import { BOTTOMMOST_ENTRY_CATEGORY } from '../../../Common/defaults'; +import { joinURLComponent } from '../../../Common/utils'; + +const connector = connect( + null, + function mapDispatchToProps(dispatch) { + return { + boundSetSelectedEntry: entry => dispatch(setSelectedEntry(entry)), + }; + }, + function mergeProps(stateProps, dispatchProps, ownProps) { + const { boundSetSelectedEntry } = dispatchProps; + const { location, entry } = ownProps; + return { + getRedirectToEntry: () => { + const to = { + ...location, + pathname: joinURLComponent(location.pathname, entry.name || ''), + }; + let currEntry = entry, nextEntry, nextAlias; + while( + _.isObjectLike(currEntry) + && currEntry.category !== BOTTOMMOST_ENTRY_CATEGORY + && Array.isArray(currEntry.entries) + && currEntry.entries.length === 1 + && _.isObjectLike(nextEntry = currEntry.entries[0]) + && _.isString(nextAlias = nextEntry.name) + ) { + currEntry = nextEntry; + const nextURLBasename = encodeURIComponent(nextAlias); + to.pathname = joinURLComponent(to.pathname, nextURLBasename); + } + boundSetSelectedEntry(currEntry); + return to; + }, + }; + }, +); + +const AutoSelectRedirect = ({ getRedirectToEntry }) => ( + +); + +export default connector(AutoSelectRedirect); diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/components/CenterPane.jsx b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/CenterPane.jsx new file mode 100644 index 000000000..2a456409e --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/CenterPane.jsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from '@reduxjs/toolkit'; +import { + mkGetUIFilter, + mkGetUISelectedEntry, + mkGetUISidebarWidthFirstAvailable, +} from '../state/uiSelectors'; +import { + mkGetReportDocument, + mkGetReportIsFetching, + mkGetReportLastFetchError, +} from '../state/reportSelectors'; +import AssertionPane from '../../../AssertionPane/AssertionPane'; +import Message from '../../../Common/Message'; +import { BOTTOMMOST_ENTRY_CATEGORY } from '../../../Common/defaults'; +import { isNonemptyArray } from '../../../Common/utils'; +import _ from 'lodash'; + +const STARTING_MSG = 'Waiting to fetch Testplan report...'; +const FETCHING_MSG = 'Fetching Testplan report...'; +const FINISHED_MSG = 'Please select an entry.'; +const ERRORED_PREFIX_MSG = 'Error fetching Testplan report'; +// using a static placeholder - rather than creating a new one every time in +// connect or the component - will help prevent unnecessary rerenders since +// we'll maintain the same referential identity +const SELECTED_ENTRY_PLACEHOLDER = { + uid: '', + category: '', + logs: [], + entries: [], + description: [], +}; + +const placeholderConnector = connect( + () => { + const getDocument = mkGetReportDocument(); + const getIsFetching = mkGetReportIsFetching(); + const getError = mkGetReportLastFetchError(); + return function mapStateToProps(state) { + const isFetching = getIsFetching(state); + const error = getError(state); + const document = getDocument(state); + let message = STARTING_MSG; + if(!isFetching && error) { + message = _.isObject(error) + ? `${ERRORED_PREFIX_MSG}: ${error.message}` + : `${ERRORED_PREFIX_MSG}.`; + } else if(isFetching) { + message = FETCHING_MSG; + } else if(!isFetching && !error && _.isObject(document)) { + message = FINISHED_MSG; + } + return { message }; + }; + } +); + +const Placeholder = ({ message, left }) => ( + +); + +const ConnectedPlaceholder = placeholderConnector(Placeholder); + +const centerPaneConnector = connect( + () => { + const getFilter = mkGetUIFilter(); + const getSelectedEntrySafe = createSelector( + mkGetUISelectedEntry(), + entry => (entry || {}) + ); + const getReportUID = createSelector( + mkGetReportDocument(), + document => (document || {}).uid, + ); + const getSidebarWidth = mkGetUISidebarWidthFirstAvailable(); + return function mapStateToProps(state) { + const selectedEntry = getSelectedEntrySafe(state); + return { + filter: getFilter(state), + selectedEntry: selectedEntry.category === BOTTOMMOST_ENTRY_CATEGORY + ? selectedEntry + : SELECTED_ENTRY_PLACEHOLDER, + reportUID: getReportUID(state), + left: getSidebarWidth(state), + }; + }; + }, +); + +const CenterPane = ({ selectedEntry, reportUID, filter, left }) => { + const { uid, category, logs, entries, description } = selectedEntry; + const descriptionEntries = React.useMemo(() => ( + isNonemptyArray(description) ? description : [ description ] + ).filter(Boolean), [ description ]); + if(category === BOTTOMMOST_ENTRY_CATEGORY) { + return ( + + ); + } + return ; +}; + +export default centerPaneConnector(CenterPane); diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/components/DisplayEmptyCheckBox.jsx b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/DisplayEmptyCheckBox.jsx new file mode 100644 index 000000000..373a1823d --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/DisplayEmptyCheckBox.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { Label, Input, DropdownItem } from 'reactstrap'; +import { css } from 'aphrodite'; +import { connect } from 'react-redux'; +import { mkGetUIIsDisplayEmpty } from '../state/uiSelectors'; +import { setDisplayEmpty } from '../state/uiActions'; +import navStyles from '../../../Toolbar/navStyles'; + +const DROPDOWN_ITEM_CLASSES = css(navStyles.dropdownItem); +const FILTER_LABEL_CLASSES = css(navStyles.filterLabel); + +const connector = connect( + () => { + const getIsDisplayEmpty = mkGetUIIsDisplayEmpty(); + return function mapStateToProps(state) { + return { + isDisplayEmpty: getIsDisplayEmpty(state), + }; + }; + }, + function mapDispatchToProps(dispatch) { + return { + boundSetDisplayEmpty: isDisplay => dispatch(setDisplayEmpty(isDisplay)), + }; + }, + function mergeProps(stateProps, dispatchProps, ownProps) { + const { isDisplayEmpty } = stateProps; + const { boundSetDisplayEmpty } = dispatchProps; + const { label } = ownProps; + return { + label: label || '', + isDisplayEmpty, + onChange: () => boundSetDisplayEmpty(!isDisplayEmpty), + }; + } +); + +const DisplayEmptyCheckBox = ({ label, isDisplayEmpty, onChange }) => ( + + + +); + +export default connector(DisplayEmptyCheckBox); diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/components/DocumentationButton.jsx b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/DocumentationButton.jsx new file mode 100644 index 000000000..f60787728 --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/DocumentationButton.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { NavItem } from 'reactstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { library } from '@fortawesome/fontawesome-svg-core'; +import { faBook } from '@fortawesome/free-solid-svg-icons'; +import { TOOLBAR_BUTTON_CLASSES, BUTTONS_BAR_CLASSES } from '../styles'; + +library.add(faBook); + +export default function DocumentationButton() { + return ( + + + + + + ); +} diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/components/EmptyListGroupItem.jsx b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/EmptyListGroupItem.jsx new file mode 100644 index 000000000..12cc025e4 --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/EmptyListGroupItem.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { ListGroupItem } from 'reactstrap'; +import { css } from 'aphrodite'; +import { navUtilsStyles } from '../styles'; + +const LGI_CLASSES = css(navUtilsStyles.navButton); + +export default function EmptyListGroupItem() { + return ( + + No entries to display... + + ); +} diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/components/FilterButton.jsx b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/FilterButton.jsx new file mode 100644 index 000000000..c6923e77d --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/FilterButton.jsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { + DropdownItem, + DropdownMenu, + DropdownToggle, + UncontrolledDropdown, +} from 'reactstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faFilter } from '@fortawesome/free-solid-svg-icons'; +import { library } from '@fortawesome/fontawesome-svg-core'; +import { + TOOLBAR_BUTTON_CLASSES, + BUTTONS_BAR_CLASSES, + FILTER_DROPDOWN_CLASSES, +} from '../styles'; +import FilterRadioButton from './FilterRadioButton'; +import DisplayEmptyCheckBox from './DisplayEmptyCheckBox'; +import * as filterStates from '../../../Common/filterStates'; + +library.add(faFilter); + +export default function FilterButton({ toolbarStyle }) { + return ( + + + + + + + + + + + + + ); +} diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/components/FilterRadioButton.jsx b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/FilterRadioButton.jsx new file mode 100644 index 000000000..00a42f913 --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/FilterRadioButton.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { DropdownItem, Input, Label } from 'reactstrap'; +import { connect } from 'react-redux'; +import { mkGetUIFilter } from '../state/uiSelectors'; +import { setFilter } from '../state/uiActions'; +import { DROPDOWN_ITEM_CLASSES, FILTER_LABEL_CLASSES } from '../styles'; + +const connector = connect( + () => { + const getFilter = mkGetUIFilter(); + return function mapStateToProps(state) { + return { + filter: getFilter(state), + }; + }; + }, + function mapDispatchToProps(dispatch) { + return { + onChange: evt => { + dispatch(setFilter(evt.currentTarget.value)); + }, + }; + }, + function mergeProps(stateProps, dispatchProps, ownProps) { + const { filter } = stateProps; + const { onChange } = dispatchProps; + const { value, label } = ownProps; + return { + value, + label, + isChecked: filter === value, + onChange, + }; + }, +); + +const FilterRadioButton = ({ isChecked, onChange, value, label }) => ( + + + +); + +export default connector(FilterRadioButton); diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/components/HelpButton.jsx b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/HelpButton.jsx new file mode 100644 index 000000000..4380c588a --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/HelpButton.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { NavItem } from 'reactstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { library } from '@fortawesome/fontawesome-svg-core'; +import { faQuestionCircle } from '@fortawesome/free-solid-svg-icons'; +import { connect } from 'react-redux'; +import { mkGetUIIsShowHelpModal } from '../state/uiSelectors'; +import { setShowHelpModal } from '../state/uiActions'; +import { TOOLBAR_BUTTON_CLASSES, BUTTONS_BAR_CLASSES } from '../styles'; + +library.add(faQuestionCircle); + +const connector = connect( + () => { + const getIsShowHelpModal = mkGetUIIsShowHelpModal(); + return function mapStateToProps(state) { + return { + isShowHelpModal: getIsShowHelpModal(state), + }; + }; + }, + function mapDispatchToProps(dispatch) { + return { + boundSetShowHelpModal: isShow => dispatch(setShowHelpModal(isShow)), + }; + }, + function mergeProps(stateProps, dispatchProps) { + const { isShowHelpModal } = stateProps; + const { boundSetShowHelpModal } = dispatchProps; + return { + onClick: evt => { + evt.stopPropagation(); + boundSetShowHelpModal(!isShowHelpModal); + }, + }; + }, +); + +/** + * Return the button which toggles the help modal. + * @returns {React.FunctionComponentElement} + */ +const HelpButton = ({ onClick }) => ( + + + +); + +export default connector(HelpButton); diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/components/HelpModal.jsx b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/HelpModal.jsx new file mode 100644 index 000000000..cb6375d85 --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/HelpModal.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { ModalHeader, ModalFooter, ModalBody, Modal, Button } from 'reactstrap'; +import { connect } from 'react-redux'; +import { mkGetUIIsShowHelpModal } from '../state/uiSelectors'; +import { setShowHelpModal } from '../state/uiActions'; + +const connector = connect( + () => { + const getIsShowHelpModal = mkGetUIIsShowHelpModal(); + return function mapStateToProps(state) { + return { + isShowHelpModal: getIsShowHelpModal(state), + }; + }; + }, + function mapDispatchToProps(dispatch) { + return { + boundSetShowHelpModal: isShow => dispatch(setShowHelpModal(isShow)), + }; + }, + function mergeProps(stateProps, dispatchProps) { + const { isShowHelpModal } = stateProps; + const { boundSetShowHelpModal } = dispatchProps; + return { + isShowHelpModal, + toggleModal: () => boundSetShowHelpModal(!isShowHelpModal), + }; + }, +); + +const HelpModal = ({ isShowHelpModal, toggleModal }) => ( + + Help + + This is filter box help! + + + + + +); + +export default connector(HelpModal); diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/components/InfoButton.jsx b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/InfoButton.jsx new file mode 100644 index 000000000..571fc5277 --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/InfoButton.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { NavItem } from 'reactstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { library } from '@fortawesome/fontawesome-svg-core'; +import { faInfo } from '@fortawesome/free-solid-svg-icons'; +import { connect } from 'react-redux'; +import { mkGetUIIsShowInfoModal } from '../state/uiSelectors'; +import { setShowInfoModal } from '../state/uiActions'; +import { TOOLBAR_BUTTON_CLASSES } from '../styles'; +import { BUTTONS_BAR_CLASSES } from '../styles'; + +library.add(faInfo); + +const connector = connect( + () => { + const getIsShowInfoModal = mkGetUIIsShowInfoModal(); + return function mapStateToProps(state) { + return { + isShowInfoModal: getIsShowInfoModal(state), + }; + }; + }, + function mapDispatchToProps(dispatch) { + return { + boundSetShowInfoModal: isShow => dispatch(setShowInfoModal(isShow)), + }; + }, + function mergeProps(stateProps, dispatchProps) { + const { isShowInfoModal } = stateProps; + const { boundSetShowInfoModal } = dispatchProps; + return { + toggleInfo: evt => { + evt.stopPropagation(); + boundSetShowInfoModal(!isShowInfoModal); + }, + }; + }, +); + +const InfoButton = ({ toggleInfo }) => ( + + + +); + +export default connector(InfoButton); diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/components/InfoModal.jsx b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/InfoModal.jsx new file mode 100644 index 000000000..a996ecd70 --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/InfoModal.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { ModalHeader, ModalFooter, ModalBody, Modal, Button } from 'reactstrap'; +import { connect } from 'react-redux'; +import { mkGetUIIsShowInfoModal } from '../state/uiSelectors'; +import { setShowInfoModal } from '../state/uiActions'; +import InfoTable from './InfoTable'; + +const connector = connect( + () => { + const getIsShowInfoModal = mkGetUIIsShowInfoModal(); + return function mapStateToProps(state) { + return { + isShowInfoModal: getIsShowInfoModal(state), + }; + }; + }, + function mapDispatchToProps(dispatch) { + return { + boundSetShowInfoModal: isShow => dispatch(setShowInfoModal(isShow)), + }; + }, + function mergeProps(stateProps, dispatchProps) { + const { isShowInfoModal } = stateProps; + const { boundSetShowInfoModal } = dispatchProps; + return { + isShowInfoModal, + toggleInfo: () => boundSetShowInfoModal(!isShowInfoModal), + }; + }, +); + +const InfoModal = ({ isShowInfoModal, toggleInfo }) => ( + + Information + + + + + + + +); + +export default connector(InfoModal); diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/components/InfoTable.jsx b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/InfoTable.jsx new file mode 100644 index 000000000..7c57c0f1d --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/InfoTable.jsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { css } from 'aphrodite'; +import { Table } from 'reactstrap'; +import { connect } from 'react-redux'; +import { mkGetReportDocument } from '../state/reportSelectors'; +import navStyles from '../../../Toolbar/navStyles'; + +const INFO_TABLE_CLASSES = css(navStyles.infoTable); +const INFO_TABLE_KEY_CLASSES = css(navStyles.infoTableKey); +const INFO_TABLE_VAL_CLASSES = css(navStyles.infoTableValue); + +const connector = connect( + () => { + const getReportDocument = mkGetReportDocument(); + return function mapStateToProps(state) { + return { + reportDocument: getReportDocument(state), + }; + }; + }, +); + +const InfoTable = ({ reportDocument }) => React.useMemo(() => { + if(!(reportDocument && reportDocument.information)) { + return ( + + +
No information to display.
+ ); + } + const infoList = reportDocument.information.map((item, i) => ( + + {item[0]} + {item[1]} + + )); + if(reportDocument.timer && reportDocument.timer.run) { + if(reportDocument.timer.run.start) { + infoList.push( + + start + {reportDocument.timer.run.start} + , + ); + } + if(reportDocument.timer.run.end) { + infoList.push( + + end + {reportDocument.timer.run.end} + , + ); + } + } + return ( + + {infoList} +
+ ); +}, [ reportDocument ]); + +export default connector(InfoTable); diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/components/NavBreadcrumb.jsx b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/NavBreadcrumb.jsx new file mode 100644 index 000000000..345cbf78c --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/NavBreadcrumb.jsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { NavLink, withRouter } from 'react-router-dom'; +import _ from 'lodash'; +import { css } from 'aphrodite'; +import { connect } from 'react-redux'; +import { setSelectedEntry } from '../state/uiActions'; +import { mkGetUISelectedEntry } from '../state/uiSelectors'; +import { + CommonStyles, + navBreadcrumbStyles, + UNDECORATED_LINK_STYLE, + ACTIVE_LINK_CLASSES, +} from '../styles'; +import NavEntry from '../../../Nav/NavEntry'; +import { joinURLComponent } from '../../../Common/utils'; + +const BREADCRUMB_LINK_CLASSES = css( + navBreadcrumbStyles.breadcrumbEntry, + CommonStyles.unselectable, +); + +const connector = connect( + () => { + const getSelectedEntry = mkGetUISelectedEntry(); + return function mapStateToProps(state) { + return { + selectedEntry: getSelectedEntry(state), + }; + }; + }, + function mapDispatchToProps(dispatch) { + return { + boundSetSelectedEntry: entry => dispatch(setSelectedEntry(entry)), + }; + }, + function mergeProps(stateProps, dispatchProps, ownProps) { + const { selectedEntry } = stateProps; + const { boundSetSelectedEntry } = dispatchProps; + const { + location: currentLocation, + match: { url: matchedUrl = '' }, + entry, + } = ownProps; + const { + name: entryName = '', + uid: entryUid = '', + category: entryCategory = '', + status: entryStatus = '', + counter: { + passed: nPass = 0, + failed: nFail = 0, + error: nError = 0, + }, + } = entry; + return { + entryName, + entryUid, + entryCategory, + entryStatus, + nPass, + nFailOrError: nFail + nError, + to: { ...currentLocation, pathname: joinURLComponent(matchedUrl, '') }, + onClick: () => { + if(selectedEntry !== entry && _.isObject(entry)) { + boundSetSelectedEntry(entry); + } + }, + }; + } +); + +const NavBreadcrumb = ({ + onClick, entryName, entryUid, entryCategory, entryStatus, nPass, + nFailOrError, to, +}) => ( + + + +); + +export default withRouter(connector(NavBreadcrumb)); diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/components/NavBreadcrumbWithNextRoute.jsx b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/NavBreadcrumbWithNextRoute.jsx new file mode 100644 index 000000000..44582f9b7 --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/NavBreadcrumbWithNextRoute.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { Route } from 'react-router'; +import _ from 'lodash'; +import NavBreadcrumb from './NavBreadcrumb'; +import { joinURLComponent } from '../../../Common/utils'; + +const NavBreadcrumbWithNextRoute = ({ entries, match }) => { + const routePath = _.isObject(match) ? match.url : null; + const tgtEntry = React.useMemo(() => { + if(_.isArray(entries) && _.isObject(match)) { + const decodedName = decodeURIComponent(match.params.id); + return entries.find(e => decodedName === e.name); + } else if(_.isObject(entries)) { + return entries; + } + return null; + }, [ entries, match ]); + const tgtEntryIsObj = _.isObject(tgtEntry); + const tgtEntryEntries = tgtEntryIsObj ? tgtEntry.entries : null; + return !(tgtEntryIsObj && _.isString(routePath)) ? null : ( + <> + + ( + + )}/> + + ); +}; + +export default NavBreadcrumbWithNextRoute; diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/components/NavPanes.jsx b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/NavPanes.jsx new file mode 100644 index 000000000..46f56c9f9 --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/NavPanes.jsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import { css } from 'aphrodite'; +import { Route } from 'react-router'; +import { navBreadcrumbStyles } from '../styles'; +import { + mkGetReportIsFetching, + mkGetReportLastFetchError, + mkGetReportDocument, +} from '../state/reportSelectors'; +import EmptyListGroupItem from './EmptyListGroupItem'; +import NavBreadcrumbWithNextRoute from './NavBreadcrumbWithNextRoute'; +import NavSidebarWithNextRoute from './NavSidebarWithNextRoute'; +import AutoSelectRedirect from './AutoSelectRedirect'; + +const NAV_BREADCRUMB_CONTAINER_CLASSES = css( + navBreadcrumbStyles.navBreadcrumbs, + navBreadcrumbStyles.breadcrumbContainer +); + +const connector = connect( + () => { + const getReportIsFetching = mkGetReportIsFetching(); + const getReportLastFetchError = mkGetReportLastFetchError(); + const getReportDocument = mkGetReportDocument(); + return function mapStateToProps(state) { + return { + document: getReportDocument(state), + fetchError: getReportLastFetchError(state), + isFetching: getReportIsFetching(state), + }; + }; + }, +); + +const NavPanes = ({ document, fetchError, isFetching }) => { + const entries = React.useMemo(() => [ document ], [ document ]); + return (isFetching || fetchError || !document) ? : ( + <> + { + /** + * Here each path component adds a new breadcrumb to the top nav, + * and it sets up the next route that will receive the next path + * component when the user navigates further + */ + } +
    + ( + + )}/> +
+ { + /** + * Here each path component completely replaces the nav sidebar. + * This contains the links that will determine the next set of routes. + */ + } + ( + + )}/> + ( + + )}/> + + ); +}; + +export default connector(NavPanes); diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/components/NavSidebar.jsx b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/NavSidebar.jsx new file mode 100644 index 000000000..888075aa7 --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/NavSidebar.jsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { ListGroup } from 'reactstrap'; +import { css } from 'aphrodite'; +import { connect } from 'react-redux'; +import _ from 'lodash'; +import { + mkGetUIFilter, + mkGetUISidebarWidthFirstAvailable, +} from '../state/uiSelectors'; +import { setSidebarWidth } from '../state/uiActions'; +import NavSidebarEntry from './NavSidebarEntry'; +import Column from '../../../Nav/Column'; +import * as filterStates from '../../../Common/filterStates'; +import { isNonemptyArray } from '../../../Common/utils'; +import { navListStyles } from '../styles'; +import EmptyListGroupItem from './EmptyListGroupItem'; + +const BUTTON_LIST_CLASSES = css(navListStyles.buttonList); + +const connector = connect( + () => { + const getFilter = mkGetUIFilter(); + const getFirstAvailableWidth = mkGetUISidebarWidthFirstAvailable(); + return function mapStateToProps(state) { + return { + filter: getFilter(state), + width: getFirstAvailableWidth(state), // string of either em or px + }; + }; + }, + function mapDispatchToProps(dispatch) { + const onDragUnrestricted = px => dispatch(setSidebarWidth(px)); + return { + onDrag: _.debounce(onDragUnrestricted, 12, { maxWait: 17 }), + }; + }, +); + +const NavSidebar = ({ entries, filter, location, match, width, onDrag }) => { + + const entryFilter = React.useCallback(currEntry => { + if(!_.isObject(currEntry)) return false; + const { passed = 0, failed = 0, errored = 0 } = currEntry.counter || {}; + return !( + (filter === filterStates.PASSED && passed === 0) || + (filter === filterStates.FAILED && (failed + errored) === 0) + ); + }, [ filter ]); + + const links = React.useMemo(() => { + const _links = !isNonemptyArray(entries) ? [] : + entries.filter(entryFilter).map((entry, idx) => ( + + )); + return _links.length ? _links : (); + }, [ entries, location, match, entryFilter ]); + + return ( + + + {links} + + + ); +}; + +export default connector(NavSidebar); diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/components/NavSidebarEntry.jsx b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/NavSidebarEntry.jsx new file mode 100644 index 000000000..c68ce36c9 --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/NavSidebarEntry.jsx @@ -0,0 +1,139 @@ +import React from 'react'; +import { NavLink } from 'react-router-dom'; +import { connect } from 'react-redux'; +import { css } from 'aphrodite'; +import { ListGroupItem } from 'reactstrap'; +import _ from 'lodash'; +import { mkGetUISelectedEntry, mkGetUIIsShowTags } from '../state/uiSelectors'; +import { setSelectedEntry } from '../state/uiActions'; +import { + navUtilsStyles, + UNDECORATED_LINK_STYLE, + ACTIVE_LINK_CLASSES, +} from '../styles'; +import { BOTTOMMOST_ENTRY_CATEGORY } from '../../../Common/defaults'; +import { joinURLComponent } from '../../../Common/utils'; +import TagList from '../../../Nav/TagList'; +import NavEntry from '../../../Nav/NavEntry'; + +const SIDEBAR_LINK_CLASSES = css( + navUtilsStyles.navButton, + navUtilsStyles.navButtonInteract, +); + +const connector = connect( + () => { + const getIsShowTags = mkGetUIIsShowTags(); + const getSelectedEntry = mkGetUISelectedEntry(); + return function mapStateToProps(state) { + return { + isShowTags: getIsShowTags(state), + selectedEntry: getSelectedEntry(state), + }; + }; + }, + function mapDispatchToProps(dispatch) { + return { + boundSetSelectedEntry: entry => dispatch(setSelectedEntry(entry)), + }; + }, + function mergeProps(stateProps, dispatchProps, ownProps) { + const { isShowTags, selectedEntry } = stateProps; + const { boundSetSelectedEntry } = dispatchProps; + const { + idx, + entry, + location: currentLocation = {}, + match: { + url: matchedUrl = '', + } = {}, + entry: { + name: entryName = '', + tags: entryTags = {}, + uid: entryUid = '', + category: entryCategory = '', + status: entryStatus = '', + counter: { + passed: nPass = 0, + failed: nFail = 0, + error: nError = 0, + } = {}, + }, + } = ownProps; + return { + entryName, + entryTags, + entryUid, + entryCategory, + entryStatus, + nPass, + isShowTags, + currentLocation, + nextPathname: joinURLComponent(matchedUrl, encodeURIComponent(entryName)), + nFailOrError: nFail + nError, + tabIndex: `${idx + 1}`, + isActive: () => ( + _.isObject(selectedEntry) && selectedEntry.uid === entryUid + ), + onClick: () => { + if(selectedEntry !== entry && _.isObject(entry)) { + boundSetSelectedEntry(entry); + } + }, + }; + } +); + +const NavSidebarEntry = ({ + entryName, entryTags, entryUid, entryCategory, entryStatus, nPass, onClick, + isShowTags, nFailOrError, tabIndex, nextPathname, currentLocation, isActive +}) => { + + const nextLocation = React.useMemo(() => { + if(entryCategory === BOTTOMMOST_ENTRY_CATEGORY) { + return currentLocation; + } else { + return { ...currentLocation, pathname: nextPathname }; + } + }, [ nextPathname, currentLocation, entryCategory ]); + + const MaybeTagList = React.useCallback(props => { + if(!isShowTags || !entryTags) { + return null; + } else { + return ( + + ); + } + }, [ entryTags, entryName, isShowTags ]); + + const NavLinkAsTag = React.useCallback(props => { + return (); + }, []); + + return ( + + + + + ); +}; + +export default connector(NavSidebarEntry); diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/components/NavSidebarWithNextRoute.jsx b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/NavSidebarWithNextRoute.jsx new file mode 100644 index 000000000..eb7456a4b --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/NavSidebarWithNextRoute.jsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Route } from 'react-router'; +import _ from 'lodash'; +import { BOTTOMMOST_ENTRY_CATEGORY } from '../../../Common/defaults'; +import NavSidebar from './NavSidebar'; + +const NavSidebarWithNextRoute = ({ entries, passedLast, match }) => { + + const routePath = _.isObject(match) ? match.url : null; + const abortRender = !_.isObject(match) || passedLast; + const isLast = ( + _.isObject(entries) && entries.category === BOTTOMMOST_ENTRY_CATEGORY + ); + + const tgtEntries = React.useMemo(() => { + let tgt = null; + if(!abortRender && _.isObject(entries)) { + tgt = entries; + if(_.isArray(entries) && _.isObject(match)) { + const decodedName = decodeURIComponent(match.params.id); + tgt = entries.find(e => decodedName === e.name); + } + } + return tgt !== null ? tgt.entries : null; + }, [ abortRender, entries, match ]); + + return abortRender ? null : ( + <> + ( + + )} + /> + ( + + )} + /> + + ); +}; + +export default NavSidebarWithNextRoute; diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/components/PrintButton.jsx b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/PrintButton.jsx new file mode 100644 index 000000000..e1a9191f7 --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/PrintButton.jsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { NavItem } from 'reactstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faPrint } from '@fortawesome/free-solid-svg-icons'; +import { library } from '@fortawesome/fontawesome-svg-core'; +import { TOOLBAR_BUTTON_CLASSES, BUTTONS_BAR_CLASSES } from '../styles'; + +library.add(faPrint); + +export default function PrintButton() { + return( + + + + ); +}; diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/components/TagsButton.jsx b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/TagsButton.jsx new file mode 100644 index 000000000..b4864eab7 --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/TagsButton.jsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { NavItem } from 'reactstrap'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { library } from '@fortawesome/fontawesome-svg-core'; +import { faTags } from '@fortawesome/free-solid-svg-icons'; +import { connect } from 'react-redux'; +import { TOOLBAR_BUTTON_CLASSES, BUTTONS_BAR_CLASSES } from '../styles'; +import { mkGetUIIsShowTags } from '../state/uiSelectors'; +import { setShowTags } from '../state/uiActions'; + +library.add(faTags); + +const connector = connect( + () => { + const getIsShowTags = mkGetUIIsShowTags(); + return function mapStateToProps(state) { + return { + isShowTags: getIsShowTags(state), + }; + }; + }, + function mapDispatchToProps(dispatch) { + return { + boundSetShowTags: isShow => dispatch(setShowTags(isShow)), + }; + }, + function mergeProps(stateProps, dispatchProps) { + const { isShowTags } = stateProps; + const { boundSetShowTags } = dispatchProps; + return { + onClick: evt => { + evt.stopPropagation(); + boundSetShowTags(!isShowTags); + }, + }; + }, +); + +const TagsButton = ({ onClick }) => ( + + + +); + +export default connector(TagsButton); diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/components/Toolbar.jsx b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/Toolbar.jsx new file mode 100644 index 000000000..1040f976a --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/Toolbar.jsx @@ -0,0 +1,14 @@ +import React from 'react'; +import InfoModal from './InfoModal'; +import HelpModal from './HelpModal'; +import TopNavbar from './TopNavbar'; + +export default function Toolbar({ children = null }) { + return( +
+ {children} + + +
+ ); +} diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/components/TopNavbar.jsx b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/TopNavbar.jsx new file mode 100644 index 000000000..d3492493d --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/components/TopNavbar.jsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { Navbar, Nav, Collapse } from 'reactstrap'; +import { connect } from 'react-redux'; +import { mkGetUIToolbarStyle } from '../state/uiSelectors'; +import { TOOLBAR_CLASSES, FILTER_BOX_CLASSES } from '../styles'; +import FilterBox from '../../../Toolbar/FilterBox'; +import InfoButton from './InfoButton'; +import FilterButton from './FilterButton'; +import PrintButton from './PrintButton'; +import TagsButton from './TagsButton'; +import HelpButton from './HelpButton'; +import DocumentationButton from './DocumentationButton'; + +const connector = connect( + () => { + const getToolbarStyle = mkGetUIToolbarStyle(); + return function mapStateToProps(state) { + return { + toolbarStyle: getToolbarStyle(state), + }; + }; + } +); + +const TopNavbar = ({ toolbarStyle, children = null }) => ( + +
+ +
+ + + +
+); + +export default connector(TopNavbar); diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/index.jsx b/testplan/web_ui/testing/src/Report/BatchReportBeta/index.jsx new file mode 100644 index 000000000..571c4a30e --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/index.jsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { connect, Provider } from 'react-redux'; +import { Router } from 'react-router'; +import CenterPane from './components/CenterPane'; +import Toolbar from './components/Toolbar'; +import uiHistory from './state/uiHistory'; +import NavPanes from './components/NavPanes'; +import { BATCH_REPORT_CLASSES } from './styles'; +import { fetchReport } from './state/reportActions'; +import store from './state/store'; + +const __DEV__ = process.env.NODE_ENV !== 'production'; + +const connector = connect( + null, + function mapDispatchToProps(dispatch) { + return { + boundFetchReport: arg => dispatch(fetchReport(arg)), + }; + }, +); + +const BatchReportInner = ({ children = null, ...props }) => { + const { boundFetchReport, uid, axios, testReport } = props; + React.useEffect(() => { + const arg = { axios }; + if(testReport && __DEV__) arg.testReport = testReport; + else arg.uid = uid; + // this `.abort()` function will be called when this component unmounts, + // thus cancelling any outstanding requests + return boundFetchReport(arg).abort; + }, [ testReport, uid, boundFetchReport, axios ]); + return ( +
+ + + + {children} +
+ ); +}; + +const ConnectedBatchReportInner = connector(BatchReportInner); + +export default function BatchReport({ match, axios, testReport, ...props }) { + return ( + + + + + + ); +} diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/state/reportActions/fetchReport.js b/testplan/web_ui/testing/src/Report/BatchReportBeta/state/reportActions/fetchReport.js new file mode 100644 index 000000000..0ebde93f2 --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/state/reportActions/fetchReport.js @@ -0,0 +1,133 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import Axios from 'axios'; +import { headers as defaultHeaders } from 'axios/lib/defaults'; +import _ from 'lodash'; +import { + getReportUid, + getReportIsFetching, + getReportDocument, + getReportLastFetchError, + getReportIsFetchCancelled, +} from '../reportSelectors'; +import { PropagateIndices } from '../../../reportUtils'; +import { toPlainObjectIn, flattened } from '../../../../Common/utils'; + +const __DEV__ = process.env.NODE_ENV !== 'production'; + +let TEST_REPORTS = {}; // eslint-disable-line no-unused-vars +if(__DEV__) { + TEST_REPORTS = { + fakeReport: require('../../../../Common/fakeReport'), + sampleReports: require('../../../../Common/sampleReports'), + }; +} + +const axiosDefaultConfig = (() => { + const baseURL = 'http://testplan-db.ms.com/api/v1/reports'; + const baseURLOrigin = new URL(baseURL).origin; + const headers = { + ...defaultHeaders.common, + Accept: 'application/json', + }; + if(__DEV__ && window.location.origin !== baseURLOrigin) { + // CORS headers: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS + headers['Access-Control-Allow-Origin'] = baseURLOrigin; + } + return { baseURL, headers, timeout: 60_000 }; +})(); + +const fetchFakeReport = async ({ testReport }, { dispatch, requestId }) => { + const { setReportUID } = await import('./'); + dispatch(setReportUID(testReport)); + const data = await _.get(TEST_REPORTS, testReport, requestId); + if(data === requestId) throw new Error(flattened` + Invalid object path "${testReport}", valid paths are + `); + return { data, headers: { 'content-length': JSON.stringify(data).length } }; +}; + +const fetchRemoteReport = async ({ axios: mockAxios, uid }, thunkAPI) => { + const { dispatch, signal } = thunkAPI; + const { setReportUID } = await import('./'); + // setup fetch + dispatch(setReportUID(uid)); + let axiosInstance, cancelFunc, cancelToken, abortListenerID = -1; + if(_.isObject(mockAxios)) { + axiosInstance = mockAxios; + if(_.isObject(axiosInstance.defaults.cancelToken)) { + cancelToken = axiosInstance.defaults.cancelToken.token; + } + } else { + const cancelSource = Axios.CancelToken.source(); + cancelFunc = cancelSource.cancel; + cancelToken = cancelSource.token; + axiosInstance = Axios.create({ ...axiosDefaultConfig, cancelToken }); + } + if(_.isFunction(cancelFunc)) signal.addEventListener('abort', () => { + cancelFunc('The fetch was cancelled.'); + }); + if(_.isObject(cancelToken)) abortListenerID = setInterval(() => { + cancelToken.throwIfRequested(); + }, 10); + // execute fetch, see https://github.com/axios/axios#response-schema + return await axiosInstance.get(`/${uid}`).finally(() => { + if(abortListenerID > 0) clearInterval(abortListenerID); + }); +}; + +/** This function keeps duplicate fetches from occurring */ +const checkShouldAcceptFetchRequest = ({ uid }, { getState }) => { + if(uid !== getReportUid(getState())) return true; + if(getReportIsFetching(getState())) return false; + if(_.isObject(getReportDocument(getState()))) return ( + !_.isNil(getReportLastFetchError(getState())) || + getReportIsFetchCancelled(getState()) + ); +}; + +/** + * During testing / development it's possible to pass in the path to one + * of the promises in the following object and return it instead of actually + * doing a fetch + * @example + fetchReport({ testReport: 'fakeReport.TESTPLAN_REPORT' }) + + * @param {object} arg + * @param {string} arg.uid + * @param {object=} arg.axios + * @param {object=} arg.testReport + * @returns {{ abort(): void }} + */ +const fetchReport = createAsyncThunk( + 'report/fetchReport', + async (arg, thunkAPI) => { + const { dispatch, rejectWithValue, requestId } = thunkAPI; + try { + if(!_.isPlainObject(arg)) + throw new class extends Error { name = 'FetchReportArgError'; }( + __DEV__ ? '`fetchReport` takes a single plain object argument' + : 'Contact support' + ); + const { setLastResponseContentLength } = await import('./'); + const { headers, data } = await ( + __DEV__ && _.isString(arg.testReport) + ? fetchFakeReport(arg, { dispatch, requestId }) + : fetchRemoteReport(arg, thunkAPI) + ); + dispatch(setLastResponseContentLength(headers['content-length'])); + return PropagateIndices(data); + } catch(err) { + return rejectWithValue( + Axios.isCancel(err) + ? _.toPlainObject(err) + : toPlainObjectIn(err) + ); + } + }, + { + condition: checkShouldAcceptFetchRequest, + dispatchConditionRejection: false, + } +); + +export default fetchReport; diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/state/reportActions/index.js b/testplan/web_ui/testing/src/Report/BatchReportBeta/state/reportActions/index.js new file mode 100644 index 000000000..febe008b1 --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/state/reportActions/index.js @@ -0,0 +1,8 @@ +import reportSlice from '../reportSlice'; + +export const { + setLastResponseContentLength, + setReportUID, +} = reportSlice.actions; + +export { default as fetchReport } from './fetchReport'; diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/state/reportSelectors.js b/testplan/web_ui/testing/src/Report/BatchReportBeta/state/reportSelectors.js new file mode 100644 index 000000000..7ddef248c --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/state/reportSelectors.js @@ -0,0 +1,17 @@ +/* eslint-disable max-len */ +import { createSelector } from '@reduxjs/toolkit'; +import _ from 'lodash'; + +export const createReportSelector = (...funcs) => _.spread(createSelector)([ state => state.report, ...funcs ]); +export const mkGetLastResponseContentLength = () => createReportSelector(({ lastResponseContentLength }) => lastResponseContentLength); +export const mkGetReportUid = () => createReportSelector(({ uid }) => uid); +export const mkGetReportDocument = () => createReportSelector(({ document }) => document); +export const mkGetReportIsFetching = () => createReportSelector(({ isFetching }) => isFetching); +export const mkGetReportIsFetchCancelled = () => createReportSelector(({ isFetchCancelled }) => isFetchCancelled); +export const mkGetReportLastFetchError = () => createReportSelector(({ fetchError }) => fetchError); +export const getLastResponseContentLength = mkGetLastResponseContentLength(); +export const getReportUid = mkGetReportUid(); +export const getReportDocument = mkGetReportDocument(); +export const getReportIsFetching = mkGetReportIsFetching(); +export const getReportIsFetchCancelled = mkGetReportIsFetchCancelled(); +export const getReportLastFetchError = mkGetReportLastFetchError(); diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/state/reportSlice.js b/testplan/web_ui/testing/src/Report/BatchReportBeta/state/reportSlice.js new file mode 100644 index 000000000..f12694178 --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/state/reportSlice.js @@ -0,0 +1,57 @@ +import _ from 'lodash'; +import { createSlice } from '@reduxjs/toolkit'; +import fetchReport from './reportActions/fetchReport'; +import Axios from 'axios'; + +export default createSlice({ + name: 'report', + initialState: { + uid: null, + document: null, + isFetching: false, + isFetchCancelled: false, + fetchError: null, + lastResponseContentLength: null, + }, + reducers: { + setReportUID: { + reducer(state, { payload }) { state.uid = payload; }, + prepare: (uid = null) => ({ payload: uid }), + }, + setLastResponseContentLength: { + reducer(state, { payload }) { + state.lastResponseContentLength = payload; + }, + prepare: (contentLength = 0) => ({ + payload: _.isString(contentLength) + ? parseInt(contentLength) + : contentLength, + }), + }, + }, + extraReducers: { + [fetchReport.pending.type](state) { + state.isFetching = true; + state.isFetchCancelled = false; + state.fetchError = null; + }, + [fetchReport.fulfilled.type](state, action) { + state.isFetching = false; + state.isFetchCancelled = false; + state.fetchError = null; + state.document = action.payload; + }, + [fetchReport.rejected.type](state, action) { + state.isFetching = false; + if(_.isObject(action.error) && action.error.message === 'Rejected') { + // handled with rejectWithValue + const { payload: rejectValue } = action; + state.isFetchCancelled = Axios.isCancel(rejectValue); + state.fetchError = rejectValue; + } else { + state.isFetchCancelled = false; + state.fetchError = action.error; + } + }, + }, +}); diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/state/store.js b/testplan/web_ui/testing/src/Report/BatchReportBeta/state/store.js new file mode 100644 index 000000000..6785b757e --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/state/store.js @@ -0,0 +1,124 @@ +import { + configureStore, + combineReducers, + getDefaultMiddleware, +} from '@reduxjs/toolkit'; +import { setUseProxies } from 'immer'; +import { fetchReport } from './reportActions'; +import { getLastResponseContentLength } from './reportSelectors'; +import { setSelectedEntry } from './uiActions'; +import { flattened } from '../../../Common/utils'; + +const __DEV__ = process.env.NODE_ENV !== 'production'; + +// At @reduxjs/toolkit/dist/redux-toolkit.esm.js:1422 (@reduxjs/toolkit@1.3.4) +// ES5 compatibility is enabled, which switches off Proxy support, which is bad: +// https://immerjs.github.io/immer/docs/performance#:~:text=The%20ES5%20fallback%20implementation +setUseProxies(true); + +const createReducer = () => combineReducers({ + // eslint-disable-next-line max-len + report: require('./reportSlice').default.reducer, + ui: require('./uiSlice').default.reducer, +}); + +let devTools = false; +const middlewareOptions = { + serializableCheck: false, + immutableCheck: false, +}; + +if(__DEV__) { + const FIX_DEVTOOL_SPEED = process.env.REACT_APP_FIX_DEVTOOL_SPEED === 'true'; + // eslint-disable-next-line max-len + const DISABLE_REDUX_DEVTOOLS = process.env.REACT_APP_DISABLE_REDUX_DEVTOOLS === 'true'; + let shaveObjectEntriesFromObject = null; + if(__DEV__ && FIX_DEVTOOL_SPEED && !DISABLE_REDUX_DEVTOOLS) { + console.warn(flattened` + You've set the environment variable REACT_APP_FIX_DEVTOOL_SPEED=true + which will disable several features in Redux Devtools. + `); + const _ = require('lodash'); + shaveObjectEntriesFromObject = (entry, placeholder = '<>') => ( + !_.isObjectLike(entry) ? entry : Object.fromEntries( + Object.entries(entry).map(([ prop, val ]) => [ + prop, _.isObjectLike(val) + ? _.isFunction(placeholder) ? placeholder(prop, val) : placeholder + : val + ]) + )); + } + + devTools = !DISABLE_REDUX_DEVTOOLS && (!FIX_DEVTOOL_SPEED ? true : { + name: 'testplan', + maxAge: 10, + trace: true, + traceLimit: 3, + shouldCatchErrors: true, + shouldRecordChanges: true, + shouldHotReload: true, + actionSanitizer: action => { + switch(action.type) { + case fetchReport.fulfilled.type: + return { ...action, payload: '<>' }; + case setSelectedEntry.type: + return { ...action, payload: shaveObjectEntriesFromObject + ? shaveObjectEntriesFromObject(action.payload) + : action.payload + }; + default: + return action; + } + }, + stateSanitizer: state => { + // devtools crashes and the UI freezes at 45_864_416 so this number can + // likely be tuned up or down (right now it's just a guess) + if(35_000_000 < getLastResponseContentLength(state)) { + // these slow down the extension - and hence the UI - a lot + window.__CURRENT_REPORT = state.report.document; + window.__SELECTED_ENTRY = state.ui.selectedEntry; + return { + ...state, + report: { + ...state.report, + document: '<>', + }, + ui: { + ...state.ui, + selectedEntry: shaveObjectEntriesFromObject + ? shaveObjectEntriesFromObject( + state.ui.selectedEntry, + key => `<>` + ) + : state.ui.selectedEntry, + }, + }; + } + return state; + }, + }); + + middlewareOptions.serializableCheck = !FIX_DEVTOOL_SPEED ? true : ({ + ignoredPaths: [ 'report.document', 'ui.selectedEntry' ], + ignoredActions: [ + fetchReport.fulfilled.type, + setSelectedEntry.type, + ], + }); + + middlewareOptions.immutableCheck = !FIX_DEVTOOL_SPEED ? true : ({ + ignoredPaths: [ 'report.document', 'ui.selectedEntry' ], + }); +} + +const store = configureStore({ + reducer: createReducer(), + middleware: getDefaultMiddleware(middlewareOptions), + devTools, +}); + +if(__DEV__ && module && module.hot) { + module.hot.accept(() => store.replaceReducer(createReducer())); +} + +export default store; diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/state/uiActions.js b/testplan/web_ui/testing/src/Report/BatchReportBeta/state/uiActions.js new file mode 100644 index 000000000..848d7a0b5 --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/state/uiActions.js @@ -0,0 +1,11 @@ +import uiSlice from './uiSlice'; + +export const { + setShowTags, + setShowInfoModal, + setFilter, + setDisplayEmpty, + setShowHelpModal, + setSelectedEntry, + setSidebarWidth, +} = uiSlice.actions; diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/state/uiHistory.js b/testplan/web_ui/testing/src/Report/BatchReportBeta/state/uiHistory.js new file mode 100644 index 000000000..d012603a7 --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/state/uiHistory.js @@ -0,0 +1,3 @@ +import { createHashHistory } from 'history'; + +export default createHashHistory({ basename: '/' }); diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/state/uiSelectors.js b/testplan/web_ui/testing/src/Report/BatchReportBeta/state/uiSelectors.js new file mode 100644 index 000000000..e6003f1ea --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/state/uiSelectors.js @@ -0,0 +1,46 @@ +/* eslint-disable max-len */ +import { createSelector } from '@reduxjs/toolkit'; +import { css } from 'aphrodite'; +import _ from 'lodash'; +import { mkGetReportDocument } from './reportSelectors'; +import navStyles from '../../../Toolbar/navStyles'; +import { STATUS_CATEGORY } from '../../../Common/defaults'; +import { COLUMN_WIDTH } from '../../../Common/defaults'; + +const STATUS_CATEGORY_STYLE_MAP = { + passed: navStyles.toolbarPassed, + failed: navStyles.toolbarFailed, + error: navStyles.toolbarFailed, + unstable: navStyles.toolbarUnstable, +}; + +export const createUISelector = (...funcs) => _.spread(createSelector)([ state => state.ui, ...funcs ]); +export const mkGetUIIsShowHelpModal = () => createUISelector(({ isShowHelpModal }) => isShowHelpModal); +export const mkGetUIIsDisplayEmpty = () => createUISelector(({ isDisplayEmpty }) => isDisplayEmpty); +export const mkGetUIFilter = () => createUISelector(({ filter }) => filter); +export const mkGetUIIsShowTags = () => createUISelector(({ isShowTags }) => isShowTags); +export const mkGetUIIsShowInfoModal = () => createUISelector(({ isShowInfoModal }) => isShowInfoModal); +export const mkGetUISelectedEntry = () => createUISelector(({ selectedEntry }) => selectedEntry); + +export const mkGetUIToolbarStyle = () => createSelector( + mkGetReportDocument(), + document => { + let statusStyle = navStyles.toolbarUnknown; + if(_.isObject(document) && document.status) { + const statcat = STATUS_CATEGORY[document.status]; + if(statcat in STATUS_CATEGORY_STYLE_MAP) { + statusStyle = STATUS_CATEGORY_STYLE_MAP[statcat]; + } + } + return css(navStyles.toolbar, statusStyle); + }, +); + + +export const mkGetUISidebarWidthFirstAvailable = () => createUISelector( + uiState => ( + uiState.sidebarWidthPx || uiState.sidebarWidthEm || `${COLUMN_WIDTH}em` + ), +); + +export const getUISelectedEntry = mkGetUISelectedEntry(); diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/state/uiSlice.js b/testplan/web_ui/testing/src/Report/BatchReportBeta/state/uiSlice.js new file mode 100644 index 000000000..da4aedf3b --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/state/uiSlice.js @@ -0,0 +1,58 @@ +import { createSlice } from '@reduxjs/toolkit/dist'; +import * as filterStates from '../../../Common/filterStates'; +import { fetchReport } from './reportActions'; +import { COLUMN_WIDTH } from '../../../Common/defaults'; + +const FILTER_STATES_ARR = Object.values(filterStates); + +/** This state slice contains information specific to how the UI should look */ +export default createSlice({ + name: 'ui', + initialState: { + isShowHelpModal: false, + isDisplayEmpty: true, + filter: filterStates.ALL, + isShowTags: false, + isShowInfoModal: false, + selectedEntry: null, + sidebarWidthPx: null, + sidebarWidthEm: `${COLUMN_WIDTH}em`, + }, + reducers: { + setSelectedEntry: { + reducer(state, action) { state.selectedEntry = action.payload; }, + prepare: (entry = null) => ({ payload: entry }), + }, + setSidebarWidth: { + reducer(state, { payload }) { state.sidebarWidthPx = payload; }, + prepare: (widthPxStr) => ({ payload: widthPxStr }), + }, + setShowTags: { + reducer(state, { payload }) { state.isShowTags = payload; }, + prepare: (showTags = false) => ({ payload: !!showTags }), + }, + setShowInfoModal: { + reducer(state, { payload }) { state.isShowInfoModal = payload; }, + prepare: (showInfoModal = false) => ({ payload: !!showInfoModal }), + }, + setFilter: { + reducer(state, { payload }) { state.filter = payload; }, + prepare: (filter = filterStates.ALL) => ({ + payload: FILTER_STATES_ARR.includes(filter) ? filter : filterStates.ALL + }), + }, + setDisplayEmpty: { + reducer(state, { payload }) { state.isDisplayEmpty = payload; }, + prepare: (displayEmpty = true) => ({ payload: !!displayEmpty }), + }, + setShowHelpModal: { + reducer(state, { payload }) { state.isShowHelpModal = payload; }, + prepare: (showHelpModal = false) => ({ payload: !!showHelpModal }), + }, + }, + extraReducers: { + [fetchReport.fulfilled.type](state, action) { + state.selectedEntry = action.payload; + }, + }, +}); diff --git a/testplan/web_ui/testing/src/Report/BatchReportBeta/styles.js b/testplan/web_ui/testing/src/Report/BatchReportBeta/styles.js new file mode 100644 index 000000000..de16d4cab --- /dev/null +++ b/testplan/web_ui/testing/src/Report/BatchReportBeta/styles.js @@ -0,0 +1,36 @@ +import { StyleSheet, css } from 'aphrodite'; +import navStyles from '../../Toolbar/navStyles'; +import { styles as navUtilsStyles } from '../../Nav/navUtils'; + +export { default as CommonStyles } from '../../Common/Styles'; +export { styles as navBreadcrumbStyles } from '../../Nav/NavBreadcrumbs'; +export { navUtilsStyles }; +export { COLUMN_WIDTH } from '../../Common/defaults'; + +export const navListStyles = StyleSheet.create({ + buttonList: { + 'overflow-y': 'auto', + 'height': '100%', + } +}); + +export const batchReportStyles = StyleSheet.create({ + batchReport: { + /** overflow will hide dropdown div */ + // overflow: 'hidden' + } +}); + +export const BATCH_REPORT_CLASSES = css(batchReportStyles.batchReport); +export const TOOLBAR_CLASSES = css(navStyles.toolbar); +export const TOOLBAR_BUTTON_CLASSES = css(navStyles.toolbarButton); +export const BUTTONS_BAR_CLASSES = css(navStyles.buttonsBar); +export const FILTER_BOX_CLASSES = css(navStyles.filterBox); +export const FILTER_DROPDOWN_CLASSES = css(navStyles.filterDropdown); +export const FILTER_LABEL_CLASSES = css(navStyles.filterLabel); +export const DROPDOWN_ITEM_CLASSES = css(navStyles.dropdownItem); +export const ACTIVE_LINK_CLASSES = css(navUtilsStyles.navButtonInteractFocus); +export const UNDECORATED_LINK_STYLE = { + textDecoration: 'none', + color: 'currentColor', +}; diff --git a/testplan/web_ui/testing/src/Report/__tests__/__snapshots__/BatchReport.test.js.snap b/testplan/web_ui/testing/src/Report/__tests__/__snapshots__/BatchReport.test.js.snap index 9e3087ca4..2436aab08 100644 --- a/testplan/web_ui/testing/src/Report/__tests__/__snapshots__/BatchReport.test.js.snap +++ b/testplan/web_ui/testing/src/Report/__tests__/__snapshots__/BatchReport.test.js.snap @@ -47,12 +47,12 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "test_equality_passing", - "name_type_index": Set { + "name_type_index": Array [ "test_equality_passing|testcase", "AlphaSuite|testsuite", "Primary|multitest", "Sample Testplan|testplan", - }, + ], "status": "passed", "status_override": null, "tags": Object { @@ -100,12 +100,12 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "test_equality_passing2", - "name_type_index": Set { + "name_type_index": Array [ "test_equality_passing2|testcase", "AlphaSuite|testsuite", "Primary|multitest", "Sample Testplan|testplan", - }, + ], "status": "failed", "status_override": null, "tags": Object { @@ -130,13 +130,13 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "AlphaSuite", - "name_type_index": Set { + "name_type_index": Array [ "AlphaSuite|testsuite", "Primary|multitest", "Sample Testplan|testplan", "test_equality_passing|testcase", "test_equality_passing2|testcase", - }, + ], "status": "failed", "status_override": null, "tags": Object { @@ -185,12 +185,12 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "test_equality_passing", - "name_type_index": Set { + "name_type_index": Array [ "test_equality_passing|testcase", "BetaSuite|testsuite", "Primary|multitest", "Sample Testplan|testplan", - }, + ], "status": "passed", "status_override": null, "tags": Object { @@ -217,12 +217,12 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "BetaSuite", - "name_type_index": Set { + "name_type_index": Array [ "BetaSuite|testsuite", "Primary|multitest", "Sample Testplan|testplan", "test_equality_passing|testcase", - }, + ], "status": "passed", "status_override": null, "tags": Object { @@ -249,14 +249,14 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "Primary", - "name_type_index": Set { + "name_type_index": Array [ "Primary|multitest", "Sample Testplan|testplan", "AlphaSuite|testsuite", "test_equality_passing|testcase", "test_equality_passing2|testcase", "BetaSuite|testsuite", - }, + ], "status": "failed", "status_override": null, "tags": Object { @@ -310,12 +310,12 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "test_equality_passing", - "name_type_index": Set { + "name_type_index": Array [ "test_equality_passing|testcase", "GammaSuite|testsuite", "Secondary|multitest", "Sample Testplan|testplan", - }, + ], "status": "passed", "status_override": null, "tags": Object {}, @@ -332,12 +332,12 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "GammaSuite", - "name_type_index": Set { + "name_type_index": Array [ "GammaSuite|testsuite", "Secondary|multitest", "Sample Testplan|testplan", "test_equality_passing|testcase", - }, + ], "status": "passed", "status_override": null, "tags": Object {}, @@ -354,12 +354,12 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "Secondary", - "name_type_index": Set { + "name_type_index": Array [ "Secondary|multitest", "Sample Testplan|testplan", "GammaSuite|testsuite", "test_equality_passing|testcase", - }, + ], "status": "passed", "status_override": null, "tags": Object {}, @@ -376,7 +376,7 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "meta": Object {}, "name": "Sample Testplan", - "name_type_index": Set { + "name_type_index": Array [ "Sample Testplan|testplan", "Primary|multitest", "AlphaSuite|testsuite", @@ -385,7 +385,7 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` "BetaSuite|testsuite", "Secondary|multitest", "GammaSuite|testsuite", - }, + ], "status": "failed", "status_override": null, "tags_index": Object { @@ -451,12 +451,12 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "test_equality_passing", - "name_type_index": Set { + "name_type_index": Array [ "test_equality_passing|testcase", "AlphaSuite|testsuite", "Primary|multitest", "Sample Testplan|testplan", - }, + ], "status": "passed", "status_override": null, "tags": Object { @@ -504,12 +504,12 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "test_equality_passing2", - "name_type_index": Set { + "name_type_index": Array [ "test_equality_passing2|testcase", "AlphaSuite|testsuite", "Primary|multitest", "Sample Testplan|testplan", - }, + ], "status": "failed", "status_override": null, "tags": Object { @@ -534,13 +534,13 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "AlphaSuite", - "name_type_index": Set { + "name_type_index": Array [ "AlphaSuite|testsuite", "Primary|multitest", "Sample Testplan|testplan", "test_equality_passing|testcase", "test_equality_passing2|testcase", - }, + ], "status": "failed", "status_override": null, "tags": Object { @@ -589,12 +589,12 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "test_equality_passing", - "name_type_index": Set { + "name_type_index": Array [ "test_equality_passing|testcase", "BetaSuite|testsuite", "Primary|multitest", "Sample Testplan|testplan", - }, + ], "status": "passed", "status_override": null, "tags": Object { @@ -621,12 +621,12 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "BetaSuite", - "name_type_index": Set { + "name_type_index": Array [ "BetaSuite|testsuite", "Primary|multitest", "Sample Testplan|testplan", "test_equality_passing|testcase", - }, + ], "status": "passed", "status_override": null, "tags": Object { @@ -653,14 +653,14 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "Primary", - "name_type_index": Set { + "name_type_index": Array [ "Primary|multitest", "Sample Testplan|testplan", "AlphaSuite|testsuite", "test_equality_passing|testcase", "test_equality_passing2|testcase", "BetaSuite|testsuite", - }, + ], "status": "failed", "status_override": null, "tags": Object { @@ -714,12 +714,12 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "test_equality_passing", - "name_type_index": Set { + "name_type_index": Array [ "test_equality_passing|testcase", "GammaSuite|testsuite", "Secondary|multitest", "Sample Testplan|testplan", - }, + ], "status": "passed", "status_override": null, "tags": Object {}, @@ -736,12 +736,12 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "GammaSuite", - "name_type_index": Set { + "name_type_index": Array [ "GammaSuite|testsuite", "Secondary|multitest", "Sample Testplan|testplan", "test_equality_passing|testcase", - }, + ], "status": "passed", "status_override": null, "tags": Object {}, @@ -758,12 +758,12 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "Secondary", - "name_type_index": Set { + "name_type_index": Array [ "Secondary|multitest", "Sample Testplan|testplan", "GammaSuite|testsuite", "test_equality_passing|testcase", - }, + ], "status": "passed", "status_override": null, "tags": Object {}, @@ -780,7 +780,7 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "meta": Object {}, "name": "Sample Testplan", - "name_type_index": Set { + "name_type_index": Array [ "Sample Testplan|testplan", "Primary|multitest", "AlphaSuite|testsuite", @@ -789,7 +789,7 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` "BetaSuite|testsuite", "Secondary|multitest", "GammaSuite|testsuite", - }, + ], "status": "failed", "status_override": null, "tags_index": Object { @@ -843,12 +843,12 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "test_equality_passing", - "name_type_index": Set { + "name_type_index": Array [ "test_equality_passing|testcase", "AlphaSuite|testsuite", "Primary|multitest", "Sample Testplan|testplan", - }, + ], "status": "passed", "status_override": null, "tags": Object { @@ -896,12 +896,12 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "test_equality_passing2", - "name_type_index": Set { + "name_type_index": Array [ "test_equality_passing2|testcase", "AlphaSuite|testsuite", "Primary|multitest", "Sample Testplan|testplan", - }, + ], "status": "failed", "status_override": null, "tags": Object { @@ -926,13 +926,13 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "AlphaSuite", - "name_type_index": Set { + "name_type_index": Array [ "AlphaSuite|testsuite", "Primary|multitest", "Sample Testplan|testplan", "test_equality_passing|testcase", "test_equality_passing2|testcase", - }, + ], "status": "failed", "status_override": null, "tags": Object { @@ -981,12 +981,12 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "test_equality_passing", - "name_type_index": Set { + "name_type_index": Array [ "test_equality_passing|testcase", "BetaSuite|testsuite", "Primary|multitest", "Sample Testplan|testplan", - }, + ], "status": "passed", "status_override": null, "tags": Object { @@ -1013,12 +1013,12 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "BetaSuite", - "name_type_index": Set { + "name_type_index": Array [ "BetaSuite|testsuite", "Primary|multitest", "Sample Testplan|testplan", "test_equality_passing|testcase", - }, + ], "status": "passed", "status_override": null, "tags": Object { @@ -1045,14 +1045,14 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "Primary", - "name_type_index": Set { + "name_type_index": Array [ "Primary|multitest", "Sample Testplan|testplan", "AlphaSuite|testsuite", "test_equality_passing|testcase", "test_equality_passing2|testcase", "BetaSuite|testsuite", - }, + ], "status": "failed", "status_override": null, "tags": Object { @@ -1106,12 +1106,12 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "test_equality_passing", - "name_type_index": Set { + "name_type_index": Array [ "test_equality_passing|testcase", "GammaSuite|testsuite", "Secondary|multitest", "Sample Testplan|testplan", - }, + ], "status": "passed", "status_override": null, "tags": Object {}, @@ -1128,12 +1128,12 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "GammaSuite", - "name_type_index": Set { + "name_type_index": Array [ "GammaSuite|testsuite", "Secondary|multitest", "Sample Testplan|testplan", "test_equality_passing|testcase", - }, + ], "status": "passed", "status_override": null, "tags": Object {}, @@ -1150,12 +1150,12 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "logs": Array [], "name": "Secondary", - "name_type_index": Set { + "name_type_index": Array [ "Secondary|multitest", "Sample Testplan|testplan", "GammaSuite|testsuite", "test_equality_passing|testcase", - }, + ], "status": "passed", "status_override": null, "tags": Object {}, @@ -1172,7 +1172,7 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` ], "meta": Object {}, "name": "Sample Testplan", - "name_type_index": Set { + "name_type_index": Array [ "Sample Testplan|testplan", "Primary|multitest", "AlphaSuite|testsuite", @@ -1181,7 +1181,7 @@ exports[`BatchReport loads a more complex report and autoselects entries 1`] = ` "BetaSuite|testsuite", "Secondary|multitest", "GammaSuite|testsuite", - }, + ], "status": "failed", "status_override": null, "tags_index": Object { @@ -1258,12 +1258,12 @@ exports[`BatchReport loads a simple report and autoselects entries 1`] = ` ], "logs": Array [], "name": "test_equality_passing", - "name_type_index": Set { + "name_type_index": Array [ "test_equality_passing|testcase", "AlphaSuite|testsuite", "Primary|multitest", "Sample Testplan|testplan", - }, + ], "status": "passed", "status_override": null, "tags": Object { @@ -1294,12 +1294,12 @@ exports[`BatchReport loads a simple report and autoselects entries 1`] = ` ], "logs": Array [], "name": "AlphaSuite", - "name_type_index": Set { + "name_type_index": Array [ "AlphaSuite|testsuite", "Primary|multitest", "Sample Testplan|testplan", "test_equality_passing|testcase", - }, + ], "status": "failed", "status_override": null, "tags": Object { @@ -1327,12 +1327,12 @@ exports[`BatchReport loads a simple report and autoselects entries 1`] = ` ], "logs": Array [], "name": "Primary", - "name_type_index": Set { + "name_type_index": Array [ "Primary|multitest", "Sample Testplan|testplan", "AlphaSuite|testsuite", "test_equality_passing|testcase", - }, + ], "status": "failed", "status_override": null, "tags": Object { @@ -1360,12 +1360,12 @@ exports[`BatchReport loads a simple report and autoselects entries 1`] = ` ], "meta": Object {}, "name": "Sample Testplan", - "name_type_index": Set { + "name_type_index": Array [ "Sample Testplan|testplan", "Primary|multitest", "AlphaSuite|testsuite", "test_equality_passing|testcase", - }, + ], "status": "failed", "status_override": null, "tags_index": Object { @@ -1430,12 +1430,12 @@ exports[`BatchReport loads a simple report and autoselects entries 1`] = ` ], "logs": Array [], "name": "test_equality_passing", - "name_type_index": Set { + "name_type_index": Array [ "test_equality_passing|testcase", "AlphaSuite|testsuite", "Primary|multitest", "Sample Testplan|testplan", - }, + ], "status": "passed", "status_override": null, "tags": Object { @@ -1466,12 +1466,12 @@ exports[`BatchReport loads a simple report and autoselects entries 1`] = ` ], "logs": Array [], "name": "AlphaSuite", - "name_type_index": Set { + "name_type_index": Array [ "AlphaSuite|testsuite", "Primary|multitest", "Sample Testplan|testplan", "test_equality_passing|testcase", - }, + ], "status": "failed", "status_override": null, "tags": Object { @@ -1499,12 +1499,12 @@ exports[`BatchReport loads a simple report and autoselects entries 1`] = ` ], "logs": Array [], "name": "Primary", - "name_type_index": Set { + "name_type_index": Array [ "Primary|multitest", "Sample Testplan|testplan", "AlphaSuite|testsuite", "test_equality_passing|testcase", - }, + ], "status": "failed", "status_override": null, "tags": Object { @@ -1532,12 +1532,12 @@ exports[`BatchReport loads a simple report and autoselects entries 1`] = ` ], "meta": Object {}, "name": "Sample Testplan", - "name_type_index": Set { + "name_type_index": Array [ "Sample Testplan|testplan", "Primary|multitest", "AlphaSuite|testsuite", "test_equality_passing|testcase", - }, + ], "status": "failed", "status_override": null, "tags_index": Object { @@ -1590,12 +1590,12 @@ exports[`BatchReport loads a simple report and autoselects entries 1`] = ` ], "logs": Array [], "name": "test_equality_passing", - "name_type_index": Set { + "name_type_index": Array [ "test_equality_passing|testcase", "AlphaSuite|testsuite", "Primary|multitest", "Sample Testplan|testplan", - }, + ], "status": "passed", "status_override": null, "tags": Object { @@ -1626,12 +1626,12 @@ exports[`BatchReport loads a simple report and autoselects entries 1`] = ` ], "logs": Array [], "name": "AlphaSuite", - "name_type_index": Set { + "name_type_index": Array [ "AlphaSuite|testsuite", "Primary|multitest", "Sample Testplan|testplan", "test_equality_passing|testcase", - }, + ], "status": "failed", "status_override": null, "tags": Object { @@ -1659,12 +1659,12 @@ exports[`BatchReport loads a simple report and autoselects entries 1`] = ` ], "logs": Array [], "name": "Primary", - "name_type_index": Set { + "name_type_index": Array [ "Primary|multitest", "Sample Testplan|testplan", "AlphaSuite|testsuite", "test_equality_passing|testcase", - }, + ], "status": "failed", "status_override": null, "tags": Object { @@ -1692,12 +1692,12 @@ exports[`BatchReport loads a simple report and autoselects entries 1`] = ` ], "meta": Object {}, "name": "Sample Testplan", - "name_type_index": Set { + "name_type_index": Array [ "Sample Testplan|testplan", "Primary|multitest", "AlphaSuite|testsuite", "test_equality_passing|testcase", - }, + ], "status": "failed", "status_override": null, "tags_index": Object { @@ -1744,12 +1744,12 @@ exports[`BatchReport loads a simple report and autoselects entries 1`] = ` ], "logs": Array [], "name": "test_equality_passing", - "name_type_index": Set { + "name_type_index": Array [ "test_equality_passing|testcase", "AlphaSuite|testsuite", "Primary|multitest", "Sample Testplan|testplan", - }, + ], "status": "passed", "status_override": null, "tags": Object { @@ -1780,12 +1780,12 @@ exports[`BatchReport loads a simple report and autoselects entries 1`] = ` ], "logs": Array [], "name": "AlphaSuite", - "name_type_index": Set { + "name_type_index": Array [ "AlphaSuite|testsuite", "Primary|multitest", "Sample Testplan|testplan", "test_equality_passing|testcase", - }, + ], "status": "failed", "status_override": null, "tags": Object { @@ -1813,12 +1813,12 @@ exports[`BatchReport loads a simple report and autoselects entries 1`] = ` ], "logs": Array [], "name": "Primary", - "name_type_index": Set { + "name_type_index": Array [ "Primary|multitest", "Sample Testplan|testplan", "AlphaSuite|testsuite", "test_equality_passing|testcase", - }, + ], "status": "failed", "status_override": null, "tags": Object { @@ -1867,12 +1867,12 @@ exports[`BatchReport loads a simple report and autoselects entries 1`] = ` ], "logs": Array [], "name": "test_equality_passing", - "name_type_index": Set { + "name_type_index": Array [ "test_equality_passing|testcase", "AlphaSuite|testsuite", "Primary|multitest", "Sample Testplan|testplan", - }, + ], "status": "passed", "status_override": null, "tags": Object { @@ -1903,12 +1903,12 @@ exports[`BatchReport loads a simple report and autoselects entries 1`] = ` ], "logs": Array [], "name": "AlphaSuite", - "name_type_index": Set { + "name_type_index": Array [ "AlphaSuite|testsuite", "Primary|multitest", "Sample Testplan|testplan", "test_equality_passing|testcase", - }, + ], "status": "failed", "status_override": null, "tags": Object { diff --git a/testplan/web_ui/testing/src/Report/__tests__/__snapshots__/InteractiveReport.test.js.snap b/testplan/web_ui/testing/src/Report/__tests__/__snapshots__/InteractiveReport.test.js.snap index 33e9f6e09..6c967dbeb 100644 --- a/testplan/web_ui/testing/src/Report/__tests__/__snapshots__/InteractiveReport.test.js.snap +++ b/testplan/web_ui/testing/src/Report/__tests__/__snapshots__/InteractiveReport.test.js.snap @@ -646,12 +646,12 @@ exports[`InteractiveReport Updates testcase state 1`] = ` "hash": 44444, "logs": Array [], "name": "testcaseName", - "name_type_index": Set { + "name_type_index": Array [ "testcaseName|testcase", "SuiteName|testsuite", "MultitestName|multitest", "TestplanName|testplan", - }, + ], "parent_uids": Array [ "TestplanUID", "MultitestUID", @@ -674,12 +674,12 @@ exports[`InteractiveReport Updates testcase state 1`] = ` "fix_spec_path": null, "hash": 33333, "name": "SuiteName", - "name_type_index": Set { + "name_type_index": Array [ "SuiteName|testsuite", "MultitestName|multitest", "TestplanName|testplan", "testcaseName|testcase", - }, + ], "parent_uids": Array [ "TestplanUID", "MultitestUID", @@ -700,12 +700,12 @@ exports[`InteractiveReport Updates testcase state 1`] = ` "fix_spec_path": null, "hash": 22222, "name": "MultitestName", - "name_type_index": Set { + "name_type_index": Array [ "MultitestName|multitest", "TestplanName|testplan", "SuiteName|testsuite", "testcaseName|testcase", - }, + ], "parent_uids": Array [ "TestplanUID", ], @@ -725,12 +725,12 @@ exports[`InteractiveReport Updates testcase state 1`] = ` "hash": 11111, "meta": Object {}, "name": "TestplanName", - "name_type_index": Set { + "name_type_index": Array [ "TestplanName|testplan", "MultitestName|multitest", "SuiteName|testsuite", "testcaseName|testcase", - }, + ], "parent_uids": Array [], "runtime_status": "running", "status": "unknown", @@ -761,12 +761,12 @@ exports[`InteractiveReport Updates testcase state 1`] = ` "hash": 44444, "logs": Array [], "name": "testcaseName", - "name_type_index": Set { + "name_type_index": Array [ "testcaseName|testcase", "SuiteName|testsuite", "MultitestName|multitest", "TestplanName|testplan", - }, + ], "parent_uids": Array [ "TestplanUID", "MultitestUID", @@ -789,12 +789,12 @@ exports[`InteractiveReport Updates testcase state 1`] = ` "fix_spec_path": null, "hash": 33333, "name": "SuiteName", - "name_type_index": Set { + "name_type_index": Array [ "SuiteName|testsuite", "MultitestName|multitest", "TestplanName|testplan", "testcaseName|testcase", - }, + ], "parent_uids": Array [ "TestplanUID", "MultitestUID", @@ -815,12 +815,12 @@ exports[`InteractiveReport Updates testcase state 1`] = ` "fix_spec_path": null, "hash": 22222, "name": "MultitestName", - "name_type_index": Set { + "name_type_index": Array [ "MultitestName|multitest", "TestplanName|testplan", "SuiteName|testsuite", "testcaseName|testcase", - }, + ], "parent_uids": Array [ "TestplanUID", ], @@ -840,12 +840,12 @@ exports[`InteractiveReport Updates testcase state 1`] = ` "hash": 11111, "meta": Object {}, "name": "TestplanName", - "name_type_index": Set { + "name_type_index": Array [ "TestplanName|testplan", "MultitestName|multitest", "SuiteName|testsuite", "testcaseName|testcase", - }, + ], "parent_uids": Array [], "runtime_status": "running", "status": "unknown", diff --git a/testplan/web_ui/testing/src/Report/__tests__/reportUtils.test.js b/testplan/web_ui/testing/src/Report/__tests__/reportUtils.test.js index c210ef841..ced909733 100644 --- a/testplan/web_ui/testing/src/Report/__tests__/reportUtils.test.js +++ b/testplan/web_ui/testing/src/Report/__tests__/reportUtils.test.js @@ -70,7 +70,7 @@ describe('Report/reportUtils', () => { [ [ 'testplan', - new Set([ + [ 'Sample Testplan|testplan', 'Primary|multitest', 'AlphaSuite|testsuite', @@ -79,37 +79,37 @@ describe('Report/reportUtils', () => { 'BetaSuite|testsuite', 'Secondary|multitest', 'GammaSuite|testsuite', - ]), + ], ], [ 'multitest', - new Set([ + [ 'Primary|multitest', 'Sample Testplan|testplan', 'AlphaSuite|testsuite', 'test_equality_passing|testcase', 'test_equality_passing2|testcase', 'BetaSuite|testsuite', - ]), + ], ], [ 'testsuite', - new Set([ + [ 'AlphaSuite|testsuite', 'Primary|multitest', 'Sample Testplan|testplan', 'test_equality_passing|testcase', 'test_equality_passing2|testcase', - ]), + ], ], [ 'testcase', - new Set([ + [ 'test_equality_passing|testcase', 'AlphaSuite|testsuite', 'Primary|multitest', 'Sample Testplan|testplan', - ]), + ], ], ].forEach(([entryType, nameTypeIndex]) => { it(`${entryType} name_type_index - stores ancestors & ` + diff --git a/testplan/web_ui/testing/src/Report/reportUtils.js b/testplan/web_ui/testing/src/Report/reportUtils.js index 3299a49cf..f59e08e11 100644 --- a/testplan/web_ui/testing/src/Report/reportUtils.js +++ b/testplan/web_ui/testing/src/Report/reportUtils.js @@ -3,7 +3,7 @@ */ import React from "react"; import format from 'date-fns/format'; - +import _ from 'lodash'; import AssertionPane from '../AssertionPane/AssertionPane'; import Message from '../Common/Message'; @@ -30,7 +30,7 @@ function _mergeTags(tagsA, tagsB) { const tags = tagsB[tagName]; if (tagsA.hasOwnProperty(tagName)) { let tagsArray = tags.concat(tagsA[tagName]); - let tagsSet = new Set(tagsArray); + let tagsSet = _.uniq(tagsArray); mergedTags[tagName] = [...tagsSet]; } else { mergedTags[tagName] = tags; @@ -59,12 +59,12 @@ const propagateIndicesRecur = (entries, parentIndices) => { if (parentIndices === undefined) { parentIndices = { tags_index: {}, - name_type_index: new Set(), + name_type_index: [], }; } let indices = { tags_index: {}, - name_type_index: new Set(), + name_type_index: [], counter: { passed: 0, failed: 0, @@ -76,7 +76,7 @@ const propagateIndicesRecur = (entries, parentIndices) => { // Initialize indices. let tagsIndex = {}; const entryNameType = entry.name + '|' + entryType; - let nameTypeIndex = new Set([ + let nameTypeIndex = _.uniq([ entryNameType, ...parentIndices.name_type_index ]); @@ -94,7 +94,7 @@ const propagateIndicesRecur = (entries, parentIndices) => { { tags_index: tags, name_type_index: nameTypeIndex } ); tagsIndex = _mergeTags(tagsIndex, descendantsIndices.tags_index); - nameTypeIndex = new Set([ + nameTypeIndex = _.uniq([ ...nameTypeIndex, ...descendantsIndices.name_type_index ]); @@ -107,7 +107,7 @@ const propagateIndicesRecur = (entries, parentIndices) => { // Update Array of entries indices. indices.tags_index = _mergeTags(indices.tags_index, tagsIndex); - indices.name_type_index = new Set([ + indices.name_type_index = _.uniq([ ...indices.name_type_index, ...nameTypeIndex ]); diff --git a/testplan/web_ui/testing/src/index.js b/testplan/web_ui/testing/src/index.js index 1fc7c4ae3..6f95c867c 100644 --- a/testplan/web_ui/testing/src/index.js +++ b/testplan/web_ui/testing/src/index.js @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import BatchReport from './Report/BatchReport'; +import BatchReportBeta from './Report/BatchReportBeta'; import InteractiveReport from './Report/InteractiveReport'; import EmptyReport from './Report/EmptyReport'; import {POLL_MS} from './Common/defaults.js'; @@ -17,6 +18,7 @@ import { BrowserRouter as Router, Route, Switch } from "react-router-dom"; const AppRouter = () => ( + diff --git a/testplan/web_ui/testing/src/setupTests.js b/testplan/web_ui/testing/src/setupTests.js index 7a1fee7e8..23d923788 100644 --- a/testplan/web_ui/testing/src/setupTests.js +++ b/testplan/web_ui/testing/src/setupTests.js @@ -1,4 +1,5 @@ +import 'react-app-polyfill/stable'; import { configure } from 'enzyme'; import Adapter from 'enzyme-adapter-react-16'; -configure({ adapter: new Adapter() }); \ No newline at end of file +configure({ adapter: new Adapter() });