PanelChrome: Implement Panel header with error, loading, and streaming data status (#60147)

* dashboards squad mob! 🔱

lastFile:packages/grafana-ui/src/components/LoadingBar/LoadingBar.tsx

* dashboards squad mob! 🔱

* dashboards squad mob! 🔱

lastFile:packages/grafana-ui/src/components/LoadingBar/LoadingBar.tsx

* user essentials mob! 🔱

* create grafana/ui LoadingBar and set it up in Storybook

* Remove test changes on PanelChrome

* Fix mdx page reference

* dashboards squad mob! 🔱

lastFile:public/api-merged.json

* dashboards squad mob! 🔱

* dashboards squad mob! 🔱

* dashboards squad mob! 🔱

* dashboards squad mob! 🔱

lastFile:public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderState.tsx

* Implemented basic draft of panel header states. Using ToolbarButton instead of IconButton.

* use 'warning' styled Button in ToolbarButton

* make LoadingBar a simple JSX Element; do not use containerWidth; have a wrapper around the loading bar itself;

* fix wrapper around LoadingBar: willChange css prop makes performance of rerendering better

* States: Render general panel query error states and render notices next
to the title

* add streaming to PanelChrome if data is streaming instead of loading

* PanelHeaderState with its own state 'mode'

* clean up useEffect

* notices have their own square space in the size of the panel header

* clean up

* minor fixes

* moving the LoadingBar to core

* LoadingBar is not in grafana/ui

* always have a place for the loading bar in the PanelChrome, otherwise it moves everything when appearing;

remove titleItemsNodes for now - in later development

make no changes to Notice component, not part of this PR

* Revert "moving the LoadingBar to core"

This reverts commit 11f0f4ff2f.

* do not use internal comment as it doesn't do anything

* integrate LoadingBar in PanelChrome from grafana/ui directly

* fix deprecated leftItems comment

* Modify annimation to 1 second

* remove comments

* remove streaming stopped UI because we cannot know when the streaming has stopped

* skip unnecessary test for now

* no point in removing hoverHeader now, even though it's not yet implemented

* small fixes

* error state of the data in a panel is positioned in PanelChrome itself, not in PanelHeaderState

* Fixed loading state jitter

* remove warning state as we have none of it

* streaming cannot be stopped from the icon

* explicit content container width and height

* explicit content container width and height

* edit deprecated comment

* fix LoadingBar to be relative to width of panel; remove explicit width and height on content strict

* no warning state of the data

* status of the panel data given directly to PanelChrome, not a node

* clean up

* clean up console log

Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com>

* panel title design fits typography h6 styles; render error status only if error or error message are passed to PanelChrome

* add storybook examples; prepare PanelChrome for hoverHeader because this will be a breaking change and it will affect how the storybook example shows up

* show storybook example for streaming panel with title because that's the condition for having a header

* override margin-bottom: 0.45em of h6

Co-authored-by: Alexandra Vargas <alexa1866@gmail.com>
Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
Co-authored-by: kay delaney <45561153+kaydelaney@users.noreply.github.com>
pull/61025/head
Polina Boneva 3 years ago committed by GitHub
parent 88a8cba6b0
commit 3f1908464d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      packages/grafana-ui/src/components/IconButton/IconButton.tsx
  2. 2
      packages/grafana-ui/src/components/LoadingBar/LoadingBar.tsx
  3. 54
      packages/grafana-ui/src/components/PanelChrome/PanelChrome.story.tsx
  4. 13
      packages/grafana-ui/src/components/PanelChrome/PanelChrome.test.tsx
  5. 126
      packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx
  6. 43
      packages/grafana-ui/src/components/PanelChrome/PanelStatus.tsx
  7. 7224
      public/api-merged.json
  8. 7760
      public/api-spec.json
  9. 26
      public/app/features/dashboard/dashgrid/PanelStateWrapper.tsx

@ -18,7 +18,7 @@ export interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement> {
name: IconName; name: IconName;
/** Icon size */ /** Icon size */
size?: IconSize; size?: IconSize;
/** Type od the icon - mono or default */ /** Type of the icon - mono or default */
iconType?: IconType; iconType?: IconType;
/** Tooltip content to display on hover */ /** Tooltip content to display on hover */
tooltip?: PopoverContent; tooltip?: PopoverContent;

@ -30,7 +30,7 @@ const getStyles = (width?: string, height?: string) => (_: GrafanaTheme2) => {
transform: 'translateX(0)', transform: 'translateX(0)',
}, },
'100%': { '100%': {
transform: `translateX(calc(100% - ${barWidth}))`, transform: `translateX(100%)`,
}, },
}); });

@ -4,6 +4,7 @@ import { merge } from 'lodash';
import React, { CSSProperties, useState, ReactNode } from 'react'; import React, { CSSProperties, useState, ReactNode } from 'react';
import { useInterval } from 'react-use'; import { useInterval } from 'react-use';
import { LoadingState } from '@grafana/data';
import { PanelChrome, PanelChromeProps } from '@grafana/ui'; import { PanelChrome, PanelChromeProps } from '@grafana/ui';
import { DashboardStoryCanvas } from '../../utils/storybook/DashboardStoryCanvas'; import { DashboardStoryCanvas } from '../../utils/storybook/DashboardStoryCanvas';
@ -64,26 +65,21 @@ export const Examples = () => {
<DashboardStoryCanvas> <DashboardStoryCanvas>
<HorizontalGroup spacing="md" align="flex-start"> <HorizontalGroup spacing="md" align="flex-start">
<VerticalGroup spacing="md"> <VerticalGroup spacing="md">
{renderPanel('Default panel with error state indicator', { {renderPanel('Default panel with error status', {
title: 'Default title', title: 'Default title',
leftItems: [ status: {
<PanelChrome.ErrorIndicator message: 'Error text',
key="errorIndicator" onClick: action('ErrorIndicator: onClick fired'),
error="Error text" },
onClick={action('ErrorIndicator: onClick fired')}
/>,
],
})} })}
{renderPanel('No padding with error state indicator', { {renderPanel('No padding with error state', {
padding: 'none', padding: 'none',
title: 'Default title', title: 'Default title',
leftItems: [ loadingState: LoadingState.Error,
<PanelChrome.ErrorIndicator })}
key="errorIndicator" {renderPanel('Default panel with streaming state', {
error="Error text" title: 'Default title',
onClick={action('ErrorIndicator: onClick fired')} loadingState: LoadingState.Streaming,
/>,
],
})} })}
</VerticalGroup> </VerticalGroup>
<VerticalGroup spacing="md"> <VerticalGroup spacing="md">
@ -93,22 +89,21 @@ export const Examples = () => {
})} })}
</VerticalGroup> </VerticalGroup>
</HorizontalGroup> </HorizontalGroup>
<HorizontalGroup spacing="md"> <HorizontalGroup spacing="md" align="flex-start">
<VerticalGroup spacing="md"> <VerticalGroup spacing="md">
{renderPanel('No title and loading indicator', { {renderPanel('Default panel with deprecated error indicator', {
title: '', title: 'Default title',
leftItems: [ leftItems: [
<PanelChrome.LoadingIndicator <PanelChrome.ErrorIndicator
loading={loading} key="errorIndicator"
onCancel={() => setLoading(false)} error="Error text"
key="loading-indicator" onClick={action('ErrorIndicator: onClick fired')}
/>, />,
], ],
})} })}
</VerticalGroup> {renderPanel('No padding with deprecated loading indicator', {
<VerticalGroup spacing="md"> padding: 'none',
{renderPanel('Very long title', { title: 'Default title',
title: 'Very long title that should get ellipsis when there is no more space',
leftItems: [ leftItems: [
<PanelChrome.LoadingIndicator <PanelChrome.LoadingIndicator
loading={loading} loading={loading}
@ -128,7 +123,9 @@ export const Basic: ComponentStory<typeof PanelChrome> = (args: PanelChromeProps
return ( return (
<PanelChrome {...args}> <PanelChrome {...args}>
{(width: number, height: number) => <div style={{ height, width, ...contentStyle }}>Description text</div>} {(width: number, height: number) => (
<div style={{ height, width, ...contentStyle }}>Panel in a loading state</div>
)}
</PanelChrome> </PanelChrome>
); );
}; };
@ -221,6 +218,7 @@ Basic.args = {
title: 'Very long title that should get ellipsis when there is no more space', title: 'Very long title that should get ellipsis when there is no more space',
titleItems, titleItems,
menu, menu,
loadingState: LoadingState.Loading,
}; };
export default meta; export default meta;

@ -35,12 +35,6 @@ it('renders an empty panel with padding', () => {
expect(screen.getByText("Panel's Content").parentElement).not.toHaveStyle({ padding: '0px' }); expect(screen.getByText("Panel's Content").parentElement).not.toHaveStyle({ padding: '0px' });
}); });
it('renders an empty panel without a header if no title or titleItems', () => {
setup();
expect(screen.queryByTestId('header-container')).not.toBeInTheDocument();
});
it('renders panel with a header if prop title', () => { it('renders panel with a header if prop title', () => {
setup({ title: 'Test Panel Header' }); setup({ title: 'Test Panel Header' });
@ -81,10 +75,9 @@ it('renders panel with a header with icons in place if prop titleItems', () => {
expect(screen.getByTestId('title-items-container')).toBeInTheDocument(); expect(screen.getByTestId('title-items-container')).toBeInTheDocument();
}); });
it('renders panel with a fixed header if prop hoverHeader is false', () => { it.skip('renders panel with a fixed header if prop hoverHeader is false', () => {
setup({ title: 'Test Panel Header', hoverHeader: false }); // setup({ title: 'Test Panel Header', hoverHeader: false });
// expect(screen.getByTestId('header-container')).toBeInTheDocument();
expect(screen.getByTestId('header-container')).toBeInTheDocument();
}); });
it('renders panel with a header if prop menu', () => { it('renders panel with a header if prop menu', () => {

@ -1,15 +1,24 @@
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import React, { CSSProperties, ReactNode } from 'react'; import { isEmpty } from 'lodash';
import React, { CSSProperties, ReactElement, ReactNode } from 'react';
import { GrafanaTheme2, isIconName } from '@grafana/data'; import { GrafanaTheme2, isIconName, LoadingState } from '@grafana/data';
import { useStyles2, useTheme2 } from '../../themes'; import { useStyles2, useTheme2 } from '../../themes';
import { IconName } from '../../types/icon'; import { IconName } from '../../types/icon';
import { Dropdown } from '../Dropdown/Dropdown'; import { Dropdown } from '../Dropdown/Dropdown';
import { Icon } from '../Icon/Icon'; import { Icon } from '../Icon/Icon';
import { IconButton, IconButtonVariant } from '../IconButton/IconButton'; import { IconButton, IconButtonVariant } from '../IconButton/IconButton';
import { LoadingBar } from '../LoadingBar/LoadingBar';
import { PopoverContent, Tooltip } from '../Tooltip'; import { PopoverContent, Tooltip } from '../Tooltip';
import { PanelStatus } from './PanelStatus';
interface Status {
message?: string;
onClick?: (e: React.SyntheticEvent) => void;
}
/** /**
* @internal * @internal
*/ */
@ -31,16 +40,16 @@ export interface PanelChromeProps {
padding?: PanelPadding; padding?: PanelPadding;
title?: string; title?: string;
titleItems?: PanelChromeInfoState[]; titleItems?: PanelChromeInfoState[];
menu?: React.ReactElement; menu?: ReactElement;
/** dragClass, hoverHeader, loadingState, and states not yet implemented */ /** dragClass, hoverHeader not yet implemented */
// dragClass?: string; // dragClass?: string;
hoverHeader?: boolean; hoverHeader?: boolean;
// loadingState?: LoadingState; loadingState?: LoadingState;
// states?: ReactNode[]; status?: Status;
/** @deprecated in favor of prop states /** @deprecated in favor of props
* status for errors and loadingState for loading and streaming
* which will serve the same purpose * which will serve the same purpose
* of showing the panel state in the top right corner * of showing/interacting with the panel's data state
* of itself or its header
* */ * */
leftItems?: ReactNode[]; leftItems?: ReactNode[];
} }
@ -53,7 +62,7 @@ export type PanelPadding = 'none' | 'md';
/** /**
* @internal * @internal
*/ */
export const PanelChrome: React.FC<PanelChromeProps> = ({ export function PanelChrome({
width, width,
height, height,
children, children,
@ -63,14 +72,18 @@ export const PanelChrome: React.FC<PanelChromeProps> = ({
menu, menu,
// dragClass, // dragClass,
hoverHeader = false, hoverHeader = false,
// loadingState, loadingState,
// states = [], status,
leftItems = [], leftItems = [],
}) => { }: PanelChromeProps) {
const theme = useTheme2(); const theme = useTheme2();
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const headerHeight = !hoverHeader ? getHeaderHeight(theme, title, leftItems) : 0; // To Do rely on hoverHeader prop for header, not separate props
// once hoverHeader is implemented
const hasHeader = title.length > 0 || leftItems.length > 0;
const headerHeight = getHeaderHeight(theme, hasHeader);
const { contentStyle, innerWidth, innerHeight } = getContentStyle(padding, theme, width, headerHeight, height); const { contentStyle, innerWidth, innerHeight } = getContentStyle(padding, theme, width, headerHeight, height);
const headerStyles: CSSProperties = { const headerStyles: CSSProperties = {
@ -82,17 +95,40 @@ export const PanelChrome: React.FC<PanelChromeProps> = ({
}; };
const containerStyles: CSSProperties = { width, height }; const containerStyles: CSSProperties = { width, height };
const handleMenuOpen = () => {}; const isUsingDeprecatedLeftItems = isEmpty(status) && !loadingState;
const showLoading = loadingState === LoadingState.Loading && !isUsingDeprecatedLeftItems;
const hasHeader = title || titleItems.length > 0 || menu; const showStreaming = loadingState === LoadingState.Streaming && !isUsingDeprecatedLeftItems;
const renderStatus = () => {
if (isUsingDeprecatedLeftItems) {
return <div className={cx(styles.rightAligned, styles.items)}>{itemsRenderer(leftItems, (item) => item)}</div>;
} else {
const showError = loadingState === LoadingState.Error || status?.message;
return showError ? (
<div className={styles.errorContainer}>
<PanelStatus message={status?.message} onClick={status?.onClick} />
</div>
) : null;
}
};
return ( return (
<div className={styles.container} style={containerStyles}> <div className={styles.container} style={containerStyles}>
{hasHeader && !hoverHeader && ( <div className={styles.loadingBarContainer}>
{showLoading ? <LoadingBar width={'28%'} height={'2px'} /> : null}
</div>
<div className={styles.headerContainer} style={headerStyles} data-testid="header-container"> <div className={styles.headerContainer} style={headerStyles} data-testid="header-container">
{title && ( {title && (
<div title={title} className={styles.title}> <h6 title={title} className={styles.title}>
{title} {title}
</h6>
)}
{showStreaming && (
<div className={styles.item} style={itemStyles}>
<Tooltip content="Streaming">
<Icon name="circle" type="mono" size="sm" className={styles.streaming} />
</Tooltip>
</div> </div>
)} )}
@ -122,32 +158,28 @@ export const PanelChrome: React.FC<PanelChromeProps> = ({
tooltip="Menu" tooltip="Menu"
name="ellipsis-v" name="ellipsis-v"
size="sm" size="sm"
onClick={handleMenuOpen}
/> />
</div> </div>
</Dropdown> </Dropdown>
)} )}
{leftItems.length > 0 && ( {renderStatus()}
<div className={cx(styles.rightAligned, styles.items)}>{itemsRenderer(leftItems, (item) => item)}</div>
)}
</div> </div>
)}
<div className={styles.content} style={contentStyle}> <div className={styles.content} style={contentStyle}>
{children(innerWidth, innerHeight)} {children(innerWidth, innerHeight)}
</div> </div>
</div> </div>
); );
}; }
const itemsRenderer = (items: ReactNode[], renderer: (items: ReactNode[]) => ReactNode): ReactNode => { const itemsRenderer = (items: ReactNode[], renderer: (items: ReactNode[]) => ReactNode): ReactNode => {
const toRender = React.Children.toArray(items).filter(Boolean); const toRender = React.Children.toArray(items).filter(Boolean);
return toRender.length > 0 ? renderer(toRender) : null; return toRender.length > 0 ? renderer(toRender) : null;
}; };
const getHeaderHeight = (theme: GrafanaTheme2, title: string, items: ReactNode[]) => { const getHeaderHeight = (theme: GrafanaTheme2, hasHeader: boolean) => {
if (title.length > 0 || items.length > 0) { if (hasHeader) {
return theme.spacing.gridSize * theme.components.panel.headerHeight; return theme.spacing.gridSize * theme.components.panel.headerHeight;
} }
return 0; return 0;
@ -161,9 +193,12 @@ const getContentStyle = (
height: number height: number
) => { ) => {
const chromePadding = (padding === 'md' ? theme.components.panel.padding : 0) * theme.spacing.gridSize; const chromePadding = (padding === 'md' ? theme.components.panel.padding : 0) * theme.spacing.gridSize;
const panelPadding = chromePadding * 2;
const panelBorder = 1 * 2; const panelBorder = 1 * 2;
const innerWidth = width - chromePadding * 2 - panelBorder;
const innerHeight = height - headerHeight - chromePadding * 2 - panelBorder; const innerWidth = width - panelPadding - panelBorder;
const innerHeight = height - headerHeight - panelPadding - panelBorder;
const contentStyle: CSSProperties = { const contentStyle: CSSProperties = {
padding: chromePadding, padding: chromePadding,
@ -185,7 +220,7 @@ const getStyles = (theme: GrafanaTheme2) => {
height: '100%', height: '100%',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
flex: '0 0 0', flex: '1 1 0',
'&:focus-visible, &:hover': { '&:focus-visible, &:hover': {
// only show menu icon on hover or focused panel // only show menu icon on hover or focused panel
@ -198,11 +233,16 @@ const getStyles = (theme: GrafanaTheme2) => {
outline: `1px solid ${theme.colors.action.focus}`, outline: `1px solid ${theme.colors.action.focus}`,
}, },
}), }),
loadingBarContainer: css({
position: 'absolute',
top: 0,
width: '100%',
overflow: 'hidden',
}),
content: css({ content: css({
label: 'panel-content', label: 'panel-content',
width: '100%',
contain: 'strict',
flexGrow: 1, flexGrow: 1,
contain: 'strict',
}), }),
headerContainer: css({ headerContainer: css({
label: 'panel-header', label: 'panel-header',
@ -210,23 +250,41 @@ const getStyles = (theme: GrafanaTheme2) => {
alignItems: 'center', alignItems: 'center',
padding: `0 ${theme.spacing(padding)}`, padding: `0 ${theme.spacing(padding)}`,
}), }),
streaming: css({
marginRight: 0,
color: theme.colors.success.text,
'&:hover': {
color: theme.colors.success.text,
},
}),
title: css({ title: css({
marginBottom: 0, // override default h6 margin-bottom
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
overflow: 'hidden', overflow: 'hidden',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
fontWeight: theme.typography.fontWeightMedium, fontSize: theme.typography.h6.fontSize,
fontWeight: theme.typography.h6.fontWeight,
}), }),
items: css({ items: css({
display: 'flex', display: 'flex',
}), }),
item: css({ item: css({
display: 'flex', display: 'flex',
justifyContent: 'space-around', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}), }),
menuItem: css({ menuItem: css({
visibility: 'hidden', visibility: 'hidden',
}), }),
errorContainer: css({
label: 'error-container',
position: 'absolute',
width: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}),
rightAligned: css({ rightAligned: css({
marginLeft: 'auto', marginLeft: 'auto',
}), }),

@ -0,0 +1,43 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes';
import { ToolbarButton } from '../ToolbarButton/ToolbarButton';
export interface Props {
message?: string;
onClick?: (e: React.SyntheticEvent) => void;
}
export function PanelStatus({ message, onClick }: Props) {
const styles = useStyles2(getStyles);
return (
<ToolbarButton
onClick={onClick}
variant={'destructive'}
className={styles.buttonStyles}
icon="exclamation-triangle"
tooltip={message || ''}
/>
);
}
const getStyles = (theme: GrafanaTheme2) => {
const { headerHeight, padding } = theme.components.panel;
return {
buttonStyles: css({
label: 'panel-header-state-button',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing(padding),
width: theme.spacing(headerHeight),
height: theme.spacing(headerHeight),
borderRadius: 0,
}),
};
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -34,6 +34,7 @@ import {
import { PANEL_BORDER } from 'app/core/constants'; import { PANEL_BORDER } from 'app/core/constants';
import { profiler } from 'app/core/profiler'; import { profiler } from 'app/core/profiler';
import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel'; import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
import { InspectTab } from 'app/features/inspector/types';
import { changeSeriesColorConfigFactory } from 'app/plugins/panel/timeseries/overrides/colorSeriesConfigFactory'; import { changeSeriesColorConfigFactory } from 'app/plugins/panel/timeseries/overrides/colorSeriesConfigFactory';
import { RenderEvent } from 'app/types/events'; import { RenderEvent } from 'app/types/events';
@ -45,7 +46,6 @@ import { DashboardModel, PanelModel } from '../state';
import { loadSnapshotData } from '../utils/loadSnapshotData'; import { loadSnapshotData } from '../utils/loadSnapshotData';
import { PanelHeader } from './PanelHeader/PanelHeader'; import { PanelHeader } from './PanelHeader/PanelHeader';
import { PanelHeaderLoadingIndicator } from './PanelHeader/PanelHeaderLoadingIndicator';
import { seriesVisibilityConfigFactory } from './SeriesVisibilityConfigFactory'; import { seriesVisibilityConfigFactory } from './SeriesVisibilityConfigFactory';
import { liveTimer } from './liveTimer'; import { liveTimer } from './liveTimer';
@ -566,6 +566,11 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
return !panel.hasTitle(); return !panel.hasTitle();
} }
onOpenErrorInspect(e: React.SyntheticEvent, tab: string) {
e.stopPropagation();
locationService.partial({ inspect: this.props.panel.id, inspectTab: tab });
}
render() { render() {
const { dashboard, panel, isViewing, isEditing, width, height, plugin } = this.props; const { dashboard, panel, isViewing, isEditing, width, height, plugin } = this.props;
const { errorMessage, data } = this.state; const { errorMessage, data } = this.state;
@ -581,17 +586,22 @@ export class PanelStateWrapper extends PureComponent<Props, State> {
[`panel-alert-state--${alertState}`]: alertState !== undefined, [`panel-alert-state--${alertState}`]: alertState !== undefined,
}); });
// for new panel header design
const onCancelQuery = () => panel.getQueryRunner().cancelQuery();
const title = panel.getDisplayTitle(); const title = panel.getDisplayTitle();
const noPadding: PanelPadding = plugin.noPadding ? 'none' : 'md'; const padding: PanelPadding = plugin.noPadding ? 'none' : 'md';
const leftItems = [
<PanelHeaderLoadingIndicator state={data.state} onClick={onCancelQuery} key="loading-indicator" />,
];
if (config.featureToggles.newPanelChromeUI) { if (config.featureToggles.newPanelChromeUI) {
return ( return (
<PanelChrome width={width} height={height} title={title} leftItems={leftItems} padding={noPadding}> <PanelChrome
width={width}
height={height}
padding={padding}
title={title}
loadingState={data.state}
status={{
message: errorMessage,
onClick: (e: React.SyntheticEvent) => this.onOpenErrorInspect(e, InspectTab.Error),
}}
>
{(innerWidth, innerHeight) => ( {(innerWidth, innerHeight) => (
<> <>
<ErrorBoundary <ErrorBoundary

Loading…
Cancel
Save