Skip to content

Commit

Permalink
Users/mordvinx/testplane 404.new UI error boundaries (#630)
Browse files Browse the repository at this point in the history
* 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: = <=>
  • Loading branch information
mordvinx authored Jan 24, 2025
1 parent d057ffc commit f92dee5
Show file tree
Hide file tree
Showing 24 changed files with 776 additions and 249 deletions.
5 changes: 5 additions & 0 deletions lib/static/icons/exclamation-triangle-large.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions lib/static/icons/github-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions lib/static/icons/testplane-mono-black.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions lib/static/new-ui.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
55 changes: 34 additions & 21 deletions lib/static/new-ui/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand All @@ -33,26 +34,38 @@ export function App(): ReactNode {
const customScripts = (store.getState() as State).config.customScripts;

return <StrictMode>
<CustomScripts scripts={customScripts} />
<ThemeProvider theme='light'>
<ToasterProvider>
<Provider store={store}>
<MetrikaScript/>
<AnalyticsProvider>
<HashRouter>
<MainLayout menuItems={pages}>
<LoadingBar/>
<Routes>
<Route element={<Navigate to={'/suites'}/>} path={'/'}/>
{pages.map(page => <Route element={page.element} path={page.url} key={page.url}>{page.children}</Route>)}
</Routes>
<GuiniToolbarOverlay/>
<ToasterComponent />
</MainLayout>
</HashRouter>
</AnalyticsProvider>
</Provider>
</ToasterProvider>
</ThemeProvider>
<ErrorHandler.Boundary fallback={<ErrorHandler.FallbackAppCrash />}>
<CustomScripts scripts={customScripts} />
<ThemeProvider theme='light'>
<ToasterProvider>
<Provider store={store}>
<MetrikaScript/>
<AnalyticsProvider>
<HashRouter>
<ErrorHandler.Boundary fallback={<ErrorHandler.FallbackAppCrash />}>
<MainLayout menuItems={pages}>
<LoadingBar/>
<Routes>
<Route element={<Navigate to={'/suites'}/>} path={'/'}/>
{pages.map(page => (
<Route element={
<ErrorHandler.Boundary watchFor={[page.url]} fallback={<ErrorHandler.FallbackAppCrash />}>
{ page.element}
</ErrorHandler.Boundary>
} path={page.url} key={page.url}>
{page.children}
</Route>
))}
</Routes>
<GuiniToolbarOverlay/>
<ToasterComponent />
</MainLayout>
</ErrorHandler.Boundary>
</HashRouter>
</AnalyticsProvider>
</Provider>
</ToasterProvider>
</ThemeProvider>
</ErrorHandler.Boundary>
</StrictMode>;
}
16 changes: 12 additions & 4 deletions lib/static/new-ui/components/AssertViewResult/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,30 @@ interface AssertViewResultProps {
function AssertViewResultInternal({result, diffMode, style}: AssertViewResultProps): ReactNode {
if (result.status === TestStatus.FAIL) {
return <DiffViewer diffMode={diffMode} {...result} />;
} else if (result.status === TestStatus.ERROR) {
}

if (result.status === TestStatus.ERROR) {
return <div className={styles.screenshotContainer}>
<ImageLabel title={'Actual'} subtitle={getImageDisplayedSize(result.actualImg)} />
<Screenshot containerStyle={style} containerClassName={styles.screenshot} image={result.actualImg} />
</div>;
} else if (result.status === TestStatus.SUCCESS || result.status === TestStatus.UPDATED) {
}

if (result.status === TestStatus.SUCCESS || result.status === TestStatus.UPDATED) {
return <div className={styles.screenshotContainer}>
<ImageLabel title={'Expected'} subtitle={getImageDisplayedSize(result.expectedImg)} />
<Screenshot containerStyle={style} containerClassName={styles.screenshot} image={result.expectedImg} />
</div>;
} else if (result.status === TestStatus.STAGED) {
}

if (result.status === TestStatus.STAGED) {
return <div className={styles.screenshotContainer}>
<ImageLabel title={'Staged'} subtitle={getImageDisplayedSize(result.actualImg)} />
<Screenshot containerStyle={style} containerClassName={styles.screenshot} image={result.actualImg} />
</div>;
} else if (result.status === TestStatus.COMMITED) {
}

if (result.status === TestStatus.COMMITED) {
return <div className={styles.screenshotContainer}>
<ImageLabel title={'Committed'} subtitle={getImageDisplayedSize(result.actualImg)} />
<Screenshot containerStyle={style} containerClassName={styles.screenshot} image={result.actualImg} />
Expand Down
2 changes: 1 addition & 1 deletion lib/static/new-ui/components/ErrorInfo/index.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
5 changes: 1 addition & 4 deletions lib/static/new-ui/components/SuiteTitle/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@ import styles from './index.module.css';

interface SuiteTitleProps {
className?: string;
}

interface SuiteTitlePropsInternal extends SuiteTitleProps {
suitePath: string[];
browserName: string;
stateName?: string;
Expand All @@ -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);

Expand Down
10 changes: 10 additions & 0 deletions lib/static/new-ui/components/TreeViewItem/index.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,20 @@
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 {
--g-color-base-simple-hover: var(--g-color-private-red-50);
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);
}
5 changes: 3 additions & 2 deletions lib/static/new-ui/components/TreeViewItem/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ interface TreeListItemProps<T> {
id: string;
list: UseListResult<T>;
mapItemDataToContentProps: (data: T) => ListItemViewContentType;
isFailed?: boolean;
status?: 'error' | 'corrupted';
onItemClick?: (data: {id: string}) => unknown;
}

Expand All @@ -34,7 +34,8 @@ export function TreeViewItem<T>(props: TreeListItemProps<T>): ReactNode {
>
<ListItemView
className={classNames([styles.treeViewItem, {
[styles['tree-view-item--error']]: props.isFailed
[styles['tree-view-item--corrupted']]: props.status === 'corrupted',
[styles['tree-view-item--error']]: props.status === 'error'
}])}
activeOnHover={true}
style={{'--indent': indent + Number(!hasChildren)} as React.CSSProperties}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import React, {Component, DependencyList, ErrorInfo, ReactNode} from 'react';
import {BoundaryProps, BoundaryState} from './interfaces';
import {ErrorContextProvider} from './context';

export class Boundary extends Component<BoundaryProps, BoundaryState> {
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 (
<ErrorContextProvider value={{state: this.state, restore: this.restore.bind(this)}}>
{this.props.fallback}
</ErrorContextProvider>
);
}

return this.props.children;
}
}
Original file line number Diff line number Diff line change
@@ -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<ButtonProps, 'view' | 'onClick'>;

function reportIssue(): void {
window.open(NEW_ISSUE_LINK, '_blank');
}

export function FileIssue(props: ActionProps): ReactNode {
return <Button {...props} view="outlined" onClick={reportIssue}>
<Button.Icon>
<img src={GithubIcon} alt="icon" width={17} height={17} />
</Button.Icon>

File an issue
</Button>;
}

function reloadPage(): void {
window.location.reload();
}

export function ReloadPage(props: ActionProps): ReactNode {
return <Button {...props} view="outlined" onClick={reloadPage}>
<Icon data={ArrowsRotateLeft} />
Refresh this page
</Button>;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React from 'react';
import {ErrorContext} from './interfaces';

const Context = React.createContext<ErrorContext| null>(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;
};
Loading

0 comments on commit f92dee5

Please sign in to comment.