diff --git a/frontend/src/components/common/async-loading-boundary/__snapshots__/async-loading-boundary.test.tsx.snap b/frontend/src/components/common/async-loading-boundary/__snapshots__/async-loading-boundary.test.tsx.snap new file mode 100644 index 000000000..8079c47b1 --- /dev/null +++ b/frontend/src/components/common/async-loading-boundary/__snapshots__/async-loading-boundary.test.tsx.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Async loading boundary shows a waiting spinner if loading 1`] = ` +<div> + <div + class="m-3 d-flex align-items-center justify-content-center" + > + <i + class="fa fa-spinner fa-spin " + /> + </div> +</div> +`; + +exports[`Async loading boundary shows an error if error is given with loading 1`] = ` +<div> + <div + class="fade alert alert-danger show" + role="alert" + > + common.errorWhileLoading + </div> +</div> +`; + +exports[`Async loading boundary shows an error if error is given without loading 1`] = ` +<div> + <div + class="fade alert alert-danger show" + role="alert" + > + common.errorWhileLoading + </div> +</div> +`; + +exports[`Async loading boundary shows the children if not loading and no error 1`] = ` +<div> + children +</div> +`; diff --git a/frontend/src/components/common/async-loading-boundary/__snapshots__/custom-async-loading-boundary.test.tsx.snap b/frontend/src/components/common/async-loading-boundary/__snapshots__/custom-async-loading-boundary.test.tsx.snap new file mode 100644 index 000000000..c01a834d0 --- /dev/null +++ b/frontend/src/components/common/async-loading-boundary/__snapshots__/custom-async-loading-boundary.test.tsx.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Custom error async loading boundary shows a waiting spinner if loading 1`] = ` +<div> + wait +</div> +`; + +exports[`Custom error async loading boundary shows an error if error is given with loading 1`] = ` +<div> + error +</div> +`; + +exports[`Custom error async loading boundary shows an error if error is given without loading 1`] = ` +<div> + error +</div> +`; + +exports[`Custom error async loading boundary shows the children if not loading and no error 1`] = ` +<div> + children +</div> +`; diff --git a/frontend/src/components/common/async-loading-boundary/async-loading-boundary.test.tsx b/frontend/src/components/common/async-loading-boundary/async-loading-boundary.test.tsx new file mode 100644 index 000000000..56e2ee685 --- /dev/null +++ b/frontend/src/components/common/async-loading-boundary/async-loading-boundary.test.tsx @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { mockI18n } from '../../markdown-renderer/test-utils/mock-i18n' +import { AsyncLoadingBoundary } from './async-loading-boundary' +import { render } from '@testing-library/react' + +describe('Async loading boundary', () => { + beforeAll(() => mockI18n()) + + it('shows the children if not loading and no error', () => { + const view = render( + <AsyncLoadingBoundary loading={false} componentName={'test'}> + children + </AsyncLoadingBoundary> + ) + expect(view.container).toMatchSnapshot() + }) + + it('shows a waiting spinner if loading', () => { + const view = render( + <AsyncLoadingBoundary loading={true} componentName={'test'}> + children + </AsyncLoadingBoundary> + ) + expect(view.container).toMatchSnapshot() + }) + + it('shows an error if error is given without loading', () => { + const view = render( + <AsyncLoadingBoundary loading={false} error={new Error('error')} componentName={'test'}> + children + </AsyncLoadingBoundary> + ) + expect(view.container).toMatchSnapshot() + }) + + it('shows an error if error is given with loading', () => { + const view = render( + <AsyncLoadingBoundary loading={true} error={new Error('error')} componentName={'test'}> + children + </AsyncLoadingBoundary> + ) + expect(view.container).toMatchSnapshot() + }) +}) diff --git a/frontend/src/components/common/async-loading-boundary.tsx b/frontend/src/components/common/async-loading-boundary/async-loading-boundary.tsx similarity index 71% rename from frontend/src/components/common/async-loading-boundary.tsx rename to frontend/src/components/common/async-loading-boundary/async-loading-boundary.tsx index fc2fd7761..336e3897b 100644 --- a/frontend/src/components/common/async-loading-boundary.tsx +++ b/frontend/src/components/common/async-loading-boundary/async-loading-boundary.tsx @@ -3,9 +3,10 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { WaitSpinner } from './wait-spinner/wait-spinner' -import type { PropsWithChildren, ReactNode } from 'react' -import React, { Fragment } from 'react' +import { WaitSpinner } from '../wait-spinner/wait-spinner' +import { CustomAsyncLoadingBoundary } from './custom-async-loading-boundary' +import type { PropsWithChildren } from 'react' +import React, { Fragment, useMemo } from 'react' import { Alert } from 'react-bootstrap' import { Trans, useTranslation } from 'react-i18next' @@ -13,7 +14,6 @@ export interface AsyncLoadingBoundaryProps { loading: boolean error?: Error | boolean componentName: string - errorComponent?: ReactNode } /** @@ -30,22 +30,27 @@ export const AsyncLoadingBoundary: React.FC<PropsWithChildren<AsyncLoadingBounda loading, error, componentName, - errorComponent, children }) => { useTranslation() - if (error !== undefined && error !== false) { - if (errorComponent) { - return <Fragment>{errorComponent}</Fragment> - } - return ( + + const errorComponent = useMemo(() => { + return error ? ( <Alert variant={'danger'}> <Trans i18nKey={'common.errorWhileLoading'} values={{ name: componentName }} /> </Alert> + ) : ( + <Fragment></Fragment> ) - } else if (loading) { - return <WaitSpinner /> - } else { - return <Fragment>{children}</Fragment> - } + }, [componentName, error]) + + return ( + <CustomAsyncLoadingBoundary + loading={loading} + error={error} + errorComponent={errorComponent} + loadingComponent={<WaitSpinner />}> + {children} + </CustomAsyncLoadingBoundary> + ) } diff --git a/frontend/src/components/common/async-loading-boundary/custom-async-loading-boundary.test.tsx b/frontend/src/components/common/async-loading-boundary/custom-async-loading-boundary.test.tsx new file mode 100644 index 000000000..8b15ca5d4 --- /dev/null +++ b/frontend/src/components/common/async-loading-boundary/custom-async-loading-boundary.test.tsx @@ -0,0 +1,56 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import { mockI18n } from '../../markdown-renderer/test-utils/mock-i18n' +import { CustomAsyncLoadingBoundary } from './custom-async-loading-boundary' +import { render } from '@testing-library/react' + +describe('Custom error async loading boundary', () => { + beforeAll(() => mockI18n()) + + it('shows the children if not loading and no error', () => { + const view = render( + <CustomAsyncLoadingBoundary loading={false} errorComponent={'error'} loadingComponent={'wait'}> + children + </CustomAsyncLoadingBoundary> + ) + expect(view.container).toMatchSnapshot() + }) + + it('shows a waiting spinner if loading', () => { + const view = render( + <CustomAsyncLoadingBoundary loading={true} errorComponent={'error'} loadingComponent={'wait'}> + children + </CustomAsyncLoadingBoundary> + ) + expect(view.container).toMatchSnapshot() + }) + + it('shows an error if error is given without loading', () => { + const view = render( + <CustomAsyncLoadingBoundary + loading={false} + error={new Error('error')} + errorComponent={'error'} + loadingComponent={'wait'}> + children + </CustomAsyncLoadingBoundary> + ) + expect(view.container).toMatchSnapshot() + }) + + it('shows an error if error is given with loading', () => { + const view = render( + <CustomAsyncLoadingBoundary + loading={true} + error={new Error('error')} + errorComponent={'error'} + loadingComponent={'wait'}> + children + </CustomAsyncLoadingBoundary> + ) + expect(view.container).toMatchSnapshot() + }) +}) diff --git a/frontend/src/components/common/async-loading-boundary/custom-async-loading-boundary.tsx b/frontend/src/components/common/async-loading-boundary/custom-async-loading-boundary.tsx new file mode 100644 index 000000000..738feae53 --- /dev/null +++ b/frontend/src/components/common/async-loading-boundary/custom-async-loading-boundary.tsx @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ +import type { PropsWithChildren, ReactNode } from 'react' +import React, { Fragment } from 'react' +import { useTranslation } from 'react-i18next' + +export interface CustomErrorAsyncLoadingBoundaryProps { + loading: boolean + error?: Error | boolean + errorComponent: ReactNode + loadingComponent: ReactNode +} + +/** + * Indicates that a component currently loading or an error occurred. + * It's meant to be used in combination with useAsync from react-use. + * + * @param loading Indicates that the component is currently loading. Setting this will show a spinner instead of the children. + * @param error Indicates that an error occurred during the loading process. Setting this to any non-null value will show an error message instead of the children. + * @param componentName The name of the component that is currently loading. It will be shown in the error message. + * @param errorComponent Optional component that will be used in case of an error instead of the default alert message. + * @param children The child {@link ReactElement elements} that are only shown if the component isn't in loading or error state + */ +export const CustomAsyncLoadingBoundary: React.FC<PropsWithChildren<CustomErrorAsyncLoadingBoundaryProps>> = ({ + loading, + error, + errorComponent, + loadingComponent, + children +}) => { + useTranslation() + if (error) { + return <Fragment>{errorComponent}</Fragment> + } else if (loading) { + return <Fragment>{loadingComponent}</Fragment> + } else { + return <Fragment>{children}</Fragment> + } +} diff --git a/frontend/src/components/common/note-loading-boundary/note-loading-boundary.tsx b/frontend/src/components/common/note-loading-boundary/note-loading-boundary.tsx index 779e8e7a5..082eff093 100644 --- a/frontend/src/components/common/note-loading-boundary/note-loading-boundary.tsx +++ b/frontend/src/components/common/note-loading-boundary/note-loading-boundary.tsx @@ -5,11 +5,12 @@ */ import { LoadingScreen } from '../../application-loader/loading-screen/loading-screen' import { CommonErrorPage } from '../../error-pages/common-error-page' +import { CustomAsyncLoadingBoundary } from '../async-loading-boundary/custom-async-loading-boundary' import { ShowIf } from '../show-if/show-if' import { CreateNonExistingNoteHint } from './create-non-existing-note-hint' import { useLoadNoteFromServer } from './hooks/use-load-note-from-server' import type { PropsWithChildren } from 'react' -import React, { Fragment, useEffect } from 'react' +import React, { useEffect, useMemo } from 'react' /** * Loads the note identified by the note-id in the URL. @@ -18,16 +19,17 @@ import React, { Fragment, useEffect } from 'react' * * @param children The react elements that will be shown when the loading was successful. */ -export const NoteLoadingBoundary: React.FC<PropsWithChildren<unknown>> = ({ children }) => { - const [{ error, loading }, loadNoteFromServer] = useLoadNoteFromServer() +export const NoteLoadingBoundary: React.FC<PropsWithChildren> = ({ children }) => { + const [{ error, loading, value }, loadNoteFromServer] = useLoadNoteFromServer() useEffect(() => { loadNoteFromServer() }, [loadNoteFromServer]) - if (loading) { - return <LoadingScreen /> - } else if (error) { + const errorComponent = useMemo(() => { + if (error === undefined) { + return <></> + } return ( <CommonErrorPage titleI18nKey={`${error.message}.title`} descriptionI18nKey={`${error.message}.description`}> <ShowIf condition={error.message === 'api.note.notFound'}> @@ -35,7 +37,15 @@ export const NoteLoadingBoundary: React.FC<PropsWithChildren<unknown>> = ({ chil </ShowIf> </CommonErrorPage> ) - } else { - return <Fragment>{children}</Fragment> - } + }, [error, loadNoteFromServer]) + + return ( + <CustomAsyncLoadingBoundary + loading={loading || !value} + error={error} + errorComponent={errorComponent} + loadingComponent={<LoadingScreen />}> + {children} + </CustomAsyncLoadingBoundary> + ) } diff --git a/frontend/src/components/common/user-avatar/user-avatar-for-username.tsx b/frontend/src/components/common/user-avatar/user-avatar-for-username.tsx index 0c3a9a170..a778fecbb 100644 --- a/frontend/src/components/common/user-avatar/user-avatar-for-username.tsx +++ b/frontend/src/components/common/user-avatar/user-avatar-for-username.tsx @@ -5,7 +5,7 @@ */ import { getUser } from '../../../api/users' import type { UserInfo } from '../../../api/users/types' -import { AsyncLoadingBoundary } from '../async-loading-boundary' +import { AsyncLoadingBoundary } from '../async-loading-boundary/async-loading-boundary' import type { UserAvatarProps } from './user-avatar' import { UserAvatar } from './user-avatar' import React from 'react' diff --git a/frontend/src/components/editor-page/document-bar/revisions/revision-list.tsx b/frontend/src/components/editor-page/document-bar/revisions/revision-list.tsx index f89281294..c85b73561 100644 --- a/frontend/src/components/editor-page/document-bar/revisions/revision-list.tsx +++ b/frontend/src/components/editor-page/document-bar/revisions/revision-list.tsx @@ -5,7 +5,7 @@ */ import { getAllRevisions } from '../../../../api/revisions' import { useApplicationState } from '../../../../hooks/common/use-application-state' -import { AsyncLoadingBoundary } from '../../../common/async-loading-boundary' +import { AsyncLoadingBoundary } from '../../../common/async-loading-boundary/async-loading-boundary' import { RevisionListEntry } from './revision-list-entry' import { DateTime } from 'luxon' import React, { useMemo } from 'react' diff --git a/frontend/src/components/editor-page/document-bar/revisions/revision-viewer.tsx b/frontend/src/components/editor-page/document-bar/revisions/revision-viewer.tsx index 23509b294..03c2a9804 100644 --- a/frontend/src/components/editor-page/document-bar/revisions/revision-viewer.tsx +++ b/frontend/src/components/editor-page/document-bar/revisions/revision-viewer.tsx @@ -6,7 +6,7 @@ import { getRevision } from '../../../../api/revisions' import { useApplicationState } from '../../../../hooks/common/use-application-state' import { useDarkModeState } from '../../../../hooks/common/use-dark-mode-state' -import { AsyncLoadingBoundary } from '../../../common/async-loading-boundary' +import { AsyncLoadingBoundary } from '../../../common/async-loading-boundary/async-loading-boundary' import { invertUnifiedPatch } from './invert-unified-patch' import { Optional } from '@mrdrogdrog/optional' import { applyPatch, parsePatch } from 'diff' diff --git a/frontend/src/components/intro-page/intro-custom-content.tsx b/frontend/src/components/intro-page/intro-custom-content.tsx index 391934be7..83e357b1b 100644 --- a/frontend/src/components/intro-page/intro-custom-content.tsx +++ b/frontend/src/components/intro-page/intro-custom-content.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { Logger } from '../../utils/logger' -import { AsyncLoadingBoundary } from '../common/async-loading-boundary' +import { AsyncLoadingBoundary } from '../common/async-loading-boundary/async-loading-boundary' import { RenderIframe } from '../editor-page/renderer-pane/render-iframe' import { RendererType } from '../render-page/window-post-message-communicator/rendering-message' import { fetchFrontPageContent } from './requests' diff --git a/frontend/src/extensions/extra-integrations/abcjs/abc-frame.tsx b/frontend/src/extensions/extra-integrations/abcjs/abc-frame.tsx index 9fdbde262..10da79e35 100644 --- a/frontend/src/extensions/extra-integrations/abcjs/abc-frame.tsx +++ b/frontend/src/extensions/extra-integrations/abcjs/abc-frame.tsx @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { AsyncLoadingBoundary } from '../../../components/common/async-loading-boundary' +import { AsyncLoadingBoundary } from '../../../components/common/async-loading-boundary/async-loading-boundary' import { ShowIf } from '../../../components/common/show-if/show-if' import { WaitSpinner } from '../../../components/common/wait-spinner/wait-spinner' import type { CodeProps } from '../../../components/markdown-renderer/replace-components/code-block-component-replacer' diff --git a/frontend/src/extensions/extra-integrations/flowchart/flowchart.tsx b/frontend/src/extensions/extra-integrations/flowchart/flowchart.tsx index 6fc8171fb..8e0434abc 100644 --- a/frontend/src/extensions/extra-integrations/flowchart/flowchart.tsx +++ b/frontend/src/extensions/extra-integrations/flowchart/flowchart.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import fontStyles from '../../../../global-styles/variables.module.scss' -import { AsyncLoadingBoundary } from '../../../components/common/async-loading-boundary' +import { AsyncLoadingBoundary } from '../../../components/common/async-loading-boundary/async-loading-boundary' import { ShowIf } from '../../../components/common/show-if/show-if' import type { CodeProps } from '../../../components/markdown-renderer/replace-components/code-block-component-replacer' import { useDarkModeState } from '../../../hooks/common/use-dark-mode-state' diff --git a/frontend/src/extensions/extra-integrations/graphviz/graphviz-frame.tsx b/frontend/src/extensions/extra-integrations/graphviz/graphviz-frame.tsx index d0b03c130..0233fee58 100644 --- a/frontend/src/extensions/extra-integrations/graphviz/graphviz-frame.tsx +++ b/frontend/src/extensions/extra-integrations/graphviz/graphviz-frame.tsx @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { AsyncLoadingBoundary } from '../../../components/common/async-loading-boundary' +import { AsyncLoadingBoundary } from '../../../components/common/async-loading-boundary/async-loading-boundary' import { ShowIf } from '../../../components/common/show-if/show-if' import type { CodeProps } from '../../../components/markdown-renderer/replace-components/code-block-component-replacer' import { cypressId } from '../../../utils/cypress-attribute' diff --git a/frontend/src/extensions/extra-integrations/highlighted-code-fence/highlighted-code.tsx b/frontend/src/extensions/extra-integrations/highlighted-code-fence/highlighted-code.tsx index f08b01cd8..8b4cc81d5 100644 --- a/frontend/src/extensions/extra-integrations/highlighted-code-fence/highlighted-code.tsx +++ b/frontend/src/extensions/extra-integrations/highlighted-code-fence/highlighted-code.tsx @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { AsyncLoadingBoundary } from '../../../components/common/async-loading-boundary' +import { AsyncLoadingBoundary } from '../../../components/common/async-loading-boundary/async-loading-boundary' import { CopyToClipboardButton } from '../../../components/common/copyable/copy-to-clipboard-button/copy-to-clipboard-button' import { cypressAttribute, cypressId } from '../../../utils/cypress-attribute' import { testId } from '../../../utils/test-id' diff --git a/frontend/src/extensions/extra-integrations/vega-lite/vega-lite-chart.tsx b/frontend/src/extensions/extra-integrations/vega-lite/vega-lite-chart.tsx index 57369c161..58fe1f839 100644 --- a/frontend/src/extensions/extra-integrations/vega-lite/vega-lite-chart.tsx +++ b/frontend/src/extensions/extra-integrations/vega-lite/vega-lite-chart.tsx @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: AGPL-3.0-only */ -import { AsyncLoadingBoundary } from '../../../components/common/async-loading-boundary' +import { AsyncLoadingBoundary } from '../../../components/common/async-loading-boundary/async-loading-boundary' import { ShowIf } from '../../../components/common/show-if/show-if' import type { CodeProps } from '../../../components/markdown-renderer/replace-components/code-block-component-replacer' import { Logger } from '../../../utils/logger' diff --git a/frontend/src/pages/new.tsx b/frontend/src/pages/new.tsx index f64a647d7..938427f5a 100644 --- a/frontend/src/pages/new.tsx +++ b/frontend/src/pages/new.tsx @@ -4,7 +4,8 @@ * SPDX-License-Identifier: AGPL-3.0-only */ import { createNote } from '../api/notes' -import { AsyncLoadingBoundary } from '../components/common/async-loading-boundary' +import { LoadingScreen } from '../components/application-loader/loading-screen/loading-screen' +import { CustomAsyncLoadingBoundary } from '../components/common/async-loading-boundary/custom-async-loading-boundary' import { Redirect } from '../components/common/redirect' import { CommonErrorPage } from '../components/error-pages/common-error-page' import { useSingleStringUrlParameter } from '../hooks/common/use-single-string-url-parameter' @@ -22,10 +23,10 @@ export const NewNotePage: NextPage = () => { }, [newContent]) return ( - <AsyncLoadingBoundary + <CustomAsyncLoadingBoundary loading={loading} - componentName={'NewNotePage'} error={error} + loadingComponent={<LoadingScreen />} errorComponent={ <CommonErrorPage titleI18nKey={'errors.noteCreationFailed.title'} @@ -33,7 +34,7 @@ export const NewNotePage: NextPage = () => { /> }> {value ? <Redirect to={`/n/${value.metadata.primaryAddress}`} /> : null} - </AsyncLoadingBoundary> + </CustomAsyncLoadingBoundary> ) }