Compare commits

...

4 Commits

Author SHA1 Message Date
Raphael Krut-Landau
2974b493c7 try to fix unit tests 2024-08-23 22:30:00 -04:00
Raphael Krut-Landau
9aa4ee0890 Fix lint issue 2024-08-23 22:30:00 -04:00
Raphael Krut-Landau
82e85d1129 Fix lint issue 2024-08-23 22:30:00 -04:00
Raphael Krut-Landau
b753373456 fix: Improve loading and error wrapper
* Remove the unused loadingScenes prop
* Support custom loading indicators
* Improve unit tests
2024-08-23 22:30:00 -04:00
14 changed files with 371 additions and 402 deletions

View File

@@ -87,11 +87,7 @@ const DashboardStrategySidebar_Base = ({
Caching settings
</Title>
</Flex>
<DelayedLoadingAndErrorWrapper
loadingMessages={[]}
loading={loading}
error={error}
>
<DelayedLoadingAndErrorWrapper loading={loading} error={error}>
<StrategyForm
targetId={dashboardId}
targetModel="dashboard"

View File

@@ -4,7 +4,7 @@ import _ from "underscore";
import { useCacheConfigs } from "metabase/admin/performance/hooks/useCacheConfigs";
import { getShortStrategyLabel } from "metabase/admin/performance/utils";
import { DelayedLoadingAndErrorWrapper } from "metabase/components/LoadingAndErrorWrapper/DelayedLoadingAndErrorWrapper";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
import type { SidebarCacheSectionProps } from "metabase/plugins";
import { Flex } from "metabase/ui";
@@ -36,7 +36,7 @@ export const SidebarCacheSection = ({
const labelId = "question-caching-policy-label";
return (
<DelayedLoadingAndErrorWrapper delay={0} loading={loading} error={error}>
<LoadingAndErrorWrapper loading={loading} error={error}>
<Flex align="center" justify="space-between">
<span id={labelId}>{t`Caching policy`}</span>
<FormLauncher
@@ -47,6 +47,6 @@ export const SidebarCacheSection = ({
{shortStrategyLabel}
</FormLauncher>
</Flex>
</DelayedLoadingAndErrorWrapper>
</LoadingAndErrorWrapper>
);
};

View File

@@ -4,7 +4,7 @@ import { t } from "ttag";
import NoResults from "assets/img/no_results.svg";
import { useListRecentsQuery } from "metabase/api";
import { useFetchModels } from "metabase/common/hooks/use-fetch-models";
import { DelayedLoadingAndErrorWrapper } from "metabase/components/LoadingAndErrorWrapper/DelayedLoadingAndErrorWrapper";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
import {
PLUGIN_COLLECTIONS,
PLUGIN_CONTENT_VERIFICATION,
@@ -126,7 +126,7 @@ export const BrowseModels = () => {
) : (
<>
<ModelExplanationBanner />
<DelayedLoadingAndErrorWrapper
<LoadingAndErrorWrapper
error={recentModelsResult.error}
loading={
// If the main models result is still pending, the list of recently viewed
@@ -135,20 +135,18 @@ export const BrowseModels = () => {
recentModelsResult.isLoading || modelsResult.isLoading
}
style={{ flex: 1 }}
delay={0}
loader={<RecentModels skeleton />}
>
<RecentModels models={recentModels} />
</DelayedLoadingAndErrorWrapper>
<DelayedLoadingAndErrorWrapper
</LoadingAndErrorWrapper>
<LoadingAndErrorWrapper
error={modelsResult.error}
loading={modelsResult.isLoading}
style={{ flex: 1 }}
delay={0}
loader={<ModelsTable skeleton />}
>
<ModelsTable models={filteredModels} />
</DelayedLoadingAndErrorWrapper>
</LoadingAndErrorWrapper>
</>
)}
</Stack>

View File

@@ -1,73 +1,14 @@
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import LoadingAndErrorWrapper, {
type LoadingAndErrorWrapperProps,
} from "./LoadingAndErrorWrapper";
import LoadingAndErrorWrapper from "./LoadingAndErrorWrapper";
export type LoadingAndErrorWrapperProps = {
className?: string;
error: any;
loading: any;
/** Component that indicates that data is loading, for example a spinner */
loader?: ReactNode;
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
*/
export const DelayedLoadingAndErrorWrapper = ({
error,
loading,
delay = 300,
loader,
children,
...props
}: {
delay?: number;
} & LoadingAndErrorWrapperProps) => {
// If delay is zero show the wrapper immediately. Otherwise, apply a timeout
const [showWrapper, setShowWrapper] = useState(delay === 0);
props.loadingMessages ??= [];
useEffect(() => {
const timeout = setTimeout(() => {
setShowWrapper(true);
}, delay);
return () => clearTimeout(timeout);
}, [delay]);
// Handle error condition
if (error) {
return <LoadingAndErrorWrapper error={error} {...props} />;
}
// Handle loading condition
if (loading) {
if (!showWrapper) {
// Don't show the wrapper yet, but make tests aware that things are loading
return <span data-testid="loading-indicator" />;
}
if (loader) {
return loader;
}
return (
<LoadingAndErrorWrapper error={error} loading={loading} {...props}>
{children}
</LoadingAndErrorWrapper>
);
}
// Happy path
return <>{children}</>;
}: LoadingAndErrorWrapperProps) => {
return (
<LoadingAndErrorWrapper delay {...props}>
{children}
</LoadingAndErrorWrapper>
);
};

View File

@@ -1,59 +0,0 @@
import { render, screen } from "__support__/ui";
import { DelayedLoadingAndErrorWrapper } from "./DelayedLoadingAndErrorWrapper";
describe("DelayedLoadingAndErrorWrapper", () => {
describe("Loading", () => {
it("should display a loading message if given a true loading prop", () => {
render(<DelayedLoadingAndErrorWrapper error={false} loading={true} />);
expect(screen.getByTestId("loading-indicator")).toBeInTheDocument();
});
it("should display a delayed loader if given a true loading prop", async () => {
render(
<DelayedLoadingAndErrorWrapper
loader={<div data-testid="loader"></div>}
delay={100}
error={false}
loading={true}
/>,
);
expect(screen.getByTestId("loading-indicator")).toBeInTheDocument();
await screen.findByTestId("loader", undefined, { timeout: 150 });
});
it("should display a given child if loading is false", () => {
render(
<DelayedLoadingAndErrorWrapper loading={false} error={null}>
<div>Hey</div>
</DelayedLoadingAndErrorWrapper>,
);
expect(screen.getByText("Hey")).toBeInTheDocument();
});
it("shouldn't fail if loaded with null children and no wrapper", () => {
expect(() =>
render(
<DelayedLoadingAndErrorWrapper
error={false}
loading={false}
noWrapper
/>,
),
).not.toThrow();
});
});
describe("Errors", () => {
it("should display an error message if given an error object and loading is true", () => {
const error = {
type: 500,
message: "Big error here folks",
};
render(<DelayedLoadingAndErrorWrapper loading={true} error={error} />);
expect(screen.getByText(error.message)).toBeInTheDocument();
});
});
});

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,158 @@
import cx from "classnames";
import type { CSSProperties, ReactNode } from "react";
import { Children, useEffect, useState } from "react";
import { t } from "ttag";
import LoadingSpinner from "metabase/components/LoadingSpinner";
import CS from "metabase/css/core/index.css";
import { Box, Title } from "metabase/ui";
import type { CoreLoadingProps } from "./types";
import { getErrorAndLoading, getErrorMessage } from "./utils";
type DelayProps =
| {
/**
* Whether to wait before showing the loading indicator
* @see https://metaboat.slack.com/archives/C02H619CJ8K/p1709558533499399
*/
delay?: true;
delayLength?: number;
}
| { delay: false; delayLength: never };
export type LoadingAndErrorWrapperProps = {
/** Component that indicates that data is loading, for example a spinner */
loader?: ReactNode;
/** Component to show when the loading indicator is hidden. The default value
* includes a data-testid attribute, to make tests aware that something is
* loading */
blankComponent?: ReactNode;
className?: string;
style?: CSSProperties;
showSpinner?: boolean;
renderError?: (error: any) => ReactNode;
testId?: string;
noWrapper?: boolean;
noBackground?: boolean;
children?: ReactNode | (() => ReactNode);
} & DelayProps &
CoreLoadingProps;
/** Show a loading indicator, error message, or - if both loading and error are falsy - the children */
export const LoadingAndErrorWrapper = ({
blankComponent = <span data-testid="loading-indicator" />,
className,
delay,
delayLength = 300,
loader,
renderError,
showSpinner = true,
style,
testId,
noWrapper,
noBackground,
children,
...props
}: LoadingAndErrorWrapperProps) => {
const [error, loading] = getErrorAndLoading(props);
// If there is no delay, show the wrapper immediately. Otherwise, apply a timeout
const [isLoadingIndicatorShown, setIsLoadingIndicatorShown] = useState(
!delay,
);
useEffect(() => {
const timeout = setTimeout(() => {
setIsLoadingIndicatorShown(true);
}, delayLength);
return () => clearTimeout(timeout);
}, [delayLength]);
const contentClassName = cx(
CS.wrapper,
CS.py4,
CS.textBrand,
CS.textCentered,
CS.flexFull,
CS.flex,
CS.flexColumn,
CS.layoutCentered,
{ [CS.bgWhite]: !noBackground },
);
const getChildren = (child = children): ReactNode => {
if (Array.isArray(child)) {
return child.map(getChildren);
} else if (typeof child === "function") {
return (child as () => ReactNode)();
} else {
return child;
}
};
const renderErrorMessage = () =>
renderError ? (
<Box py="2rem">{renderError(getErrorMessage(error))}</Box>
) : (
<div className={contentClassName}>
<Title order={2} c={"var(--mb-color-text-light)"}>
{getErrorMessage(error)}
</Title>
</div>
);
// Handle error condition
if (error) {
return (
<div className={className} style={style} data-testid={testId}>
{renderErrorMessage()}
</div>
);
}
// Handle loading condition
else if (loading) {
if (isLoadingIndicatorShown) {
return (
<div className={className} style={style} data-testid={testId}>
{loader ?? (
<DefaultLoadingIndicator
showSpinner={showSpinner}
message={t`Loading…`}
contentClassName={contentClassName}
/>
)}
</div>
);
} else {
return <>{blankComponent}</>;
}
}
// Happy path
else {
const theChildren = getChildren();
return theChildren ? Children.only(theChildren) : null;
}
};
const DefaultLoadingIndicator = ({
showSpinner,
message,
contentClassName,
}: {
showSpinner?: boolean;
message: string;
contentClassName?: string;
}) => {
return (
<div className={contentClassName} data-testid="loading-indicator-wrapper">
{showSpinner && <LoadingSpinner />}
<h2 className={cx(CS.textNormal, CS.textLight, CS.mt1)}>{message}</h2>
</div>
);
};
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default LoadingAndErrorWrapper;

View File

@@ -1,88 +0,0 @@
import { act, render, screen } from "__support__/ui";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
describe("LoadingAndErrorWrapper", () => {
describe("Loading", () => {
it("should display a loading message if given a true loading prop", () => {
render(<LoadingAndErrorWrapper loading={true} />);
expect(screen.getByTestId("loading-indicator")).toBeInTheDocument();
});
it("should display a given child if loading is false", () => {
const Child = () => <div>Hey</div>;
render(
<LoadingAndErrorWrapper loading={false} error={null}>
{() => <Child />}
</LoadingAndErrorWrapper>,
);
expect(screen.getByText("Hey")).toBeInTheDocument();
});
it("shouldn't fail if loaded with null children and no wrapper", () => {
expect(() =>
render(<LoadingAndErrorWrapper loading={false} noWrapper />),
).not.toThrow();
});
it("should display a given scene during loading", () => {
const Scene = () => <div>Fun load animation</div>;
render(
<LoadingAndErrorWrapper
loading={true}
error={null}
loadingScenes={[<Scene key="0" />]}
/>,
);
expect(screen.getByText("Fun load animation")).toBeInTheDocument();
});
describe("cycling", () => {
it("should cycle through loading messages if provided", () => {
jest.useFakeTimers();
const interval = 6000;
render(
<LoadingAndErrorWrapper
loading={true}
error={null}
loadingMessages={["One", "Two", "Three"]}
messageInterval={interval}
/>,
);
expect(screen.getByText("One")).toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(interval);
});
expect(screen.getByText("Two")).toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(interval);
});
expect(screen.getByText("Three")).toBeInTheDocument();
act(() => {
jest.advanceTimersByTime(interval);
});
expect(screen.getByText("One")).toBeInTheDocument();
});
});
});
describe("Errors", () => {
it("should display an error message if given an error object", () => {
const error = {
type: 500,
message: "Big error here folks",
};
render(<LoadingAndErrorWrapper loading={true} error={error} />);
expect(screen.getByText(error.message)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,56 @@
import { render, screen } from "__support__/ui";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
describe("Loading", () => {
const Data = () => <div>Data</div>;
const error = {
type: 500,
message: "Big error here folks",
};
describe("loading condition", () => {
it("should display a loading indicator if given a true loading prop", () => {
render(<LoadingAndErrorWrapper loading />);
expect(screen.getByTestId("loading-indicator")).toBeInTheDocument();
});
it("should display a given component child if loading is false", () => {
render(
<LoadingAndErrorWrapper loading={false} error={null}>
<Data />
</LoadingAndErrorWrapper>,
);
expect(screen.getByText("Data")).toBeInTheDocument();
expect(screen.queryByTestId("loading-indicator")).not.toBeInTheDocument();
});
it("should display a given function child if loading is false", () => {
render(
<LoadingAndErrorWrapper loading={false} error={null}>
{() => <Data />}
</LoadingAndErrorWrapper>,
);
expect(screen.getByText("Data")).toBeInTheDocument();
expect(screen.queryByTestId("loading-indicator")).not.toBeInTheDocument();
});
it("shouldn't fail if loaded with null children and no wrapper", () => {
expect(() =>
render(<LoadingAndErrorWrapper loading={false} />),
).not.toThrow();
});
});
describe("error condition", () => {
it("should display an error message if given an error object", () => {
render(<LoadingAndErrorWrapper error={error} />);
expect(screen.getByText(error.message)).toBeInTheDocument();
});
});
describe("both conditions", () => {
it("should display an error message if given an error object and loading is true", () => {
render(<LoadingAndErrorWrapper loading error={error} />);
expect(screen.getByText(error.message)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,9 @@
export type CoreLoadingProps = {
error?: any;
loading?: boolean | boolean[];
};
export type LoadableResult = {
error?: any;
isLoading?: boolean;
};

View File

@@ -0,0 +1,34 @@
import { t } from "ttag";
import type { CoreLoadingProps, LoadableResult } from "./types";
/** Return the first truthy value in the input, or false if there are no truthy values */
export const or = <T,>(
/** The input can be either an array or a single value */
arg: T | T[],
) => (Array.isArray(arg) ? arg.find(Boolean) : arg) || false;
export const getErrorAndLoading = ({
error,
loading,
}: CoreLoadingProps): [any, boolean] => [or(error), or(loading)];
export const getErrorMessage = (error: any) => {
// NOTE: Dashboard API endpoint returns the error as JSON with `message` field
const message =
error?.data?.message ||
error?.data ||
error?.statusText ||
error?.message ||
error;
if (message && typeof message === "string") {
return message;
} else {
return t`An error occurred`;
}
};
export const unready = (result: LoadableResult) => {
const [error, loading] = getErrorAndLoading(result);
return Boolean(error || loading);
};

View File

@@ -0,0 +1,87 @@
import type { CoreLoadingProps } from "./types";
import { getErrorAndLoading, or } from "./utils";
describe("getErrorAndLoading", () => {
it("returns [false, false] by default", () => {
const [error, loading] = getErrorAndLoading({});
expect(error).toBe(false);
expect(loading).toBe(false);
});
it("extracts error and loading fields from props", () => {
const props: CoreLoadingProps = { error: false, loading: true };
const [error, loading] = getErrorAndLoading(props);
expect(error).toBe(false);
expect(loading).toBe(true);
});
it("handles loading array with values true and false", () => {
const props: CoreLoadingProps = {
error: false,
loading: [true, false],
};
const [error, loading] = getErrorAndLoading(props);
expect(error).toBe(false);
expect(loading).toBe(true);
});
it("handles loading array with values false and false", () => {
const props: CoreLoadingProps = {
error: true,
loading: [false, false],
};
const [error, loading] = getErrorAndLoading(props);
expect(error).toBe(true);
expect(loading).toBe(false);
});
it("handles error array with one truthy value", () => {
const props: CoreLoadingProps = {
error: [false, { message: "Error!" }],
loading: [false, false],
};
const [error, loading] = getErrorAndLoading(props);
expect(error).toEqual({ message: "Error!" });
expect(loading).toBe(false);
});
it("handles error array with no truthy values", () => {
const props: CoreLoadingProps = {
error: [false, null, undefined],
loading: [true, true],
};
const [error, loading] = getErrorAndLoading(props);
expect(error).toEqual(false);
expect(loading).toBe(true);
});
});
describe("or", () => {
it("returns the single truthy value if the input is a non-array truthy value", () => {
expect(or(42)).toBe(42);
});
it("returns false if the input is a non-array falsy value", () => {
expect(or(0)).toBe(false);
});
it("returns the first truthy value from an array of mixed values", () => {
expect(or([0, null, undefined, false, 5, true])).toBe(5);
});
it("returns false if all elements in an array are falsy", () => {
expect(or([0, null, undefined, false, ""])).toBe(false);
});
it("works with an empty array", () => {
expect(or([])).toBe(false);
});
it("returns the truthy value from an array with one truthy item", () => {
expect(or([true])).toBe(true);
});
it("returns the single falsy value if the array has only one falsy item", () => {
expect(or([0])).toBe(false);
});
});

View File

@@ -1,9 +1,9 @@
import { css } from "@emotion/react";
import styled from "@emotion/styled";
import cx from "classnames";
import type { ComponentPropsWithoutRef } from "react";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
import type { LoadingAndErrorWrapperProps } from "metabase/components/LoadingAndErrorWrapper/LoadingAndErrorWrapper";
import ColorS from "metabase/css/core/colors.module.css";
import DashboardS from "metabase/css/dashboard.module.css";
import { isEmbeddingSdk } from "metabase/env";
@@ -18,7 +18,11 @@ export const DashboardLoadingAndErrorWrapper = styled(
isNightMode,
className,
...props
}: ComponentPropsWithoutRef<typeof LoadingAndErrorWrapper>) => {
}: {
isFullscreen: boolean;
isNightMode: boolean;
isFullHeight: boolean;
} & LoadingAndErrorWrapperProps) => {
return (
<LoadingAndErrorWrapper
className={cx(className, DashboardS.Dashboard, {

View File

@@ -3,7 +3,7 @@ import { c, t } from "ttag";
import { useUserSetting } from "metabase/common/hooks";
import { useHasModels } from "metabase/common/hooks/use-has-models";
import CollapseSection from "metabase/components/CollapseSection";
import { DelayedLoadingAndErrorWrapper } from "metabase/components/LoadingAndErrorWrapper/DelayedLoadingAndErrorWrapper";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
import CS from "metabase/css/core/index.css";
import { Flex, Skeleton } from "metabase/ui";
@@ -49,7 +49,7 @@ export const BrowseNavSection = ({
headerClass={CS.mb1}
onToggle={setExpandBrowse}
>
<DelayedLoadingAndErrorWrapper
<LoadingAndErrorWrapper
loading={areModelsLoading}
error={modelsError}
loader={
@@ -58,7 +58,6 @@ export const BrowseNavSection = ({
<Skeleton radius="xs" w="4rem" h="1.2rem" />
</Flex>
}
delay={0}
>
{!noModelsExist && (
<PaddedSidebarLink
@@ -71,7 +70,7 @@ export const BrowseNavSection = ({
{t`Models`}
</PaddedSidebarLink>
)}
</DelayedLoadingAndErrorWrapper>
</LoadingAndErrorWrapper>
{hasDataAccess && (
<PaddedSidebarLink
icon="database"