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
+
+
+
+
+ File an issue
+ ;
+}
+
+function reloadPage(): void {
+ window.location.reload();
+}
+
+export function ReloadPage(props: ActionProps): ReactNode {
+ return
+
+ Refresh this page
+ ;
+}
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
+
+
+
+
+
+
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
+
+
+
Something went wrong
+
The data is corrupted or there’s a bug on our side
+
+
+
+ {typeof recommendedAction === 'string' ?
{recommendedAction} : recommendedAction}
+
+ {recommendedAction &&
}
+
+
+
;
+}
+
+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 &&
+ }
+
+
+
+
+
+
+ onChangeHandler(diffMode as DiffModeId)} multiple={false}>
+ {Object.values(DiffModes).map(diffMode =>
+
+ )}
+
+
+ {isEditScreensAvailable &&
+ {isUndoAvailable && Undo }
+ {currentImage && isAcceptable(currentImage) && Accept }
+
}
+
+ ;
+}
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 &&
}
-
-
-
-
-
- onChangeHandler(diffMode as DiffModeId)} multiple={false}>
- {Object.values(DiffModes).map(diffMode =>
-
- )}
-
- {isEditScreensAvailable &&
- {isUndoAvailable && Undo }
- {currentImage && isAcceptable(currentImage) && Accept }
-
}
-
-
- {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(() => {