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

View File

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

View File

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

View File

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

View File

@ -29,6 +29,7 @@ import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { api } from '../App'; import { api } from '../App';
import { Boundary } from '../components/Boundary';
import { ActionButton } from '../components/buttons/ActionButton'; import { ActionButton } from '../components/buttons/ActionButton';
import { ButtonMenu } from '../components/buttons/ButtonMenu'; import { ButtonMenu } from '../components/buttons/ButtonMenu';
import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField'; import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
@ -557,131 +558,135 @@ export function InvenTreeTable<T = any>({
onClose={() => setFiltersVisible(false)} onClose={() => setFiltersVisible(false)}
/> />
)} )}
<Stack spacing="sm"> <Boundary label="inventreetable">
<Group position="apart"> <Stack spacing="sm">
<Group position="left" key="custom-actions" spacing={5}> <Group position="apart">
{tableProps.tableActions?.map((group, idx) => ( <Group position="left" key="custom-actions" spacing={5}>
<Fragment key={idx}>{group}</Fragment> {tableProps.tableActions?.map((group, idx) => (
))} <Fragment key={idx}>{group}</Fragment>
{(tableProps.barcodeActions?.length ?? 0 > 0) && ( ))}
<ButtonMenu {(tableProps.barcodeActions?.length ?? 0 > 0) && (
key="barcode-actions" <ButtonMenu
icon={<IconBarcode />} key="barcode-actions"
label={t`Barcode actions`} icon={<IconBarcode />}
tooltip={t`Barcode actions`} label={t`Barcode actions`}
actions={tableProps.barcodeActions ?? []} tooltip={t`Barcode actions`}
/> actions={tableProps.barcodeActions ?? []}
)} />
{(tableProps.printingActions?.length ?? 0 > 0) && ( )}
<ButtonMenu {(tableProps.printingActions?.length ?? 0 > 0) && (
key="printing-actions" <ButtonMenu
icon={<IconPrinter />} key="printing-actions"
label={t`Print actions`} icon={<IconPrinter />}
tooltip={t`Print actions`} label={t`Print actions`}
actions={tableProps.printingActions ?? []} tooltip={t`Print actions`}
/> actions={tableProps.printingActions ?? []}
)} />
{(tableProps.enableBulkDelete ?? false) && ( )}
<ActionButton {(tableProps.enableBulkDelete ?? false) && (
disabled={tableState.selectedRecords.length == 0} <ActionButton
icon={<IconTrash />} disabled={tableState.selectedRecords.length == 0}
color="red" icon={<IconTrash />}
tooltip={t`Delete selected records`} color="red"
onClick={deleteSelectedRecords} tooltip={t`Delete selected records`}
/> onClick={deleteSelectedRecords}
)} />
</Group> )}
<Space /> </Group>
<Group position="right" spacing={5}> <Space />
{tableProps.enableSearch && ( <Group position="right" spacing={5}>
<TableSearchInput {tableProps.enableSearch && (
searchCallback={(term: string) => <TableSearchInput
tableState.setSearchTerm(term) searchCallback={(term: string) =>
} tableState.setSearchTerm(term)
/> }
)} />
{tableProps.enableRefresh && ( )}
<ActionIcon> {tableProps.enableRefresh && (
<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}
>
<ActionIcon> <ActionIcon>
<Tooltip label={t`Table filters`}> <Tooltip label={t`Refresh data`}>
<IconFilter <IconRefresh onClick={() => refetch()} />
onClick={() => setFiltersVisible(!filtersVisible)}
/>
</Tooltip> </Tooltip>
</ActionIcon> </ActionIcon>
</Indicator> )}
)} {hasSwitchableColumns && (
{tableProps.enableDownload && ( <TableColumnSelect
<DownloadAction columns={dataColumns}
key="download-action" onToggleColumn={toggleColumn}
downloadCallback={downloadData} />
/> )}
)} {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>
</Group> <Box pos="relative">
<Box pos="relative"> <LoadingOverlay
<LoadingOverlay visible={
visible={tableOptionQuery.isLoading || tableOptionQuery.isFetching} 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> <DataTable
</Stack> 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>
</> </>
); );
} }