[PUI] Error boundary (#7176)

* Create error boundary component

- Ref: https://docs.sentry.io/platforms/javascript/guides/react/features/error-boundary/
- Keeps errors container to local components
- Will be critical for plugin support

* Add boundary to API forms
This commit is contained in:
Oliver 2024-05-08 07:32:01 +10:00 committed by GitHub
parent c7351c4064
commit 108bd28102
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 363 additions and 293 deletions

View File

@ -0,0 +1,44 @@
import { t } from '@lingui/macro';
import { Alert } from '@mantine/core';
import { ErrorBoundary, FallbackRender } from '@sentry/react';
import { IconExclamationCircle } from '@tabler/icons-react';
import { ReactNode, useCallback } from 'react';
function DefaultFallback({ title }: { title: String }): ReactNode {
return (
<Alert
color="red"
icon={<IconExclamationCircle />}
title={t`Error rendering component` + `: ${title}`}
>
{t`An error occurred while rendering this component. Refer to the console for more information.`}
</Alert>
);
}
export function Boundary({
children,
label,
fallback
}: {
children: ReactNode;
label: string;
fallback?: React.ReactElement | FallbackRender | undefined;
}): ReactNode {
const onError = useCallback(
(error: Error, componentStack: string, eventId: string) => {
console.error(`Error rendering component: ${label}`);
console.error(error, componentStack);
},
[]
);
return (
<ErrorBoundary
fallback={fallback ?? <DefaultFallback title={label} />}
onError={onError}
>
{children}
</ErrorBoundary>
);
}

View File

@ -36,6 +36,7 @@ import {
import { invalidResponse } from '../../functions/notifications';
import { getDetailUrl } from '../../functions/urls';
import { PathParams } from '../../states/ApiState';
import { Boundary } from '../Boundary';
import {
ApiFormField,
ApiFormFieldSet,
@ -472,81 +473,89 @@ export function ApiForm({
return (
<Stack>
{/* Show loading overlay while fetching fields */}
{/* zIndex used to force overlay on top of modal header bar */}
<LoadingOverlay visible={isLoading} zIndex={1010} />
<Boundary label={`ApiForm-${id}`}>
{/* Show loading overlay while fetching fields */}
{/* zIndex used to force overlay on top of modal header bar */}
<LoadingOverlay visible={isLoading} zIndex={1010} />
{/* Attempt at making fixed footer with scroll area */}
<Paper mah={'65vh'} style={{ overflowY: 'auto' }}>
<div>
{/* Form Fields */}
<Stack spacing="sm">
{(!isValid || nonFieldErrors.length > 0) && (
<Alert radius="sm" color="red" title={t`Form Errors Exist`}>
{nonFieldErrors.length > 0 && (
<Stack spacing="xs">
{nonFieldErrors.map((message) => (
<Text key={message}>{message}</Text>
))}
</Stack>
{/* Attempt at making fixed footer with scroll area */}
<Paper mah={'65vh'} style={{ overflowY: 'auto' }}>
<div>
{/* Form Fields */}
<Stack spacing="sm">
{(!isValid || nonFieldErrors.length > 0) && (
<Alert radius="sm" color="red" title={t`Form Errors Exist`}>
{nonFieldErrors.length > 0 && (
<Stack spacing="xs">
{nonFieldErrors.map((message) => (
<Text key={message}>{message}</Text>
))}
</Stack>
)}
</Alert>
)}
<Boundary label={`ApiForm-${id}-PreFormContent`}>
{props.preFormContent}
{props.preFormSuccess && (
<Alert color="green" radius="sm">
{props.preFormSuccess}
</Alert>
)}
</Alert>
)}
{props.preFormContent}
{props.preFormSuccess && (
<Alert color="green" radius="sm">
{props.preFormSuccess}
</Alert>
)}
{props.preFormWarning && (
<Alert color="orange" radius="sm">
{props.preFormWarning}
</Alert>
)}
<FormProvider {...form}>
<Stack spacing="xs">
{!optionsLoading &&
Object.entries(fields).map(([fieldName, field]) => (
<ApiFormField
key={fieldName}
fieldName={fieldName}
definition={field}
control={form.control}
/>
))}
</Stack>
</FormProvider>
{props.postFormContent}
</Stack>
</div>
</Paper>
{props.preFormWarning && (
<Alert color="orange" radius="sm">
{props.preFormWarning}
</Alert>
)}
</Boundary>
<Boundary label={`ApiForm-${id}-FormContent`}>
<FormProvider {...form}>
<Stack spacing="xs">
{!optionsLoading &&
Object.entries(fields).map(([fieldName, field]) => (
<ApiFormField
key={fieldName}
fieldName={fieldName}
definition={field}
control={form.control}
/>
))}
</Stack>
</FormProvider>
</Boundary>
<Boundary label={`ApiForm-${id}-PostFormContent`}>
{props.postFormContent}
</Boundary>
</Stack>
</div>
</Paper>
{/* Footer with Action Buttons */}
<Divider />
<div>
<Group position="right">
{props.actions?.map((action, i) => (
{/* Footer with Action Buttons */}
<Divider />
<div>
<Group position="right">
{props.actions?.map((action, i) => (
<Button
key={i}
onClick={action.onClick}
variant={action.variant ?? 'outline'}
radius="sm"
color={action.color}
>
{action.text}
</Button>
))}
<Button
key={i}
onClick={action.onClick}
variant={action.variant ?? 'outline'}
onClick={form.handleSubmit(submitForm, onFormError)}
variant="filled"
radius="sm"
color={action.color}
color={props.submitColor ?? 'green'}
disabled={isLoading || (props.fetchInitialData && !isDirty)}
>
{action.text}
{props.submitText ?? t`Submit`}
</Button>
))}
<Button
onClick={form.handleSubmit(submitForm, onFormError)}
variant="filled"
radius="sm"
color={props.submitColor ?? 'green'}
disabled={isLoading || (props.fetchInitialData && !isDirty)}
>
{props.submitText ?? t`Submit`}
</Button>
</Group>
</div>
</Group>
</div>
</Boundary>
</Stack>
);
}

View File

@ -8,6 +8,7 @@ import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { getActions } from '../../defaults/actions';
import { InvenTreeStyle } from '../../globalStyle';
import { useUserState } from '../../states/UserState';
import { Boundary } from '../Boundary';
import { Footer } from './Footer';
import { Header } from './Header';
@ -29,17 +30,17 @@ export default function LayoutComponent() {
const navigate = useNavigate();
const location = useLocation();
const defaultactions = getActions(navigate);
const [actions, setActions] = useState(defaultactions);
const defaultActions = getActions(navigate);
const [actions, setActions] = useState(defaultActions);
const [customActions, setCustomActions] = useState<boolean>(false);
function actionsAreChanging(change: []) {
if (change.length > defaultactions.length) setCustomActions(true);
if (change.length > defaultActions.length) setCustomActions(true);
setActions(change);
}
useEffect(() => {
if (customActions) {
setActions(defaultactions);
setActions(defaultActions);
setCustomActions(false);
}
}, [location]);
@ -57,7 +58,10 @@ export default function LayoutComponent() {
<Flex direction="column" mih="100vh">
<Header />
<Container className={classes.layoutContent} size="100%">
<Outlet />
<Boundary label={'layout'}>
<Outlet />
</Boundary>
{/* </ErrorBoundary> */}
</Container>
<Space h="xl" />
<Footer />

View File

@ -20,6 +20,7 @@ import {
} from 'react-router-dom';
import { useLocalState } from '../../states/LocalState';
import { Boundary } from '../Boundary';
import { PlaceholderPanel } from '../items/Placeholder';
import { StylishText } from '../items/StylishText';
@ -103,77 +104,81 @@ function BasePanelGroup({
const [expanded, setExpanded] = useState<boolean>(true);
return (
<Paper p="sm" radius="xs" shadow="xs">
<Tabs
value={panel}
orientation="vertical"
onTabChange={handlePanelChange}
keepMounted={false}
>
<Tabs.List position="left">
<Boundary label={`PanelGroup-${pageKey}`}>
<Paper p="sm" radius="xs" shadow="xs">
<Tabs
value={panel}
orientation="vertical"
onTabChange={handlePanelChange}
keepMounted={false}
>
<Tabs.List position="left">
{panels.map(
(panel) =>
!panel.hidden && (
<Tooltip
label={panel.label}
key={panel.name}
disabled={expanded}
>
<Tabs.Tab
p="xs"
value={panel.name}
// icon={(<InvenTreeIcon icon={panel.name}/>)} // Enable when implementing Icon manager everywhere
icon={panel.icon}
hidden={panel.hidden}
disabled={panel.disabled}
style={{ cursor: panel.disabled ? 'unset' : 'pointer' }}
>
{expanded && panel.label}
</Tabs.Tab>
</Tooltip>
)
)}
{collapsible && (
<ActionIcon
style={{
paddingLeft: '10px'
}}
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<IconLayoutSidebarLeftCollapse opacity={0.5} />
) : (
<IconLayoutSidebarRightCollapse opacity={0.5} />
)}
</ActionIcon>
)}
</Tabs.List>
{panels.map(
(panel) =>
!panel.hidden && (
<Tooltip
label={panel.label}
<Tabs.Panel
key={panel.name}
disabled={expanded}
value={panel.name}
p="sm"
style={{
overflowX: 'scroll',
width: '100%'
}}
>
<Tabs.Tab
p="xs"
value={panel.name}
// icon={(<InvenTreeIcon icon={panel.name}/>)} // Enable when implementing Icon manager everywhere
icon={panel.icon}
hidden={panel.hidden}
disabled={panel.disabled}
style={{ cursor: panel.disabled ? 'unset' : 'pointer' }}
>
{expanded && panel.label}
</Tabs.Tab>
</Tooltip>
<Stack spacing="md">
{panel.showHeadline !== false && (
<>
<StylishText size="xl">{panel.label}</StylishText>
<Divider />
</>
)}
<Boundary label={`PanelContent-${panel.name}`}>
{panel.content ?? <PlaceholderPanel />}
</Boundary>
</Stack>
</Tabs.Panel>
)
)}
{collapsible && (
<ActionIcon
style={{
paddingLeft: '10px'
}}
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<IconLayoutSidebarLeftCollapse opacity={0.5} />
) : (
<IconLayoutSidebarRightCollapse opacity={0.5} />
)}
</ActionIcon>
)}
</Tabs.List>
{panels.map(
(panel) =>
!panel.hidden && (
<Tabs.Panel
key={panel.name}
value={panel.name}
p="sm"
style={{
overflowX: 'scroll',
width: '100%'
}}
>
<Stack spacing="md">
{panel.showHeadline !== false && (
<>
<StylishText size="xl">{panel.label}</StylishText>
<Divider />
</>
)}
{panel.content ?? <PlaceholderPanel />}
</Stack>
</Tabs.Panel>
)
)}
</Tabs>
</Paper>
</Tabs>
</Paper>
</Boundary>
);
}

View File

@ -36,6 +36,7 @@ import { UserRoles } from '../../enums/Roles';
import { apiUrl } from '../../states/ApiState';
import { useUserSettingsState } from '../../states/SettingsState';
import { useUserState } from '../../states/UserState';
import { Boundary } from '../Boundary';
import { RenderInstance } from '../render/Instance';
import { ModelInformationDict } from '../render/ModelType';
@ -386,48 +387,50 @@ export function SearchDrawer({
</Group>
}
>
{searchQuery.isFetching && (
<Center>
<Loader />
</Center>
)}
{!searchQuery.isFetching && !searchQuery.isError && (
<Stack spacing="md">
{queryResults.map((query, idx) => (
<QueryResultGroup
key={idx}
query={query}
onRemove={(query) => removeResults(query)}
onResultClick={(query, pk) => onResultClick(query, pk)}
/>
))}
</Stack>
)}
{searchQuery.isError && (
<Alert
color="red"
radius="sm"
variant="light"
title={t`Error`}
icon={<IconAlertCircle size="1rem" />}
>
<Trans>An error occurred during search query</Trans>
</Alert>
)}
{searchText &&
!searchQuery.isFetching &&
!searchQuery.isError &&
queryResults.length == 0 && (
<Boundary label="SearchDrawer">
{searchQuery.isFetching && (
<Center>
<Loader />
</Center>
)}
{!searchQuery.isFetching && !searchQuery.isError && (
<Stack spacing="md">
{queryResults.map((query, idx) => (
<QueryResultGroup
key={idx}
query={query}
onRemove={(query) => removeResults(query)}
onResultClick={(query, pk) => onResultClick(query, pk)}
/>
))}
</Stack>
)}
{searchQuery.isError && (
<Alert
color="blue"
color="red"
radius="sm"
variant="light"
title={t`No results`}
icon={<IconSearch size="1rem" />}
title={t`Error`}
icon={<IconAlertCircle size="1rem" />}
>
<Trans>No results available for search query</Trans>
<Trans>An error occurred during search query</Trans>
</Alert>
)}
{searchText &&
!searchQuery.isFetching &&
!searchQuery.isError &&
queryResults.length == 0 && (
<Alert
color="blue"
radius="sm"
variant="light"
title={t`No results`}
icon={<IconSearch size="1rem" />}
>
<Trans>No results available for search query</Trans>
</Alert>
)}
</Boundary>
</Drawer>
);
}

View File

@ -29,6 +29,7 @@ import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../App';
import { Boundary } from '../components/Boundary';
import { ActionButton } from '../components/buttons/ActionButton';
import { ButtonMenu } from '../components/buttons/ButtonMenu';
import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
@ -557,131 +558,135 @@ export function InvenTreeTable<T = any>({
onClose={() => setFiltersVisible(false)}
/>
)}
<Stack spacing="sm">
<Group position="apart">
<Group position="left" key="custom-actions" spacing={5}>
{tableProps.tableActions?.map((group, idx) => (
<Fragment key={idx}>{group}</Fragment>
))}
{(tableProps.barcodeActions?.length ?? 0 > 0) && (
<ButtonMenu
key="barcode-actions"
icon={<IconBarcode />}
label={t`Barcode actions`}
tooltip={t`Barcode actions`}
actions={tableProps.barcodeActions ?? []}
/>
)}
{(tableProps.printingActions?.length ?? 0 > 0) && (
<ButtonMenu
key="printing-actions"
icon={<IconPrinter />}
label={t`Print actions`}
tooltip={t`Print actions`}
actions={tableProps.printingActions ?? []}
/>
)}
{(tableProps.enableBulkDelete ?? false) && (
<ActionButton
disabled={tableState.selectedRecords.length == 0}
icon={<IconTrash />}
color="red"
tooltip={t`Delete selected records`}
onClick={deleteSelectedRecords}
/>
)}
</Group>
<Space />
<Group position="right" spacing={5}>
{tableProps.enableSearch && (
<TableSearchInput
searchCallback={(term: string) =>
tableState.setSearchTerm(term)
}
/>
)}
{tableProps.enableRefresh && (
<ActionIcon>
<Tooltip label={t`Refresh data`}>
<IconRefresh onClick={() => refetch()} />
</Tooltip>
</ActionIcon>
)}
{hasSwitchableColumns && (
<TableColumnSelect
columns={dataColumns}
onToggleColumn={toggleColumn}
/>
)}
{tableProps.enableFilters && filters.length > 0 && (
<Indicator
size="xs"
label={tableState.activeFilters?.length ?? 0}
disabled={tableState.activeFilters?.length == 0}
>
<Boundary label="inventreetable">
<Stack spacing="sm">
<Group position="apart">
<Group position="left" key="custom-actions" spacing={5}>
{tableProps.tableActions?.map((group, idx) => (
<Fragment key={idx}>{group}</Fragment>
))}
{(tableProps.barcodeActions?.length ?? 0 > 0) && (
<ButtonMenu
key="barcode-actions"
icon={<IconBarcode />}
label={t`Barcode actions`}
tooltip={t`Barcode actions`}
actions={tableProps.barcodeActions ?? []}
/>
)}
{(tableProps.printingActions?.length ?? 0 > 0) && (
<ButtonMenu
key="printing-actions"
icon={<IconPrinter />}
label={t`Print actions`}
tooltip={t`Print actions`}
actions={tableProps.printingActions ?? []}
/>
)}
{(tableProps.enableBulkDelete ?? false) && (
<ActionButton
disabled={tableState.selectedRecords.length == 0}
icon={<IconTrash />}
color="red"
tooltip={t`Delete selected records`}
onClick={deleteSelectedRecords}
/>
)}
</Group>
<Space />
<Group position="right" spacing={5}>
{tableProps.enableSearch && (
<TableSearchInput
searchCallback={(term: string) =>
tableState.setSearchTerm(term)
}
/>
)}
{tableProps.enableRefresh && (
<ActionIcon>
<Tooltip label={t`Table filters`}>
<IconFilter
onClick={() => setFiltersVisible(!filtersVisible)}
/>
<Tooltip label={t`Refresh data`}>
<IconRefresh onClick={() => refetch()} />
</Tooltip>
</ActionIcon>
</Indicator>
)}
{tableProps.enableDownload && (
<DownloadAction
key="download-action"
downloadCallback={downloadData}
/>
)}
)}
{hasSwitchableColumns && (
<TableColumnSelect
columns={dataColumns}
onToggleColumn={toggleColumn}
/>
)}
{tableProps.enableFilters && filters.length > 0 && (
<Indicator
size="xs"
label={tableState.activeFilters?.length ?? 0}
disabled={tableState.activeFilters?.length == 0}
>
<ActionIcon>
<Tooltip label={t`Table filters`}>
<IconFilter
onClick={() => setFiltersVisible(!filtersVisible)}
/>
</Tooltip>
</ActionIcon>
</Indicator>
)}
{tableProps.enableDownload && (
<DownloadAction
key="download-action"
downloadCallback={downloadData}
/>
)}
</Group>
</Group>
</Group>
<Box pos="relative">
<LoadingOverlay
visible={tableOptionQuery.isLoading || tableOptionQuery.isFetching}
/>
<DataTable
withBorder
striped
highlightOnHover
loaderVariant="dots"
pinLastColumn={tableProps.rowActions != undefined}
idAccessor={tableProps.idAccessor}
minHeight={300}
totalRecords={tableState.recordCount}
recordsPerPage={tableProps.pageSize ?? defaultPageSize}
page={tableState.page}
onPageChange={tableState.setPage}
sortStatus={sortStatus}
onSortStatusChange={handleSortStatusChange}
selectedRecords={
tableProps.enableSelection
? tableState.selectedRecords
: undefined
}
onSelectedRecordsChange={
tableProps.enableSelection ? onSelectedRecordsChange : undefined
}
rowExpansion={tableProps.rowExpansion}
rowStyle={tableProps.rowStyle}
fetching={isFetching}
noRecordsText={missingRecordsText}
records={tableState.records}
columns={dataColumns}
onRowClick={handleRowClick}
onCellClick={tableProps.onCellClick}
defaultColumnProps={{
noWrap: true,
textAlignment: 'left',
cellsStyle: {
// TODO @SchrodingersGat : Need a better way of handling "wide" cells,
overflow: 'hidden'
<Box pos="relative">
<LoadingOverlay
visible={
tableOptionQuery.isLoading || tableOptionQuery.isFetching
}
}}
/>
</Box>
</Stack>
/>
<DataTable
withBorder
striped
highlightOnHover
loaderVariant="dots"
pinLastColumn={tableProps.rowActions != undefined}
idAccessor={tableProps.idAccessor}
minHeight={300}
totalRecords={tableState.recordCount}
recordsPerPage={tableProps.pageSize ?? defaultPageSize}
page={tableState.page}
onPageChange={tableState.setPage}
sortStatus={sortStatus}
onSortStatusChange={handleSortStatusChange}
selectedRecords={
tableProps.enableSelection
? tableState.selectedRecords
: undefined
}
onSelectedRecordsChange={
tableProps.enableSelection ? onSelectedRecordsChange : undefined
}
rowExpansion={tableProps.rowExpansion}
rowStyle={tableProps.rowStyle}
fetching={isFetching}
noRecordsText={missingRecordsText}
records={tableState.records}
columns={dataColumns}
onRowClick={handleRowClick}
onCellClick={tableProps.onCellClick}
defaultColumnProps={{
noWrap: true,
textAlignment: 'left',
cellsStyle: {
// TODO @SchrodingersGat : Need a better way of handling "wide" cells,
overflow: 'hidden'
}
}}
/>
</Box>
</Stack>
</Boundary>
</>
);
}