feat(async-loading-boundary): extract custom error component into separate component

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-12-29 12:25:06 +01:00
parent 6eb6b6a25f
commit 26c1f1bcaa
17 changed files with 265 additions and 37 deletions

View file

@ -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>
`;

View file

@ -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>
`;

View file

@ -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()
})
})

View file

@ -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>
)
}

View file

@ -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()
})
})

View file

@ -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>
}
}

View file

@ -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>
)
}

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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'

View file

@ -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>
)
}