From cdd8646958263a94c18e45d182def6b24a90752c Mon Sep 17 00:00:00 2001 From: Roman Kuznetsov Date: Wed, 26 Jun 2024 13:36:18 +0300 Subject: [PATCH] feat: add static image accepter --- lib/common-utils.ts | 8 +- lib/config/index.ts | 37 ++++ lib/constants/defaults.ts | 8 + lib/constants/test-statuses.ts | 16 ++ lib/constants/view-modes.ts | 2 + lib/server-utils.ts | 3 +- .../components/controls/common-controls.jsx | 14 +- .../components/controls/common-filters.jsx | 63 ++++-- lib/static/components/modals/index.js | 4 +- .../modals/screenshot-accepter/header.jsx | 29 ++- .../modals/screenshot-accepter/index.jsx | 78 +++++-- .../modals/static-accepter-confirm/index.tsx | 104 +++++++++ .../modals/static-accepter-confirm/style.css | 22 ++ lib/static/components/report.jsx | 2 + lib/static/components/state/index.jsx | 29 ++- lib/static/containers/modal.jsx | 2 +- .../{action-names.js => action-names.ts} | 9 +- lib/static/modules/actions.js | 81 ++++++- .../{default-state.js => default-state.ts} | 19 +- lib/static/modules/reducers/index.js | 4 +- lib/static/modules/reducers/loading.js | 2 +- lib/static/modules/reducers/modals.js | 2 +- lib/static/modules/reducers/processing.js | 2 +- lib/static/modules/reducers/running.js | 2 +- .../modules/reducers/static-image-accepter.js | 199 ++++++++++++++++++ lib/static/modules/reducers/stopping.js | 2 +- lib/static/modules/reducers/tree/helpers.js | 81 ++++++- lib/static/modules/reducers/tree/index.js | 79 ++++++- .../modules/reducers/tree/nodes/browsers.js | 12 +- .../modules/reducers/tree/nodes/images.js | 6 + .../modules/reducers/tree/nodes/suites.js | 12 +- lib/static/modules/selectors/tree.js | 4 +- lib/static/modules/static-image-accepter.ts | 134 ++++++++++++ lib/static/modules/utils/index.js | 40 +++- lib/static/styles.css | 36 ++++ lib/static/variables.css | 2 + lib/types.ts | 29 +++ 37 files changed, 1079 insertions(+), 99 deletions(-) create mode 100644 lib/static/components/modals/static-accepter-confirm/index.tsx create mode 100644 lib/static/components/modals/static-accepter-confirm/style.css rename lib/static/modules/{action-names.js => action-names.ts} (86%) rename lib/static/modules/{default-state.js => default-state.ts} (78%) create mode 100644 lib/static/modules/reducers/static-image-accepter.js create mode 100644 lib/static/modules/static-image-accepter.ts diff --git a/lib/common-utils.ts b/lib/common-utils.ts index dbbe5b0ae..aff185615 100644 --- a/lib/common-utils.ts +++ b/lib/common-utils.ts @@ -11,7 +11,9 @@ import { SKIPPED, SUCCESS, TestStatus, - UPDATED + UPDATED, + STAGED, + COMMITED } from './constants'; import {CHECKED, INDETERMINATE, UNCHECKED} from './constants/checked-statuses'; @@ -36,7 +38,7 @@ const statusPriority: TestStatus[] = [ RUNNING, QUEUED, // final - ERROR, FAIL, UPDATED, SUCCESS, IDLE, SKIPPED + ERROR, FAIL, STAGED, COMMITED, UPDATED, SUCCESS, IDLE, SKIPPED ]; export const logger = pick(console, ['log', 'warn', 'error']); @@ -48,6 +50,8 @@ export const isRunningStatus = (status: TestStatus): boolean => status === RUNNI export const isErrorStatus = (status: TestStatus): boolean => status === ERROR; export const isSkippedStatus = (status: TestStatus): boolean => status === SKIPPED; export const isUpdatedStatus = (status: TestStatus): boolean => status === UPDATED; +export const isStagedStatus = (status: TestStatus): boolean => status === STAGED; +export const isCommitedStatus = (status: TestStatus): boolean => status === COMMITED; export const determineFinalStatus = (statuses: TestStatus[]): TestStatus | null => { if (!statuses.length) { diff --git a/lib/config/index.ts b/lib/config/index.ts index 6e3ae9bb7..ade2e0d05 100644 --- a/lib/config/index.ts +++ b/lib/config/index.ts @@ -15,6 +15,10 @@ const ALLOWED_PLUGIN_DESCRIPTION_FIELDS = new Set(['name', 'component', 'point', type TypePredicateFn = (value: unknown) => value is T; type AssertionFn = (value: unknown) => asserts value is T; +const isPlainObject = (value: unknown): value is Record => { + return _.isPlainObject(value); +}; + const assertType = (name: string, validationFn: (value: unknown) => value is T, type: string): AssertionFn => { return (v: unknown): asserts v is T => { if (!validationFn(v)) { @@ -25,6 +29,7 @@ const assertType = (name: string, validationFn: (value: unknown) => value is const assertString = (name: string): AssertionFn => assertType(name, _.isString, 'string'); const assertBoolean = (name: string): AssertionFn => assertType(name, _.isBoolean, 'boolean'); const assertNumber = (name: string): AssertionFn => assertType(name, _.isNumber, 'number'); +const assertPlainObject = (name: string): AssertionFn> => assertType(name, isPlainObject, 'plain object'); const assertSaveFormat = (saveFormat: unknown): asserts saveFormat is SaveFormat => { const formats = Object.values(SaveFormat); @@ -219,6 +224,38 @@ const getParser = (): ReturnType> => { parseEnv: JSON.parse, parseCli: JSON.parse, validate: assertArrayOf('plugin descriptions', 'plugins', assertPluginDescription) + }), + staticImageAccepter: section({ + enabled: option({ + defaultValue: configDefaults.staticImageAccepter.enabled, + parseEnv: JSON.parse, + parseCli: JSON.parse, + validate: assertBoolean('staticImageAccepter.enabled') + }), + repositoryUrl: option({ + defaultValue: configDefaults.staticImageAccepter.repositoryUrl, + validate: assertString('staticImageAccepter.repositoryUrl') + }), + pullRequestUrl: option({ + defaultValue: configDefaults.staticImageAccepter.pullRequestUrl, + validate: assertString('staticImageAccepter.pullRequestUrl') + }), + serviceUrl: option({ + defaultValue: configDefaults.staticImageAccepter.serviceUrl, + validate: assertString('staticImageAccepter.serviceUrl') + }), + meta: option({ + defaultValue: configDefaults.staticImageAccepter.meta, + parseEnv: JSON.parse, + parseCli: JSON.parse, + validate: assertPlainObject('staticImageAccepter.meta') + }), + axiosRequestOptions: option({ + defaultValue: configDefaults.staticImageAccepter.axiosRequestOptions, + parseEnv: JSON.parse, + parseCli: JSON.parse, + validate: assertPlainObject('staticImageAccepter.axiosRequestOptions') + }) }) }), {envPrefix: ENV_PREFIX, cliPrefix: CLI_PREFIX}); }; diff --git a/lib/constants/defaults.ts b/lib/constants/defaults.ts index 9bb2d57da..6fc84fa2f 100644 --- a/lib/constants/defaults.ts +++ b/lib/constants/defaults.ts @@ -23,5 +23,13 @@ export const configDefaults: ReporterConfig = { saveFormat: SaveFormat.SQLITE, yandexMetrika: { counterNumber: null + }, + staticImageAccepter: { + enabled: false, + repositoryUrl: '', + pullRequestUrl: '', + serviceUrl: '', + meta: {}, + axiosRequestOptions: {} } }; diff --git a/lib/constants/test-statuses.ts b/lib/constants/test-statuses.ts index c4b68966e..3ee8b9bf8 100644 --- a/lib/constants/test-statuses.ts +++ b/lib/constants/test-statuses.ts @@ -7,6 +7,14 @@ export enum TestStatus { ERROR = 'error', SKIPPED = 'skipped', UPDATED = 'updated', + /** + * @note used by staticImageAccepter only + */ + STAGED = 'staged', + /** + * @note used by staticImageAccepter only + */ + COMMITED = 'commited', } export const IDLE = TestStatus.IDLE; @@ -17,3 +25,11 @@ export const FAIL = TestStatus.FAIL; export const ERROR = TestStatus.ERROR; export const SKIPPED = TestStatus.SKIPPED; export const UPDATED = TestStatus.UPDATED; +/** + * @note used by staticImageAccepter only + */ +export const STAGED = TestStatus.STAGED; +/** + * @note used by staticImageAccepter only + */ +export const COMMITED = TestStatus.COMMITED; diff --git a/lib/constants/view-modes.ts b/lib/constants/view-modes.ts index 554979b3d..9cc6b985b 100644 --- a/lib/constants/view-modes.ts +++ b/lib/constants/view-modes.ts @@ -4,4 +4,6 @@ export enum ViewMode { FAILED = 'failed', RETRIED = 'retried', SKIPPED = 'skipped', + STAGED = 'staged', + COMMITED = 'commited', } diff --git a/lib/server-utils.ts b/lib/server-utils.ts index da100250c..690c2c6fb 100644 --- a/lib/server-utils.ts +++ b/lib/server-utils.ts @@ -221,7 +221,8 @@ export function getConfigForStaticFile(pluginConfig: ReporterConfig): ConfigForS 'customScripts', 'yandexMetrika', 'pluginsEnabled', - 'plugins' + 'plugins', + 'staticImageAccepter' ]); } diff --git a/lib/static/components/controls/common-controls.jsx b/lib/static/components/controls/common-controls.jsx index 52a6dcaae..e312468d0 100644 --- a/lib/static/components/controls/common-controls.jsx +++ b/lib/static/components/controls/common-controls.jsx @@ -1,7 +1,7 @@ import React, {Component} from 'react'; import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; -import {capitalize} from 'lodash'; +import {capitalize, pull} from 'lodash'; import * as actions from '../../modules/actions'; import ControlButton from './control-button'; import ControlSelect from './selects/control'; @@ -14,6 +14,14 @@ import {DiffModes} from '../../../constants/diff-modes'; import {EXPAND_ALL, COLLAPSE_ALL, EXPAND_ERRORS, EXPAND_RETRIES} from '../../../constants/expand-modes'; class ControlButtons extends Component { + getShowTestsOptions() { + const viewModes = Object.values(ViewMode).map(value => ({value, text: capitalize(value)})); + + return this.props.isStatisImageAccepterEnabled + ? viewModes + : viewModes.filter(viewMode => ![ViewMode.STAGED, ViewMode.COMMITED].includes(viewMode.value)); + } + render() { const {actions, view} = this.props; @@ -23,7 +31,7 @@ class ControlButtons extends Component { label="Show tests" value={view.viewMode} handler={actions.changeViewMode} - options = {Object.values(ViewMode).map((value) => ({value, text: capitalize(value)}))} + options = {this.getShowTestsOptions()} />
({view}), + ({view, staticImageAccepter: {enabled}}) => ({view, isStatisImageAccepterEnabled: enabled}), (dispatch) => ({actions: bindActionCreators(actions, dispatch)}) )(ControlButtons); diff --git a/lib/static/components/controls/common-filters.jsx b/lib/static/components/controls/common-filters.jsx index d6b7bda5b..3ff3e7df6 100644 --- a/lib/static/components/controls/common-filters.jsx +++ b/lib/static/components/controls/common-filters.jsx @@ -1,6 +1,6 @@ 'use strict'; -import React, {Component} from 'react'; +import React from 'react'; import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; import * as actions from '../../modules/actions'; @@ -8,27 +8,62 @@ import TestNameFilterInput from './test-name-filter-input'; import StrictMatchFilterInput from './strict-match-filter-input'; import ShowCheckboxesInput from './show-checkboxes-input'; import BrowserList from './browser-list'; +import ProgressBar from '../progress-bar'; +import ControlButton from './control-button'; -class CommonFilters extends Component { - render() { - const {filteredBrowsers, browsers, gui, actions} = this.props; +const CommonFilters = (props) => { + const onCommitChanges = () => { + props.actions.staticAccepterOpenConfirm(); + } + + const renderStaticImageAccepterControls = () => { + const {staticImageAccepter} = props; + + if (!staticImageAccepter.enabled) { + return null; + } return ( -
- + 0} + isDisabled={staticImageAccepter.imagesToCommitCount === 0} + isSuiteControl={true} + extendClassNames="static-image-accepter-commit" + handler={onCommitChanges} + /> + - - - {gui && }
- ); + ) } + + return ( +
+ + + + {props.gui && } + {renderStaticImageAccepterControls()} +
+ ); } export default connect( - ({view, browsers, gui}) => ({filteredBrowsers: view.filteredBrowsers, browsers, gui}), + ({view, browsers, gui, staticImageAccepter}) => ({ + filteredBrowsers: view.filteredBrowsers, + browsers, + gui, + staticImageAccepter, + }), (dispatch) => ({actions: bindActionCreators(actions, dispatch)}) )(CommonFilters); diff --git a/lib/static/components/modals/index.js b/lib/static/components/modals/index.js index 245a7e059..a1c9e3e51 100644 --- a/lib/static/components/modals/index.js +++ b/lib/static/components/modals/index.js @@ -1,6 +1,8 @@ export const types = { FIND_SAME_DIFFS: 'FindSameDiffs', - SCREENSHOT_ACCEPTER: 'ScreenshotAccepter' + SCREENSHOT_ACCEPTER: 'ScreenshotAccepter', + STATIC_ACCEPTER_CONFIRM: 'StaticAccepterConfirm' }; export {default as FindSameDiffs} from './find-same-diffs'; export {default as ScreenshotAccepter} from './screenshot-accepter'; +export {default as StaticAccepterConfirm} from './static-accepter-confirm'; diff --git a/lib/static/components/modals/screenshot-accepter/header.jsx b/lib/static/components/modals/screenshot-accepter/header.jsx index d9008265c..53bbff915 100644 --- a/lib/static/components/modals/screenshot-accepter/header.jsx +++ b/lib/static/components/modals/screenshot-accepter/header.jsx @@ -10,6 +10,7 @@ import ControlButton from '../../controls/control-button'; import ControlSelect from '../../controls/selects/control'; import RetrySwitcher from '../../retry-switcher'; import {DiffModes} from '../../../../constants/diff-modes'; +import {staticImageAccepterPropType} from "../../../modules/static-image-accepter"; export default class ScreenshotAccepterHeader extends Component { static propTypes = { @@ -28,7 +29,9 @@ export default class ScreenshotAccepterHeader extends Component { onActiveImageChange: PropTypes.func.isRequired, onScreenshotAccept: PropTypes.func.isRequired, onScreenshotUndo: PropTypes.func.isRequired, - onShowMeta: PropTypes.func.isRequired + onShowMeta: PropTypes.func.isRequired, + onCommitChanges: PropTypes.func.isRequired, + staticImageAccepter: staticImageAccepterPropType }; constructor(props) { @@ -96,6 +99,11 @@ export default class ScreenshotAccepterHeader extends Component { event.preventDefault(); const {images, retryIndex, onScreenshotAccept} = this.props; + + if (retryIndex === null) { + return; + } + const imageId = images[retryIndex].id; onScreenshotAccept(imageId); @@ -109,11 +117,16 @@ export default class ScreenshotAccepterHeader extends Component { render() { const {actions, view, images, stateNameImageIds, retryIndex, - showMeta, onClose, onRetryChange, onShowMeta, - totalImages, acceptedImages + showMeta, onClose, onRetryChange, onShowMeta, onCommitChanges, + totalImages, acceptedImages, staticImageAccepter } = this.props; const resultIds = uniqBy(images, 'id').map((image) => image.parentId); const isArrowControlDisabed = stateNameImageIds.length <= 1; + const staticAccepterDelayedImages = staticImageAccepter.accepterDelayedImages.length; + const imagesToCommitCount = staticImageAccepter.imagesToCommitCount + staticAccepterDelayedImages; + const isUndoEnabled = staticImageAccepter.enabled + ? Boolean(staticImageAccepter.accepterDelayedImages.length) + : Boolean(acceptedImages); return ( @@ -148,7 +161,7 @@ export default class ScreenshotAccepterHeader extends Component { + {staticImageAccepter.enabled && 0} + isDisabled={imagesToCommitCount === 0} + isSuiteControl={true} + handler={onCommitChanges} + />}
diff --git a/lib/static/components/modals/screenshot-accepter/index.jsx b/lib/static/components/modals/screenshot-accepter/index.jsx index 905502589..d616199ce 100644 --- a/lib/static/components/modals/screenshot-accepter/index.jsx +++ b/lib/static/components/modals/screenshot-accepter/index.jsx @@ -3,13 +3,15 @@ import ReactDOM from 'react-dom'; import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; import PropTypes from 'prop-types'; -import {isEmpty, isNumber, size, get, findIndex, last} from 'lodash'; +import {isEmpty, isNumber, size, get, findIndex, last, pick, map} from 'lodash'; import * as actions from '../../../modules/actions'; import ScreenshotAccepterHeader from './header'; import ScreenshotAccepterMeta from './meta'; import ScreenshotAccepterBody from './body'; +import StaticAccepterConfirm from "../static-accepter-confirm"; import {getAcceptableImagesByStateName} from '../../../modules/selectors/tree'; +import {formatCommitPayload, staticImageAccepterPropType} from "../../../modules/static-image-accepter"; import {preloadImage} from '../../../modules/utils'; import './style.css'; @@ -28,7 +30,8 @@ class ScreenshotAccepter extends Component { // store imagesByStateName: PropTypes.object.isRequired, stateNameImageIds: PropTypes.arrayOf(PropTypes.string).isRequired, - activeImageIndex: PropTypes.number.isRequired + activeImageIndex: PropTypes.number.isRequired, + staticImageAccepter: staticImageAccepterPropType, }; constructor(props) { @@ -72,22 +75,21 @@ class ScreenshotAccepter extends Component { onScreenshotAccept = async (imageId) => { const currentImages = this._getActiveImages(); const {stateName} = currentImages[0]; - const updatedData = await this.props.actions.screenshotAccepterAccept(imageId); - - if (updatedData === null) { - return; - } - this.delayedTestResults = this.delayedTestResults.concat(updatedData); - - const updatedImagesData = get(updatedData, '[0].images', []); - const {id: updatedImageId} = updatedImagesData.find(img => img.stateName === stateName) || {}; const acceptedStateNameImageIdIndex = this.state.stateNameImageIds.findIndex((stateNameImageId) => { const currImages = this.props.imagesByStateName[stateNameImageId]; return currImages.some(img => img.id === imageId); }); + let updatedImageId = this.props.staticImageAccepter.enabled + ? this._stageScreenshot(imageId, stateName) + : await this._acceptScreenshot(imageId, stateName); + + if (!updatedImageId) { + return; + } + this.acceptedImages.push({ stateNameImageId: this.state.stateNameImageIds[acceptedStateNameImageIdIndex], ind: acceptedStateNameImageIdIndex, @@ -130,9 +132,12 @@ class ScreenshotAccepter extends Component { ]; const images = this._getActiveImages(ind, previousStateNameImageId); - await this.props.actions.undoAcceptImage(imageId, {skipTreeUpdate: true}); - - this.delayedTestResults.pop(); + if (this.props.staticImageAccepter.enabled) { + this.props.actions.staticAccepterUndoDelayScreenshot(); + } else { + await this.props.actions.undoAcceptImage(imageId, {skipTreeUpdate: true}); + this.delayedTestResults.pop(); + } this.setState({ activeImageIndex: ind, @@ -147,13 +152,23 @@ class ScreenshotAccepter extends Component { }; onClose = () => { - if (!isEmpty(this.delayedTestResults)) { + if (isEmpty(this.props.staticImageAccepter.accepterDelayedImages) && isEmpty(this.delayedTestResults)) { + return this.props.onClose(); + } + + if (this.props.staticImageAccepter.enabled) { + this.props.actions.staticAccepterStageScreenshot(this.props.staticImageAccepter.accepterDelayedImages); + } else { this.props.actions.applyDelayedTestResults(this.delayedTestResults); } this.props.onClose(); }; + onCommitChanges = () => { + this.props.actions.staticAccepterOpenConfirm(); + } + _getActiveImages( activeImageIndex = this.state.activeImageIndex, stateNameImageIds = this.state.stateNameImageIds @@ -163,6 +178,31 @@ class ScreenshotAccepter extends Component { : []; } + async _acceptScreenshot(imageId, stateName) { + const updatedData = await this.props.actions.screenshotAccepterAccept(imageId); + + if (updatedData === null) { + return null; + } + + this.delayedTestResults = this.delayedTestResults.concat(updatedData); + + const updatedImagesData = get(updatedData, '[0].images', []); + const {id: updatedImageId} = updatedImagesData.find(img => img.stateName === stateName) || {}; + + return updatedImageId; + } + + _stageScreenshot(imageId, stateName) { + const stateNameImageId = this.props.stateNameImageIds.find(stateNameImageId => { + return this.props.imagesByStateName[stateNameImageId].some(img => img.id === imageId); + }); + + this.props.actions.staticAccepterDelayScreenshot({imageId, stateName, stateNameImageId}); + + return imageId; + } + _preloadAdjacentImages(offset = PRELOAD_IMAGE_COUNT) { const screensCount = size(this.state.stateNameImageIds); const previosImagesIndex = (screensCount + this.state.activeImageIndex - offset) % screensCount; @@ -177,7 +217,7 @@ class ScreenshotAccepter extends Component { } render() { - const {actions, view} = this.props; + const {actions, view, staticImageAccepter} = this.props; const {retryIndex, stateNameImageIds, activeImageIndex, showMeta} = this.state; const images = this._getActiveImages(); const currImage = isNumber(retryIndex) ? images[retryIndex] : null; @@ -201,6 +241,8 @@ class ScreenshotAccepter extends Component { onScreenshotAccept={this.onScreenshotAccept} onScreenshotUndo={this.onScreenshotUndo} onShowMeta={this.onShowMeta} + onCommitChanges={this.onCommitChanges} + staticImageAccepter={staticImageAccepter} /> ({actions: bindActionCreators(actions, dispatch)}) diff --git a/lib/static/components/modals/static-accepter-confirm/index.tsx b/lib/static/components/modals/static-accepter-confirm/index.tsx new file mode 100644 index 000000000..c083fd530 --- /dev/null +++ b/lib/static/components/modals/static-accepter-confirm/index.tsx @@ -0,0 +1,104 @@ +import React, {KeyboardEvent, useMemo, useState} from 'react'; +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import {pick} from 'lodash'; +import {Button, Card, Link, Text, TextArea} from '@gravity-ui/uikit'; + +import * as actions from '../../../modules/actions'; +import type defaultState from '../../../modules/default-state'; +import {formatCommitPayload} from '../../../modules/static-image-accepter'; + +import './style.css'; + +interface Props { + staticImageAccepter: typeof defaultState['staticImageAccepter']; + staticAccepterConfig: typeof defaultState['config']['staticImageAccepter']; + imagesById: typeof defaultState['tree']['images']['byId']; + actions: typeof actions; +} + +const StaticAccepterConfirm: React.FC = ({staticImageAccepter, staticAccepterConfig, imagesById, actions}) => { + const [commitMessage, setCommitMessage] = useState(''); + + const pullRequestUrl = staticAccepterConfig.pullRequestUrl; + + const imagesInfo = useMemo(() => formatCommitPayload( + Object.values(staticImageAccepter.acceptableImages), + imagesById, + staticImageAccepter.accepterDelayedImages + ), [staticImageAccepter, imagesById]); + + const onClose = (): void => { + actions.staticAccepterCloseConfirm(); + }; + + const onConfirm = async (): Promise => { + const opts = { + ...pick(staticAccepterConfig, [ + 'repositoryUrl', + 'pullRequestUrl', + 'serviceUrl', + 'axiosRequestOptions', + 'meta' + ]), + message: commitMessage + }; + + await actions.staticAccepterCommitScreenshot(imagesInfo, opts); + }; + + const onKeyPress = (event: KeyboardEvent): void => { + if (event.key === 'Enter') { + event.preventDefault(); + + onConfirm(); + } + }; + + return ( + + You are commiting {imagesInfo.length} images to Pull Request: +
+ {pullRequestUrl} +
+
+
+ Enter commit message: +