Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions src/ui/components/ErrorBoundary/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import React, { Component, ErrorInfo, PropsWithChildren, ReactNode, useState } from 'react';
import Paper from '@material-ui/core/Paper';
import Typography from '@material-ui/core/Typography';
import Button from '@material-ui/core/Button';
import Collapse from '@material-ui/core/Collapse';
import { makeStyles } from '@material-ui/core/styles';

const IS_DEV = process.env.NODE_ENV !== 'production';

const useStyles = makeStyles((theme) => ({
wrapper: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
minHeight: '60vh',
padding: theme.spacing(2),
},
root: {
padding: theme.spacing(4),
borderLeft: `4px solid ${theme.palette.error.main}`,
maxWidth: 560,
width: '100%',
},
title: {
color: theme.palette.error.main,
marginBottom: theme.spacing(1),
},
message: {
marginBottom: theme.spacing(2),
color: theme.palette.text.secondary,
},
hint: {
marginBottom: theme.spacing(2),
color: theme.palette.text.secondary,
fontStyle: 'italic',
},
actions: {
display: 'flex',
gap: theme.spacing(1),
alignItems: 'center',
marginBottom: theme.spacing(1),
},
stack: {
marginTop: theme.spacing(2),
padding: theme.spacing(2),
backgroundColor: theme.palette.grey[100],
borderRadius: theme.shape.borderRadius,
overflowX: 'auto',
fontSize: '0.75rem',
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
},
devBadge: {
display: 'inline-block',
marginBottom: theme.spacing(2),
padding: '2px 8px',
backgroundColor: theme.palette.warning.main,
color: theme.palette.warning.contrastText,
borderRadius: theme.shape.borderRadius,
fontSize: '0.7rem',
fontWeight: 700,
letterSpacing: '0.05em',
textTransform: 'uppercase',
},
}));

const ProdFallback = ({ reset }: { reset: () => void }) => {
const classes = useStyles();
return (
<div className={classes.wrapper}>
<Paper className={classes.root} role='alert' elevation={0} variant='outlined'>
<Typography variant='h6' className={classes.title}>
Something went wrong
</Typography>
<Typography variant='body2' className={classes.message}>
An unexpected error occurred. Please try again — if the problem persists, contact your
administrator.
</Typography>
<div className={classes.actions}>
<Button variant='outlined' size='small' color='primary' onClick={reset}>
Retry
</Button>
<Button size='small' onClick={() => window.location.reload()}>
Reload page
</Button>
</div>
</Paper>
</div>
);
};

const DevFallback = ({
error,
name,
reset,
}: {
error: Error;
name?: string;
reset: () => void;
}) => {
const classes = useStyles();
const [showDetails, setShowDetails] = useState(false);
const context = name ? ` in ${name}` : '';

return (
<div className={classes.wrapper}>
<Paper className={classes.root} role='alert' elevation={0} variant='outlined'>
<div className={classes.devBadge}>dev</div>
<Typography variant='h6' className={classes.title}>
Something went wrong{context}
</Typography>
<Typography variant='body2' className={classes.message}>
{error.message}
</Typography>
<div className={classes.actions}>
<Button variant='outlined' size='small' color='primary' onClick={reset}>
Retry
</Button>
{error.stack && (
<Button size='small' onClick={() => setShowDetails((v) => !v)}>
{showDetails ? 'Hide stack trace' : 'Show stack trace'}
</Button>
)}
</div>
{error.stack && (
<Collapse in={showDetails}>
<pre className={classes.stack}>{error.stack}</pre>
</Collapse>
)}
</Paper>
</div>
);
};

type Props = PropsWithChildren<{
name?: string;
fallback?: (error: Error, reset: () => void) => ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}>;

type State = { error: Error | undefined };

export class ErrorBoundary extends Component<Props, State> {
state: State = { error: undefined };

static getDerivedStateFromError(error: Error): State {
return { error };
}

componentDidCatch(error: Error, errorInfo: ErrorInfo) {
this.props.onError?.(error, errorInfo);
if (IS_DEV) {
console.error('ErrorBoundary caught:', error, errorInfo);
}
}

reset = () => this.setState({ error: undefined });

render() {
const { error } = this.state;
const { children, fallback, name } = this.props;

if (error) {
if (fallback) return fallback(error, this.reset);
return IS_DEV ? (
<DevFallback error={error} name={name} reset={this.reset} />
) : (
<ProdFallback reset={this.reset} />
);
}

return children;
}
}
23 changes: 13 additions & 10 deletions src/ui/layouts/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { UserContext } from '../context';
import { getUser } from '../services/user';
import { Route as RouteType } from '../types';
import { PublicUser } from '../../db/types';
import { ErrorBoundary } from '../components/ErrorBoundary/ErrorBoundary';

interface DashboardProps {
[key: string]: any;
Expand Down Expand Up @@ -95,16 +96,18 @@ const Dashboard: React.FC<DashboardProps> = ({ ...rest }) => {
/>
<div className={classes.mainPanel} ref={mainPanel}>
<Navbar routes={routes} handleDrawerToggle={handleDrawerToggle} {...rest} />
{isMapRoute() ? (
<div className={classes.map}>{switchRoutes}</div>
) : (
<>
<div className={classes.content}>
<div className={classes.container}>{switchRoutes}</div>
</div>
<Footer />
</>
)}
<ErrorBoundary name='Dashboard'>
{isMapRoute() ? (
<div className={classes.map}>{switchRoutes}</div>
) : (
<>
<div className={classes.content}>
<div className={classes.container}>{switchRoutes}</div>
</div>
<Footer />
</>
)}
</ErrorBoundary>
</div>
</div>
</UserContext.Provider>
Expand Down
13 changes: 11 additions & 2 deletions src/ui/services/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ interface AxiosConfig {
};
}

const IS_DEV = process.env.NODE_ENV !== 'production';

/**
* Gets the current user's information
*/
Expand All @@ -20,10 +22,17 @@ export const getUserInfo = async (): Promise<PublicUser | null> => {
const response = await fetch(`${baseUrl}/api/auth/profile`, {
credentials: 'include', // Sends cookies
});
if (!response.ok) throw new Error(`Failed to fetch user info: ${response.statusText}`);
if (!response.ok) {
if (response.status === 401) {
return null;
}
throw new Error(`Failed to fetch user info: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('Error fetching user info:', error);
if (IS_DEV) {
console.warn('Error fetching user info:', error);
}
return null;
}
};
Expand Down
37 changes: 37 additions & 0 deletions src/ui/services/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export interface ServiceResult<T = void> {
success: boolean;
status?: number;
message?: string;
data?: T;
}

export const getServiceError = (
error: any,
fallbackMessage: string,
): { status?: number; message: string } => {
const status = error?.response?.status;
const responseMessage = error?.response?.data?.message;
const message =
typeof responseMessage === 'string' && responseMessage.trim().length > 0
? responseMessage
: status
? `Unknown error occurred, response code: ${status}`
: error?.message || fallbackMessage;
return { status, message };
};

export const formatErrorMessage = (
prefix: string,
status: number | undefined,
message: string,
): string => `${prefix}: ${status ? `${status} ` : ''}${message}`;

export const errorResult = <T = void>(error: any, fallbackMessage: string): ServiceResult<T> => {
const { status, message } = getServiceError(error, fallbackMessage);
return { success: false, status, message };
};

export const successResult = <T = void>(data?: T): ServiceResult<T> => ({
success: true,
...(data !== undefined && { data }),
});
Loading
Loading