From f92dee5dba3daafe07f26421f23579eb55a89cfa Mon Sep 17 00:00:00 2001 From: Alexandr Shaporov <85781517+mordvinx@users.noreply.github.com> Date: Fri, 24 Jan 2025 14:06:51 +0300 Subject: [PATCH] Users/mordvinx/testplane 404.new UI error boundaries (#630) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(css): add min-width and min-height to app In the future, it will help to render ErrorBoundary to the full screen. * refactor(visual-checks): extract VisualCheckStickyHeader FC * refactor(test-steps): raise CollapsibleSection, extract TestStep FC * feat(error-handling): add ErrorHandling components * style(suite-title): remove unused interface * feat(test-step): add corrupted style for test step item * feat(error-handling): add usages of ErrorHandler component * refactor(error-handler): rename file * refactor(error-handler): reorganize components * feat(error-handler): add top level error handler * feat(error-handler): additional message * fix(icons): add new icons Не добавил сразу, потому что они под gitignore * feat(error-handler): change error font size from 15px to 13px * feat(error-handler): fallback components will take 100% of width * feat(error-handler): change data corruption fallback style * feat(error-handler): raise AssertViewResult error handling * feat(error-handler): reorganize fallbacks and add styling * feat(error-handling): clip stack if length is more than 50 lines * fix(imports): remove unused import * feat(error-handling): change icon * fix(styles): pretty width limitation for separator * refactor(error-info): remove stack clipping * feat(corrupted-test-step): update warning colors * refactor(error-actions): do not use internals * refactor(naming): rename ErrorHandler Root to Boundary * feat(error-handler): add recommended action for CardCrash * feat(error-boundary): add handling for any typed values * feat(error-handling): change data corrution fallback align * feat(error-handling): add handling for broken pages * feat(error-handling): full width buttons * fix(error-info): fix weird borders * feat(error-handling): set code block max height * fix(error-handling): fix mistypes * feat(tests): add svg import stub --------- Co-authored-by: = <=> --- .../icons/exclamation-triangle-large.svg | 5 + lib/static/icons/github-icon.svg | 3 + lib/static/icons/testplane-mono-black.svg | 5 + lib/static/new-ui.css | 8 + lib/static/new-ui/app/App.tsx | 55 +++--- .../components/AssertViewResult/index.tsx | 16 +- .../components/ErrorInfo/index.module.css | 2 +- .../new-ui/components/SuiteTitle/index.tsx | 5 +- .../components/TreeViewItem/index.module.css | 10 ++ .../new-ui/components/TreeViewItem/index.tsx | 5 +- .../components/ErrorHandling/Boundary.tsx | 101 +++++++++++ .../components/ErrorHandling/actions.tsx | 32 ++++ .../components/ErrorHandling/context.ts | 16 ++ .../components/ErrorHandling/fallbacks.tsx | 79 +++++++++ .../components/ErrorHandling/index.module.css | 107 ++++++++++++ .../components/ErrorHandling/index.tsx | 9 + .../components/ErrorHandling/interfaces.ts | 32 ++++ .../ScreenshotsTreeViewItem/index.tsx | 6 +- .../suites/components/SuitesPage/index.tsx | 89 ++++++---- .../suites/components/TestSteps/index.tsx | 127 +++++++++----- .../VisualChecksStickyHeader.tsx | 145 ++++++++++++++++ .../components/VisualChecksPage/index.tsx | 159 +++--------------- test/setup/globals.js | 1 + ...sPage.jsx => VisualChecksStickyHeader.jsx} | 8 +- 24 files changed, 776 insertions(+), 249 deletions(-) create mode 100644 lib/static/icons/exclamation-triangle-large.svg create mode 100644 lib/static/icons/github-icon.svg create mode 100644 lib/static/icons/testplane-mono-black.svg create mode 100644 lib/static/new-ui/features/error-handling/components/ErrorHandling/Boundary.tsx create mode 100644 lib/static/new-ui/features/error-handling/components/ErrorHandling/actions.tsx create mode 100644 lib/static/new-ui/features/error-handling/components/ErrorHandling/context.ts create mode 100644 lib/static/new-ui/features/error-handling/components/ErrorHandling/fallbacks.tsx create mode 100644 lib/static/new-ui/features/error-handling/components/ErrorHandling/index.module.css create mode 100644 lib/static/new-ui/features/error-handling/components/ErrorHandling/index.tsx create mode 100644 lib/static/new-ui/features/error-handling/components/ErrorHandling/interfaces.ts create mode 100644 lib/static/new-ui/features/visual-checks/components/VisualChecksPage/VisualChecksStickyHeader.tsx rename test/unit/lib/static/new-ui/features/visual-checks/components/{VisualChecksPage.jsx => VisualChecksStickyHeader.jsx} (86%) diff --git a/lib/static/icons/exclamation-triangle-large.svg b/lib/static/icons/exclamation-triangle-large.svg new file mode 100644 index 000000000..09190ac4b --- /dev/null +++ b/lib/static/icons/exclamation-triangle-large.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/lib/static/icons/github-icon.svg b/lib/static/icons/github-icon.svg new file mode 100644 index 000000000..782591dc6 --- /dev/null +++ b/lib/static/icons/github-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/lib/static/icons/testplane-mono-black.svg b/lib/static/icons/testplane-mono-black.svg new file mode 100644 index 000000000..dfba6fc60 --- /dev/null +++ b/lib/static/icons/testplane-mono-black.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/lib/static/new-ui.css b/lib/static/new-ui.css index 0fdde8e3a..5f43d2ed4 100644 --- a/lib/static/new-ui.css +++ b/lib/static/new-ui.css @@ -23,10 +23,18 @@ body { } .report { + min-width: 100%; + min-height: 100%; font-family: var(--g-font-family-sans), sans-serif !important; margin-bottom: 0 !important;; } +#app { + position: relative; + min-width: 100%; + min-height: 100%; +} + /* Aside header styles */ :root { --gn-aside-header-item-current-background-color: #1f2937; diff --git a/lib/static/new-ui/app/App.tsx b/lib/static/new-ui/app/App.tsx index b540cba4f..1c172423f 100644 --- a/lib/static/new-ui/app/App.tsx +++ b/lib/static/new-ui/app/App.tsx @@ -17,6 +17,7 @@ import {CustomScripts} from '@/static/new-ui/components/CustomScripts'; import {State} from '@/static/new-ui/types/store'; import {AnalyticsProvider} from '@/static/new-ui/providers/analytics'; import {MetrikaScript} from '@/static/new-ui/components/MetrikaScript'; +import {ErrorHandler} from '../features/error-handling/components/ErrorHandling'; export function App(): ReactNode { const pages = [ @@ -33,26 +34,38 @@ export function App(): ReactNode { const customScripts = (store.getState() as State).config.customScripts; return - - - - - - - - - - - } path={'/'}/> - {pages.map(page => {page.children})} - - - - - - - - - + }> + + + + + + + + }> + + + + } path={'/'}/> + {pages.map(page => ( + }> + { page.element} + + } path={page.url} key={page.url}> + {page.children} + + ))} + + + + + + + + + + + ; } diff --git a/lib/static/new-ui/components/AssertViewResult/index.tsx b/lib/static/new-ui/components/AssertViewResult/index.tsx index b4c90ffdf..369319ceb 100644 --- a/lib/static/new-ui/components/AssertViewResult/index.tsx +++ b/lib/static/new-ui/components/AssertViewResult/index.tsx @@ -18,22 +18,30 @@ interface AssertViewResultProps { function AssertViewResultInternal({result, diffMode, style}: AssertViewResultProps): ReactNode { if (result.status === TestStatus.FAIL) { return ; - } else if (result.status === TestStatus.ERROR) { + } + + if (result.status === TestStatus.ERROR) { return
; - } else if (result.status === TestStatus.SUCCESS || result.status === TestStatus.UPDATED) { + } + + if (result.status === TestStatus.SUCCESS || result.status === TestStatus.UPDATED) { return
; - } else if (result.status === TestStatus.STAGED) { + } + + if (result.status === TestStatus.STAGED) { return
; - } else if (result.status === TestStatus.COMMITED) { + } + + if (result.status === TestStatus.COMMITED) { return
diff --git a/lib/static/new-ui/components/ErrorInfo/index.module.css b/lib/static/new-ui/components/ErrorInfo/index.module.css index 4e3d762aa..bc3cffbb2 100644 --- a/lib/static/new-ui/components/ErrorInfo/index.module.css +++ b/lib/static/new-ui/components/ErrorInfo/index.module.css @@ -5,6 +5,6 @@ overflow-y: hidden; background: #101827; border-radius: 5px; - border: 12px solid #101827; + padding: 12px; box-shadow: rgba(0, 0, 0, 0) 0 0 0 0, rgba(0, 0, 0, 0) 0 0 0 0, rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.1) 0 4px 6px -4px; } diff --git a/lib/static/new-ui/components/SuiteTitle/index.tsx b/lib/static/new-ui/components/SuiteTitle/index.tsx index 139b5a405..d2e564c17 100644 --- a/lib/static/new-ui/components/SuiteTitle/index.tsx +++ b/lib/static/new-ui/components/SuiteTitle/index.tsx @@ -7,9 +7,6 @@ import styles from './index.module.css'; interface SuiteTitleProps { className?: string; -} - -interface SuiteTitlePropsInternal extends SuiteTitleProps { suitePath: string[]; browserName: string; stateName?: string; @@ -19,7 +16,7 @@ interface SuiteTitlePropsInternal extends SuiteTitleProps { onNext: () => void; } -export function SuiteTitle(props: SuiteTitlePropsInternal): ReactNode { +export function SuiteTitle(props: SuiteTitleProps): ReactNode { const suiteName = props.suitePath[props.suitePath.length - 1]; const suitePath = props.suitePath.slice(0, -1); diff --git a/lib/static/new-ui/components/TreeViewItem/index.module.css b/lib/static/new-ui/components/TreeViewItem/index.module.css index 3ef5ddfe8..c6a9bd6ac 100644 --- a/lib/static/new-ui/components/TreeViewItem/index.module.css +++ b/lib/static/new-ui/components/TreeViewItem/index.module.css @@ -16,6 +16,8 @@ cursor: pointer; padding-left: calc(var(--indent) * 24px); + + transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out; } .tree-view-item--error { @@ -23,3 +25,11 @@ background: var(--g-color-private-red-100); color: var(--g-color-private-red-600-solid); } + +.tree-view-item--corrupted { + --g-color-base-simple-hover: hsl(32deg 100% 48% / 20%); + background-color: rgba(255, 235, 206, 1); + color: hsl(32 100% 48% / 1); + --box-shadow-color: hsl(32deg 100% 48% / 42%); + --g-button-text-color-hover: hsl(32 100% 48% / 1); +} diff --git a/lib/static/new-ui/components/TreeViewItem/index.tsx b/lib/static/new-ui/components/TreeViewItem/index.tsx index a2de64e7a..67d049dca 100644 --- a/lib/static/new-ui/components/TreeViewItem/index.tsx +++ b/lib/static/new-ui/components/TreeViewItem/index.tsx @@ -21,7 +21,7 @@ interface TreeListItemProps { id: string; list: UseListResult; mapItemDataToContentProps: (data: T) => ListItemViewContentType; - isFailed?: boolean; + status?: 'error' | 'corrupted'; onItemClick?: (data: {id: string}) => unknown; } @@ -34,7 +34,8 @@ export function TreeViewItem(props: TreeListItemProps): ReactNode { > { + constructor(props: BoundaryProps) { + super(props); + this.state = { + watchFor: props.watchFor, + hasError: false, + error: null, + errorInfo: null + }; + } + + private static messageStyle = 'background: crimson; color: white;'; + private static timestampStyle = 'color: gray; font-size: smaller'; + + private static isNothingChanged(prev?: DependencyList, next?: DependencyList): boolean { + if (prev === next) { + return true; + } + + if (prev === undefined || next === undefined) { + return false; + } + + if (prev.length !== next.length) { + return false; + } + + return prev.every((item, index) => item === next[index]); + } + + private restore(): void { + this.setState({hasError: false, error: null}); + } + + static getDerivedStateFromProps(nextProps: BoundaryProps, prevState: BoundaryState): null | BoundaryState { + if (Boundary.isNothingChanged(prevState.watchFor, nextProps.watchFor)) { + return null; + } + + return {...prevState, error: null, hasError: false, errorInfo: null, watchFor: nextProps.watchFor}; + } + + private componentDidCatchErrorInstance(error: Error, errorInfo: ErrorInfo): void { + this.setState({hasError: true, error, errorInfo}); + + const timestamp = new Date().toTimeString(); + + console.groupCollapsed( + `%cError boundary catched error named "${error.name}". See details below:` + '%c @ ' + timestamp, + Boundary.messageStyle, + Boundary.timestampStyle + ); + console.error(error); + console.error('Component stack: ', errorInfo.componentStack); + console.groupEnd(); + } + + private componentDidCatchSomethingWeird(notAnError: unknown, errorInfo: ErrorInfo): void { + this.setState({hasError: true, error: new Error(`Unknown error, based on ${typeof notAnError} value provided.\nReceived value: ${notAnError}, which is not an Error instance.\nTry check your code for throwing ${typeof notAnError}s.`), errorInfo}); + + const timestamp = new Date().toTimeString(); + + console.groupCollapsed( + `%cError boundary catched ${typeof notAnError} instead of Error class instance. Try check your code for throwing ${typeof notAnError}s. See details below:` + '%c @ ' + timestamp, + Boundary.messageStyle, + Boundary.timestampStyle + ); + + console.log(`Received ${typeof notAnError} value: `, notAnError); + console.error('Component stack: ', errorInfo.componentStack); + console.groupEnd(); + } + + /** + * @param _error - The value throwed from the child component. This is not necessarily an Error class instance because of the ability to throw anything in JS. + * @param errorInfo - React specific object, contains useful componentStack parameter. + */ + componentDidCatch(error: unknown, errorInfo: ErrorInfo): void { + if (error instanceof Error) { + return this.componentDidCatchErrorInstance(error, errorInfo); + } + + return this.componentDidCatchSomethingWeird(error, errorInfo); + } + + render(): ReactNode { + if (this.state.hasError) { + return ( + + {this.props.fallback} + + ); + } + + return this.props.children; + } +} diff --git a/lib/static/new-ui/features/error-handling/components/ErrorHandling/actions.tsx b/lib/static/new-ui/features/error-handling/components/ErrorHandling/actions.tsx new file mode 100644 index 000000000..6c1b0efb7 --- /dev/null +++ b/lib/static/new-ui/features/error-handling/components/ErrorHandling/actions.tsx @@ -0,0 +1,32 @@ +import {ArrowsRotateLeft} from '@gravity-ui/icons'; +import {Button, ButtonProps, Icon} from '@gravity-ui/uikit'; +import React, {ReactNode} from 'react'; +import GithubIcon from '../../../../../icons/github-icon.svg'; +import {NEW_ISSUE_LINK} from '@/constants'; + +type ActionProps = Omit; + +function reportIssue(): void { + window.open(NEW_ISSUE_LINK, '_blank'); +} + +export function FileIssue(props: ActionProps): ReactNode { + return ; +} + +function reloadPage(): void { + window.location.reload(); +} + +export function ReloadPage(props: ActionProps): ReactNode { + return ; +} diff --git a/lib/static/new-ui/features/error-handling/components/ErrorHandling/context.ts b/lib/static/new-ui/features/error-handling/components/ErrorHandling/context.ts new file mode 100644 index 000000000..12c7e762b --- /dev/null +++ b/lib/static/new-ui/features/error-handling/components/ErrorHandling/context.ts @@ -0,0 +1,16 @@ +import React from 'react'; +import {ErrorContext} from './interfaces'; + +const Context = React.createContext(null); + +export const ErrorContextProvider = Context.Provider; + +export const useErrorContext = (): ErrorContext => { + const ctx = React.useContext(Context); + + if (ctx === null) { + throw new Error('useErrorContext must be used within ErrorContextProvider'); + } + + return ctx; +}; diff --git a/lib/static/new-ui/features/error-handling/components/ErrorHandling/fallbacks.tsx b/lib/static/new-ui/features/error-handling/components/ErrorHandling/fallbacks.tsx new file mode 100644 index 000000000..e635e365b --- /dev/null +++ b/lib/static/new-ui/features/error-handling/components/ErrorHandling/fallbacks.tsx @@ -0,0 +1,79 @@ +import {Divider, Link, Text} from '@gravity-ui/uikit'; +import classNames from 'classnames'; +import React, {ReactNode} from 'react'; +import TestplaneIcon from '../../../../../icons/testplane-mono-black.svg'; +import ExclamationTriangleLarge from '../../../../../icons/exclamation-triangle-large.svg'; +import {ErrorInfo as ErrorInfoFc} from '../../../../components/ErrorInfo'; +import styles from './index.module.css'; +import {useErrorContext} from './context'; +import {FileIssue, ReloadPage} from './actions'; +import {NEW_ISSUE_LINK} from '@/constants'; + +export function FallbackAppCrash(): ReactNode { + const {state} = useErrorContext(); + + return
+
+ icon + +
+ + Something went wrong + Testplane UI has crashed + +
+ + + +
+ +
+ + + +
+ + + We would appreciate a detailed
+ report with reproduction steps. +
+
+
; +} + +interface FallbackCardCrashProps { + recommendedAction?: ReactNode; +} + +export function FallbackCardCrash({recommendedAction}: FallbackCardCrashProps): ReactNode { + const {state} = useErrorContext(); + + return
+ icon + + Something went wrong + The data is corrupted or there’s a bug on our side + + + + {typeof recommendedAction === 'string' ? {recommendedAction} : recommendedAction} + + {recommendedAction &&
+ + OR + +
} + + +
; +} + +export function FallbackDataCorruption(): ReactNode { + const {state} = useErrorContext(); + + return
+ The data is corrupted or there’s a bug on our side. File an issue + + +
; +} diff --git a/lib/static/new-ui/features/error-handling/components/ErrorHandling/index.module.css b/lib/static/new-ui/features/error-handling/components/ErrorHandling/index.module.css new file mode 100644 index 000000000..1aac31875 --- /dev/null +++ b/lib/static/new-ui/features/error-handling/components/ErrorHandling/index.module.css @@ -0,0 +1,107 @@ +.crash-absolute-wrapper { + overflow: hidden; + position: absolute; + background: white; + top: 0; + left: 0; + right: 0; + bottom: 0; +} + +.crash { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + flex: 1; + gap: 8px; + width: 100%; + height: 100%; + margin: auto; + background: white; +} + +.lined { + padding: 16px; + max-width: 432px; + border: 1px solid rgba(0, 0, 0, 0.03); + border-top: none; + border-bottom: none; +} + +.divider { + position: relative; + width: 100vw; + height: 1px; + background: rgba(0, 0, 0, 0.03); +} + +.divider:after, .divider:before { + content: ''; + position: absolute; + top: -8px; + width: 17px; + height: 17px; + background: + linear-gradient( + to right, + transparent 0%, + transparent 8px, + rgba(0, 0, 0, 0.1) 8px, + rgba(0, 0, 0, 0.1) 9px, + transparent 9px, + transparent 100% + ), + linear-gradient( + to bottom, + transparent 0%, + transparent 8px, + rgba(0, 0, 0, 0.1) 8px, + rgba(0, 0, 0, 0.1) 9px, + transparent 9px, + transparent 100% + ); +} + +.divider:before { + left: calc(50% - 216px - 8px); +} + +.divider:after { + right: calc(50% - 216px - 8px); +} + +.crashCorruption { + gap: 0; + align-items: start; +} + +.pick-action-separator { + flex-direction: row; + display: flex; + width: 100%; + max-width: 400px; + gap: 8px; + align-items: center; +} + +.pick-action-separator-line { + flex: 1; +} + +.error-info { + max-width: 100%; + max-height: 700px; + color: white; + margin: 8px 0; + font-size: 13px; + overflow-y: auto; + scrollbar-color: white #101827; +} + +.action-row { + display: flex; + flex-direction: row; + width: 100%; + gap: 8px; +} \ No newline at end of file diff --git a/lib/static/new-ui/features/error-handling/components/ErrorHandling/index.tsx b/lib/static/new-ui/features/error-handling/components/ErrorHandling/index.tsx new file mode 100644 index 000000000..3f37a90a5 --- /dev/null +++ b/lib/static/new-ui/features/error-handling/components/ErrorHandling/index.tsx @@ -0,0 +1,9 @@ +import {Boundary} from './Boundary'; +import {FallbackAppCrash, FallbackCardCrash, FallbackDataCorruption} from './fallbacks'; + +export const ErrorHandler = { + Boundary, + FallbackAppCrash, + FallbackCardCrash, + FallbackDataCorruption +}; diff --git a/lib/static/new-ui/features/error-handling/components/ErrorHandling/interfaces.ts b/lib/static/new-ui/features/error-handling/components/ErrorHandling/interfaces.ts new file mode 100644 index 000000000..bfd315c78 --- /dev/null +++ b/lib/static/new-ui/features/error-handling/components/ErrorHandling/interfaces.ts @@ -0,0 +1,32 @@ +import {ReactNode, DependencyList, ErrorInfo} from 'react'; + +export interface BoundaryProps { + /** Node to display when falling */ + fallback?: ReactNode, + /** Changing this primitive will update the component forcibly if it crashed with an error. */ + watchFor?: DependencyList; + children?: ReactNode +} + +export interface BoundaryStateAlive { + hasError: false; + error: null; + errorInfo: null; +} + +export interface BoundaryStateDead { + hasError: true; + error: Error; + errorInfo: ErrorInfo; +} + +export interface BoundaryStateInternal { + watchFor?: DependencyList +} + +export type BoundaryState = (BoundaryStateAlive | BoundaryStateDead) & BoundaryStateInternal; + +export interface ErrorContext { + state: BoundaryStateDead; + restore(): void; +} diff --git a/lib/static/new-ui/features/suites/components/ScreenshotsTreeViewItem/index.tsx b/lib/static/new-ui/features/suites/components/ScreenshotsTreeViewItem/index.tsx index bb4c0ac92..ff1c7fed6 100644 --- a/lib/static/new-ui/features/suites/components/ScreenshotsTreeViewItem/index.tsx +++ b/lib/static/new-ui/features/suites/components/ScreenshotsTreeViewItem/index.tsx @@ -17,6 +17,7 @@ import {AssertViewStatus} from '@/static/new-ui/components/AssertViewStatus'; import styles from './index.module.css'; import {thunkAcceptImages, thunkRevertImages} from '@/static/modules/actions/screenshots'; import {useAnalytics} from '@/static/new-ui/hooks/useAnalytics'; +import {ErrorHandler} from '../../../error-handling/components/ErrorHandling'; interface ScreenshotsTreeViewItemProps { image: ImageEntity; @@ -95,6 +96,9 @@ export function ScreenshotsTreeViewItem(props: ScreenshotsTreeViewItemProps): Re }
}
} - + + }> + +
; } diff --git a/lib/static/new-ui/features/suites/components/SuitesPage/index.tsx b/lib/static/new-ui/features/suites/components/SuitesPage/index.tsx index 5960e32d9..b6cfb7f19 100644 --- a/lib/static/new-ui/features/suites/components/SuitesPage/index.tsx +++ b/lib/static/new-ui/features/suites/components/SuitesPage/index.tsx @@ -29,6 +29,7 @@ import {TreeActionsToolbar} from '@/static/new-ui/features/suites/components/Tre import {findTreeNodeById, getGroupId} from '@/static/new-ui/features/suites/utils'; import {TreeViewItemData} from '@/static/new-ui/features/suites/components/SuitesPage/types'; import {NEW_ISSUE_LINK} from '@/constants'; +import {ErrorHandler} from '../../../error-handling/components/ErrorHandling'; interface SuitesPageProps { actions: typeof actions; @@ -75,40 +76,60 @@ function SuitesPageInternal({currentResult, actions, treeNodeId}: SuitesPageProp }); }; - return
-

Suites

- - - - - - - {isInitialized && } - {!isInitialized && } - , - - {currentResult && <> -
- onPrevNextSuiteHandler(1)} - onPrevious={(): void => onPrevNextSuiteHandler(-1)} /> - -
- - -
} id={'overview'}/> - - } - {!suiteIdParam && !currentResult &&
Select a test to see details
} - {suiteIdParam && !isInitialized && } - - ]} />
; + return
+ + }> +

Suites

+ + + + + + + {isInitialized && } + {!isInitialized && } +
+ , + + + }> + {currentResult && <> +
+ onPrevNextSuiteHandler(1)} + onPrevious={(): void => onPrevNextSuiteHandler(-1)} /> + +
+ + + }> + + +
+ }/> + + }> + + + }/> + + } + + {!suiteIdParam && !currentResult &&
Select a test to see details
} + {suiteIdParam && !isInitialized && } + + + ]} /> + ; } export const SuitesPage = connect( diff --git a/lib/static/new-ui/features/suites/components/TestSteps/index.tsx b/lib/static/new-ui/features/suites/components/TestSteps/index.tsx index fe0e05ff4..7bebbb8f0 100644 --- a/lib/static/new-ui/features/suites/components/TestSteps/index.tsx +++ b/lib/static/new-ui/features/suites/components/TestSteps/index.tsx @@ -3,12 +3,11 @@ import { unstable_ListTreeItemType as ListTreeItemType, unstable_useList as useList } from '@gravity-ui/uikit/unstable'; -import {Paperclip} from '@gravity-ui/icons'; +import {CircleExclamation, Paperclip} from '@gravity-ui/icons'; import React, {ReactNode, useCallback} from 'react'; import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; -import {CollapsibleSection} from '@/static/new-ui/features/suites/components/CollapsibleSection'; import {TreeViewItemIcon} from '@/static/new-ui/components/TreeViewItemIcon'; import {TestStepArgs} from '@/static/new-ui/features/suites/components/TestStepArgs'; import {getIconByStatus} from '@/static/new-ui/utils'; @@ -23,6 +22,80 @@ import {Screenshot} from '@/static/new-ui/components/Screenshot'; import {getIndentStyle} from '@/static/new-ui/features/suites/components/TestSteps/utils'; import {isErrorStatus, isFailStatus} from '@/common-utils'; import {ScreenshotsTreeViewItem} from '@/static/new-ui/features/suites/components/ScreenshotsTreeViewItem'; +import {UseListResult} from '@gravity-ui/uikit/build/esm/components/useList'; +import {ErrorHandler} from '../../../error-handling/components/ErrorHandling'; + +type TestStepClickHandler = (item: {id: string}) => void + +interface TestStepProps { + items: UseListResult; + itemId: string; +} + +interface TestStepPropsActionable extends TestStepProps { + onItemClick: TestStepClickHandler; + +} + +function ListItemCorrupted({items, itemId}: TestStepProps): ReactNode { + return ({ + title:
+ Couldn’t display this item: data is corrupted. See console for details. +
, + startSlot: + + + }) + } + />; +} + +function TestStep({onItemClick, items, itemId}: TestStepPropsActionable): ReactNode { + const item = items.structure.itemsById[itemId]; + + if (item.type === StepType.Action) { + const shouldHighlightFail = (isErrorStatus(item.status) || isFailStatus(item.status)) && !item.isGroup; + + return { + return { + title:
+ {item.title} + + {item.duration !== undefined && {item.duration} ms} +
, + startSlot: {getIconByStatus(item.status)} + }; + }}/>; + } + + if (item.type === StepType.Attachment) { + return { + return { + title: item.title, + startSlot: + }; + }}/>; + } + + if (item.type === StepType.ErrorInfo) { + const indent = items.structure.itemsState[itemId].indentation; + return ; + } + + if (item.type === StepType.SingleImage) { + return ; + } + + if (item.type === StepType.AssertViewResult) { + return ; + } + + // @ts-expect-error all types should be handled here + throw new Error(`Unknown step type: ${item.type}`); +} interface TestStepsProps { resultId: string; @@ -55,48 +128,16 @@ function TestStepsInternal(props: TestStepsProps): ReactNode { }); }, [items, props.actions, props.stepsExpandedById]); - return - {items.structure.visibleFlattenIds.map(itemId => { - const item = items.structure.itemsById[itemId]; - - if (item.type === StepType.Action) { - const shouldHighlightFail = (isErrorStatus(item.status) || isFailStatus(item.status)) && !item.isGroup; - - return { - return { - title:
- {item.title} - - {item.duration !== undefined && {item.duration} ms} -
, - startSlot: {getIconByStatus(item.status)} - }; - }}/>; - } else if (item.type === StepType.Attachment) { - return { - return { - title: item.title, - startSlot: - }; - }}/>; - } else if (item.type === StepType.ErrorInfo) { - const indent = items.structure.itemsState[itemId].indentation; - return ; - } else if (item.type === StepType.SingleImage) { - return ; - } else if (item.type === StepType.AssertViewResult) { - return ; - } - - return null; - })} - - } />; + return + {items.structure.visibleFlattenIds.map(itemId => + ( + }> + + + ) + )} + ; } - export const TestSteps = connect(state => ({ resultId: getCurrentResultId(state) ?? '', testSteps: getTestSteps(state), diff --git a/lib/static/new-ui/features/visual-checks/components/VisualChecksPage/VisualChecksStickyHeader.tsx b/lib/static/new-ui/features/visual-checks/components/VisualChecksPage/VisualChecksStickyHeader.tsx new file mode 100644 index 000000000..6a92b9a83 --- /dev/null +++ b/lib/static/new-ui/features/visual-checks/components/VisualChecksPage/VisualChecksStickyHeader.tsx @@ -0,0 +1,145 @@ +import {ArrowUturnCcwLeft, Check} from '@gravity-ui/icons'; +import {Button, Divider, Icon, Select} from '@gravity-ui/uikit'; +import React, {ReactNode, useEffect, useRef} from 'react'; +import {useDispatch, useSelector} from 'react-redux'; + +import { + getCurrentImage, getVisibleNamedImageIds, + getImagesByNamedImageIds, + NamedImageEntity +} from '@/static/new-ui/features/visual-checks/selectors'; +import {SuiteTitle} from '@/static/new-ui/components/SuiteTitle'; +import styles from './index.module.css'; +import {CompactAttemptPicker} from '@/static/new-ui/components/CompactAttemptPicker'; +import {DiffModeId, DiffModes, EditScreensFeature} from '@/constants'; +import { + setDiffMode, + staticAccepterStageScreenshot, staticAccepterUnstageScreenshot, + visualChecksPageSetCurrentNamedImage +} from '@/static/modules/actions'; +import {isAcceptable, isScreenRevertable} from '@/static/modules/utils'; +import {AssertViewStatus} from '@/static/new-ui/components/AssertViewStatus'; +import {thunkAcceptImages, thunkRevertImages} from '@/static/modules/actions/screenshots'; +import {useAnalytics} from '@/static/new-ui/hooks/useAnalytics'; + +import {preloadImageEntity} from '../../../../../modules/utils/imageEntity'; + +interface VisualChecksStickyHeaderProps { + currentNamedImage: NamedImageEntity | null; +} + +export const PRELOAD_IMAGES_COUNT = 3; + +const usePreloadImages = ( + currentNamedImageIndex: number, + visibleNamedImageIds: string[]): void => { + const preloaded = useRef void | undefined>>({}); + + const namedImageIdsToPreload: string[] = visibleNamedImageIds.slice( + Math.max(0, currentNamedImageIndex - 1 - PRELOAD_IMAGES_COUNT), + Math.min(visibleNamedImageIds.length, currentNamedImageIndex + 1 + PRELOAD_IMAGES_COUNT) + ); + + const imagesToPreload = useSelector((state) => getImagesByNamedImageIds(state, namedImageIdsToPreload)); + + useEffect(() => { + imagesToPreload.forEach(image => { + preloaded.current[image.id] = preloadImageEntity(image); + }); + }, [currentNamedImageIndex]); + + useEffect(() => () => { + Object.values(preloaded.current).forEach(disposeCallback => disposeCallback?.()); + }, []); +}; + +export function VisualChecksStickyHeader({currentNamedImage}: VisualChecksStickyHeaderProps): ReactNode { + const dispatch = useDispatch(); + + const analytics = useAnalytics(); + + const currentImage = useSelector(getCurrentImage); + + const visibleNamedImageIds = useSelector(getVisibleNamedImageIds); + + const currentNamedImageIndex = visibleNamedImageIds.indexOf(currentNamedImage?.id as string); + const onPreviousImageHandler = (): void => void dispatch(visualChecksPageSetCurrentNamedImage(visibleNamedImageIds[currentNamedImageIndex - 1])); + const onNextImageHandler = (): void => void dispatch(visualChecksPageSetCurrentNamedImage(visibleNamedImageIds[currentNamedImageIndex + 1])); + + usePreloadImages(currentNamedImageIndex, visibleNamedImageIds); + + const diffMode = useSelector(state => state.view.diffMode); + const onChangeHandler = (diffModeId: DiffModeId): void => { + dispatch(setDiffMode({diffModeId})); + }; + + const isStaticImageAccepterEnabled = useSelector(state => state.staticImageAccepter.enabled); + const isEditScreensAvailable = useSelector(state => state.app.availableFeatures) + .find(feature => feature.name === EditScreensFeature.name); + const isRunning = useSelector(state => state.running); + const isProcessing = useSelector(state => state.processing); + const isGui = useSelector(state => state.gui); + + const onScreenshotAccept = (): void => { + if (!currentImage) { + return; + } + analytics?.trackScreenshotsAccept(); + + if (isStaticImageAccepterEnabled) { + dispatch(staticAccepterStageScreenshot([currentImage.id])); + } else { + dispatch(thunkAcceptImages({imageIds: [currentImage.id]})); + } + }; + const onScreenshotUndo = (): void => { + if (!currentImage) { + return; + } + + if (isStaticImageAccepterEnabled) { + dispatch(staticAccepterUnstageScreenshot([currentImage.id])); + } else { + dispatch(thunkRevertImages({imageIds: [currentImage.id]})); + } + }; + + const currentBrowserId = useSelector(state => state.tree.results.byId[currentImage?.parentId ?? '']?.parentId); + const currentBrowser = useSelector(state => currentBrowserId && state.tree.browsers.byId[currentBrowserId]); + + const currentResultId = currentImage?.parentId; + const isLastResult = Boolean(currentResultId && currentBrowser && currentResultId === currentBrowser.resultIds[currentBrowser.resultIds.length - 1]); + const isUndoAvailable = isScreenRevertable({gui: isGui, image: currentImage ?? {}, isLastResult, isStaticImageAccepterEnabled}); + + return
+ {currentNamedImage && + } + +
+ + + + + + + {isEditScreensAvailable &&
+ {isUndoAvailable && } + {currentImage && isAcceptable(currentImage) && } +
} +
+
; +} diff --git a/lib/static/new-ui/features/visual-checks/components/VisualChecksPage/index.tsx b/lib/static/new-ui/features/visual-checks/components/VisualChecksPage/index.tsx index e745a34a1..ec9763945 100644 --- a/lib/static/new-ui/features/visual-checks/components/VisualChecksPage/index.tsx +++ b/lib/static/new-ui/features/visual-checks/components/VisualChecksPage/index.tsx @@ -1,155 +1,44 @@ -import {ArrowUturnCcwLeft, Check} from '@gravity-ui/icons'; -import {Button, Divider, Icon, Select} from '@gravity-ui/uikit'; import classNames from 'classnames'; -import React, {ReactNode, useEffect, useRef} from 'react'; -import {useDispatch, useSelector} from 'react-redux'; +import React, {ReactNode} from 'react'; +import {useSelector} from 'react-redux'; import {SplitViewLayout} from '@/static/new-ui/components/SplitViewLayout'; import {UiCard} from '@/static/new-ui/components/Card/UiCard'; import { getCurrentImage, - getCurrentNamedImage, - getImagesByNamedImageIds, - getVisibleNamedImageIds + getCurrentNamedImage } from '@/static/new-ui/features/visual-checks/selectors'; -import {SuiteTitle} from '@/static/new-ui/components/SuiteTitle'; import {AssertViewResult} from '@/static/new-ui/components/AssertViewResult'; import styles from './index.module.css'; -import {CompactAttemptPicker} from '@/static/new-ui/components/CompactAttemptPicker'; -import {DiffModeId, DiffModes, EditScreensFeature} from '@/constants'; -import { - setDiffMode, - staticAccepterStageScreenshot, staticAccepterUnstageScreenshot, - visualChecksPageSetCurrentNamedImage -} from '@/static/modules/actions'; -import {isAcceptable, isScreenRevertable} from '@/static/modules/utils'; -import {AssertViewStatus} from '@/static/new-ui/components/AssertViewStatus'; import { AssertViewResultSkeleton } from '@/static/new-ui/features/visual-checks/components/VisualChecksPage/AssertViewResultSkeleton'; -import {thunkAcceptImages, thunkRevertImages} from '@/static/modules/actions/screenshots'; -import {useAnalytics} from '@/static/new-ui/hooks/useAnalytics'; -import {preloadImageEntity} from '../../../../../modules/utils/imageEntity'; - -export const PRELOAD_IMAGES_COUNT = 3; - -const usePreloadImages = ( - currentNamedImageIndex: number, - visibleNamedImageIds: string[]): void => { - const preloaded = useRef void | undefined>>({}); - - const namedImageIdsToPreload: string[] = visibleNamedImageIds.slice( - Math.max(0, currentNamedImageIndex - 1 - PRELOAD_IMAGES_COUNT), - Math.min(visibleNamedImageIds.length, currentNamedImageIndex + 1 + PRELOAD_IMAGES_COUNT) - ); - - const imagesToPreload = useSelector((state) => getImagesByNamedImageIds(state, namedImageIdsToPreload)); - - useEffect(() => { - imagesToPreload.forEach(image => { - preloaded.current[image.id] = preloadImageEntity(image); - }); - }, [currentNamedImageIndex]); - - useEffect(() => () => { - Object.values(preloaded.current).forEach(disposeCallback => disposeCallback?.()); - }, []); -}; +import {VisualChecksStickyHeader} from './VisualChecksStickyHeader'; +import {ErrorHandler} from '../../../error-handling/components/ErrorHandling'; export function VisualChecksPage(): ReactNode { - const dispatch = useDispatch(); - const analytics = useAnalytics(); - const currentNamedImage = useSelector(getCurrentNamedImage); const currentImage = useSelector(getCurrentImage); - const visibleNamedImageIds = useSelector(getVisibleNamedImageIds); - - const currentNamedImageIndex = visibleNamedImageIds.indexOf(currentNamedImage?.id as string); - const onPreviousImageHandler = (): void => void dispatch(visualChecksPageSetCurrentNamedImage(visibleNamedImageIds[currentNamedImageIndex - 1])); - const onNextImageHandler = (): void => void dispatch(visualChecksPageSetCurrentNamedImage(visibleNamedImageIds[currentNamedImageIndex + 1])); - - usePreloadImages(currentNamedImageIndex, visibleNamedImageIds); - - const diffMode = useSelector(state => state.view.diffMode); - const onChangeHandler = (diffModeId: DiffModeId): void => { - dispatch(setDiffMode({diffModeId})); - }; - - const isStaticImageAccepterEnabled = useSelector(state => state.staticImageAccepter.enabled); - const isEditScreensAvailable = useSelector(state => state.app.availableFeatures) - .find(feature => feature.name === EditScreensFeature.name); - const isRunning = useSelector(state => state.running); - const isProcessing = useSelector(state => state.processing); - const isGui = useSelector(state => state.gui); - - const onScreenshotAccept = (): void => { - if (!currentImage) { - return; - } - analytics?.trackScreenshotsAccept(); - - if (isStaticImageAccepterEnabled) { - dispatch(staticAccepterStageScreenshot([currentImage.id])); - } else { - dispatch(thunkAcceptImages({imageIds: [currentImage.id]})); - } - }; - const onScreenshotUndo = (): void => { - if (!currentImage) { - return; - } - - if (isStaticImageAccepterEnabled) { - dispatch(staticAccepterUnstageScreenshot([currentImage.id])); - } else { - dispatch(thunkRevertImages({imageIds: [currentImage.id]})); - } - }; - - const currentBrowserId = useSelector(state => state.tree.results.byId[currentImage?.parentId ?? '']?.parentId); - const currentBrowser = useSelector(state => currentBrowserId && state.tree.browsers.byId[currentBrowserId]); - - const currentResultId = currentImage?.parentId; - const isLastResult = Boolean(currentResultId && currentBrowser && currentResultId === currentBrowser.resultIds[currentBrowser.resultIds.length - 1]); - const isUndoAvailable = isScreenRevertable({gui: isGui, image: currentImage ?? {}, isLastResult, isStaticImageAccepterEnabled}); const isInitialized = useSelector(state => state.app.isInitialized); - return
- {isInitialized && <>
- {currentNamedImage && } -
- - - - - - {isEditScreensAvailable &&
- {isUndoAvailable && } - {currentImage && isAcceptable(currentImage) && } -
} -
-
- {currentImage && } - {!currentImage &&
This run doesn't have an image with - name "{currentNamedImage?.stateName}"
} - } - {!isInitialized && } - - ]}/>
; + return
+ + }> + {isInitialized + ? <> + {currentNamedImage && } + + {currentImage && }> + + } + + {!currentImage &&
This run doesn't have an image with name "{currentNamedImage?.stateName}"
} + + : } +
+ + ]}/> +
; } diff --git a/test/setup/globals.js b/test/setup/globals.js index 2726e7fa4..4cc9269a2 100644 --- a/test/setup/globals.js +++ b/test/setup/globals.js @@ -15,6 +15,7 @@ global.assert = chai.assert; require.extensions['.styl'] = () => {}; require.extensions['.css'] = () => {}; require.extensions['.less'] = () => {}; +require.extensions['.svg'] = () => {}; require.extensions['.module.css'] = function(module) { module.exports = require('./css-modules-mock').cssModulesMock; }; diff --git a/test/unit/lib/static/new-ui/features/visual-checks/components/VisualChecksPage.jsx b/test/unit/lib/static/new-ui/features/visual-checks/components/VisualChecksStickyHeader.jsx similarity index 86% rename from test/unit/lib/static/new-ui/features/visual-checks/components/VisualChecksPage.jsx rename to test/unit/lib/static/new-ui/features/visual-checks/components/VisualChecksStickyHeader.jsx index 2810d0135..072f25f21 100644 --- a/test/unit/lib/static/new-ui/features/visual-checks/components/VisualChecksPage.jsx +++ b/test/unit/lib/static/new-ui/features/visual-checks/components/VisualChecksStickyHeader.jsx @@ -2,7 +2,7 @@ import React from 'react'; import {addBrowserToTree, addImageToTree, addResultToTree, addSuiteToTree, mkBrowserEntity, mkEmptyTree, mkImageEntityFail, mkRealStore, mkResultEntity, mkSuiteEntityLeaf, renderWithStore} from '../../../../utils'; import proxyquire from 'proxyquire'; -describe('', () => { +describe('', () => { const sandbox = sinon.sandbox.create(); const prepareTestStore = () => { @@ -42,11 +42,11 @@ describe('', () => { store = prepareTestStore(); - const VisualChecksPage = proxyquire('lib/static/new-ui/features/visual-checks/components/VisualChecksPage', { + const VisualChecksStickyHeader = proxyquire('lib/static/new-ui/features/visual-checks/components/VisualChecksPage/VisualChecksStickyHeader', { '../../../../../modules/utils/imageEntity': {preloadImageEntity: preloadImageEntityStub} - }).VisualChecksPage; + }).VisualChecksStickyHeader; - renderWithStore(, store); + renderWithStore(, store); }); afterEach(() => {