Compare commits

...

1 Commits

Author SHA1 Message Date
Oisin Coveney
683d63436e Start work 2024-05-31 16:58:29 +03:00
7 changed files with 242 additions and 183 deletions

View File

@@ -1,26 +1,11 @@
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import type { LoadingAndErrorWrapperProps } from "metabase/components/LoadingAndErrorWrapper/types";
import { Transition } from "metabase/ui";
import LoadingAndErrorWrapper from "./LoadingAndErrorWrapper";
export type LoadingAndErrorWrapperProps = {
className?: string;
error: any;
loading: any;
noBackground?: boolean;
noWrapper?: boolean;
children?: ReactNode;
style?: object;
showSpinner?: boolean;
loadingMessages?: string[];
messageInterval?: number;
loadingScenes?: string[];
renderError?: (error: any) => ReactNode;
"data-testid"?: string;
};
/**
* A loading/error display component that waits a bit before appearing
* @see https://metaboat.slack.com/archives/C02H619CJ8K/p1709558533499399

View File

@@ -0,0 +1,53 @@
import cx from "classnames";
import type { ReactNode } from "react";
import { t } from "ttag";
import CS from "metabase/css/core/index.css";
import type { Error, ErrorWithData } from "./types";
const isErrorWithData = (error?: unknown): error is ErrorWithData => {
return error !== null && typeof error === "object" && "data" in error;
};
export const ErrorComponent = ({
className,
error,
renderError,
}: {
className?: string;
error: Error | unknown | null;
renderError: ((errorMessage: ReactNode) => ReactNode) | undefined;
}) => {
const getErrorMessage = (): string => {
let errorMessage = "";
if (typeof error === "string") {
errorMessage = error;
} else if (isErrorWithData(error)) {
if (typeof error.data === "string") {
errorMessage = error.data;
} else if (error.data?.message) {
errorMessage = error.data.message;
} else if (error.statusText) {
errorMessage = error.statusText;
} else if (error.message) {
errorMessage = error.message;
}
}
return errorMessage || t`An error occurred`;
};
if (renderError) {
return <div className={CS.py4}>{renderError(getErrorMessage())}</div>;
}
return (
<div className={className}>
<h2 className={cx(CS.textNormal, CS.textLight, CS.ieWrapContentFix)}>
{getErrorMessage()}
</h2>
</div>
);
};

View File

@@ -1,166 +0,0 @@
/* eslint "react/prop-types": "warn" */
import cx from "classnames";
import PropTypes from "prop-types";
import { Children, Component } from "react";
import { t } from "ttag";
import LoadingSpinner from "metabase/components/LoadingSpinner";
import CS from "metabase/css/core/index.css";
export default class LoadingAndErrorWrapper extends Component {
state = {
messageIndex: 0,
sceneIndex: 0,
};
static propTypes = {
className: PropTypes.string,
error: PropTypes.any,
loading: PropTypes.any,
noBackground: PropTypes.bool,
noWrapper: PropTypes.bool,
children: PropTypes.any,
style: PropTypes.object,
showSpinner: PropTypes.bool,
loadingMessages: PropTypes.array,
messageInterval: PropTypes.number,
loadingScenes: PropTypes.array,
renderError: PropTypes.func,
"data-testid": PropTypes.string,
};
static defaultProps = {
error: false,
loading: false,
noBackground: true,
noWrapper: false,
showSpinner: true,
loadingMessages: [t`Loading...`],
messageInterval: 6000,
};
renderError(contentClassName) {
if (this.props.renderError) {
return (
<div className={CS.py4}>
{this.props.renderError(this.getErrorMessage())}
</div>
);
}
return (
<div className={contentClassName}>
<h2 className={cx(CS.textNormal, CS.textLight, CS.ieWrapContentFix)}>
{this.getErrorMessage()}
</h2>
</div>
);
}
getErrorMessage() {
const { error } = this.props;
let errorMessage =
// NOTE Atte Keinänen 5/10/17 Dashboard API endpoint returns the error as JSON with `message` field
(error.data && (error.data.message ? error.data.message : error.data)) ||
error.statusText ||
error.message ||
error;
if (!errorMessage || typeof errorMessage !== "string") {
errorMessage = t`An error occurred`;
}
return errorMessage;
}
componentDidMount() {
const { loadingMessages, messageInterval } = this.props;
// only start cycling if multiple messages are provided
if (loadingMessages.length > 1) {
this.cycle = setInterval(this.loadingInterval, messageInterval);
}
}
componentWillUnmount() {
clearInterval(this.cycle);
}
loadingInterval = () => {
if (this.props.loading) {
this.cycleLoadingMessage();
}
};
getChildren(child = this.props.children) {
if (Array.isArray(child)) {
return child.map(this.getChildren);
} else if (typeof child === "function") {
return child();
} else {
return child;
}
}
cycleLoadingMessage = () => {
this.setState({
messageIndex:
this.state.messageIndex + 1 < this.props.loadingMessages.length
? this.state.messageIndex + 1
: 0,
});
};
render() {
const {
loading,
error,
noBackground,
noWrapper,
showSpinner,
loadingMessages,
loadingScenes,
style,
className,
"data-testid": testId,
} = this.props;
const { messageIndex, sceneIndex } = this.state;
const contentClassName = cx(
CS.wrapper,
CS.py4,
CS.textBrand,
CS.textCentered,
CS.flexFull,
CS.flex,
CS.flexColumn,
CS.layoutCentered,
{ [CS.bgWhite]: !noBackground },
);
if (noWrapper && !error && !loading) {
const children = this.getChildren();
// special case for loading wrapper with null/undefined child
if (children == null) {
return null;
}
return Children.only(children);
}
return (
<div className={className} style={style} data-testid={testId}>
{error ? (
this.renderError(contentClassName)
) : loading ? (
<div className={contentClassName}>
{loadingScenes && loadingScenes[sceneIndex]}
{!loadingScenes && showSpinner && <LoadingSpinner />}
<h2 className={cx(CS.textNormal, CS.textLight, CS.mt1)}>
{loadingMessages[messageIndex]}
</h2>
</div>
) : (
this.getChildren()
)}
</div>
);
}
}

View File

@@ -0,0 +1,91 @@
import cx from "classnames";
import type { ReactNode } from "react";
import { Children } from "react";
import { t } from "ttag";
import { ErrorComponent } from "metabase/components/LoadingAndErrorWrapper/ErrorComponent";
import { LoadingComponent } from "metabase/components/LoadingAndErrorWrapper/LoadingComponent";
import type { LoadingAndErrorWrapperProps } from "metabase/components/LoadingAndErrorWrapper/types";
import CS from "metabase/css/core/index.css";
const LoadingAndErrorWrapper = ({
className,
error = null,
loading = false,
noBackground = false,
noWrapper = false,
children,
style,
showSpinner = true,
loadingMessages = [t`Loading...`],
messageInterval = 6000,
loadingScenes,
renderError,
"data-testid": dataTestId,
}: LoadingAndErrorWrapperProps) => {
const getChildren = (child: ReactNode = children): ReactNode => {
if (Array.isArray(child)) {
return child.map(getChildren);
} else if (typeof child === "function") {
return child();
} else {
return child;
}
};
const contentClassName = cx(
CS.wrapper,
CS.py4,
CS.textBrand,
CS.textCentered,
CS.flexFull,
CS.flex,
CS.flexColumn,
CS.layoutCentered,
{ [CS.bgWhite]: !noBackground },
);
if (noWrapper && !error && !loading) {
const children = getChildren();
// special case for loading wrapper with null/undefined child
if (children == null) {
return null;
}
return <>{Children.only(children)}</>;
}
const getInnerContent = () => {
if (error) {
return (
<ErrorComponent
className={contentClassName}
error={error}
renderError={renderError}
/>
);
}
if (loading) {
return (
<LoadingComponent
className={contentClassName}
loadingScenes={loadingScenes}
showSpinner={showSpinner}
loadingMessages={loadingMessages}
messageInterval={messageInterval}
/>
);
}
return getChildren();
};
return (
<div className={className} style={style} data-testid={dataTestId}>
{getInnerContent()}
</div>
);
};
// eslint-disable-next-line import/no-default-export
export default LoadingAndErrorWrapper;

View File

@@ -0,0 +1,57 @@
import { useInterval } from "@mantine/hooks";
import cx from "classnames";
import { useState } from "react";
import { useMount, useUnmount } from "react-use";
import type { LoadingAndErrorWrapperProps } from "metabase/components/LoadingAndErrorWrapper/types";
import LoadingSpinner from "metabase/components/LoadingSpinner";
import CS from "metabase/css/core/index.css";
type LoadingComponentProps = Pick<
LoadingAndErrorWrapperProps,
| "className"
| "loadingMessages"
| "loadingScenes"
| "showSpinner"
| "messageInterval"
>;
export const LoadingComponent = ({
className,
loadingMessages = [],
loadingScenes,
showSpinner,
messageInterval = 6000,
}: LoadingComponentProps) => {
const [messageIndex, setMessageIndex] = useState(0);
// TODO: should this be cycling as well?
const [sceneIndex] = useState(0);
const loadingInterval = () => {
setMessageIndex(prevIndex => (prevIndex + 1) % loadingMessages.length);
};
const interval = useInterval(loadingInterval, messageInterval);
useMount(() => {
// only start cycling if multiple messages are provided
if (loadingMessages.length > 1) {
interval.start();
}
});
useUnmount(() => {
interval.stop();
});
return (
<div className={className}>
{loadingScenes && loadingScenes[sceneIndex]}
{!loadingScenes && showSpinner && <LoadingSpinner />}
<h2 className={cx(CS.textNormal, CS.textLight, CS.mt1)}>
{loadingMessages[messageIndex]}
</h2>
</div>
);
};

View File

@@ -0,0 +1,35 @@
import type { CSSProperties, ReactNode, PropsWithChildren } from "react";
export type LoadingAndErrorWrapperProps = PropsWithChildren<
Partial<{
className: string;
style: CSSProperties;
// display config
noBackground: boolean;
noWrapper: boolean;
showSpinner: boolean;
"data-testid": string;
loading: boolean;
loadingMessages?: string[];
messageInterval?: number;
loadingScenes: ReactNode[];
error: unknown | Error | null;
renderError: (errorMessage: ReactNode | string) => ReactNode;
}>
>;
export type ErrorDataMessage = {
message?: string;
};
export type ErrorWithData = {
data?: ErrorDataMessage | string;
statusText?: string;
message?: string;
};
export type Error = ErrorWithData | string;

View File

@@ -20,7 +20,11 @@ export const DashboardLoadingAndErrorWrapper = styled(
isNightMode,
className,
...props
}: ComponentPropsWithoutRef<typeof LoadingAndErrorWrapper>) => {
}: {
isFullscreen?: boolean;
isNightMode?: boolean;
isFullHeight?: boolean;
} & ComponentPropsWithoutRef<typeof LoadingAndErrorWrapper>) => {
return (
<LoadingAndErrorWrapper
className={cx(className, DashboardS.Dashboard, {