Refactor abcframe

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-03-28 21:38:03 +02:00
parent 76cae637e6
commit ed6ab1b1fe
9 changed files with 2687 additions and 94 deletions

View file

@ -20,6 +20,7 @@ const customJestConfig = {
'^@/components/(.*)$': '<rootDir>/src/components/$1',
},
roots: ["<rootDir>/src"],
testEnvironment: 'jsdom',
testPathIgnorePatterns: ["/node_modules/", "/cypress/"]
}

View file

@ -465,6 +465,9 @@
"placeholderText": "Placeholder",
"upload": "Upload image"
}
},
"abcJs": {
"errorWhileRendering": "Error while rendering your score. Please check if the code is correct."
}
},
"views": {

View file

@ -11,7 +11,7 @@ import { Alert } from 'react-bootstrap'
export interface AsyncLoadingBoundaryProps {
loading: boolean
error?: Error
error?: boolean
componentName: string
}
@ -21,17 +21,17 @@ export interface AsyncLoadingBoundaryProps {
*
* @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 libraryName The name of the component that is currently loading. It will be shown in the error message.
* @param componentName The name of the component that is currently loading. It will be shown in the error message.
* @param children The child {@link ReactElement elements} that are only shown if the component isn't in loading or error state
*/
export const AsyncLibraryLoadingBoundary: React.FC<AsyncLoadingBoundaryProps> = ({
export const AsyncLoadingBoundary: React.FC<AsyncLoadingBoundaryProps> = ({
loading,
error,
componentName,
children
}) => {
useTranslation()
if (error) {
if (error === true) {
return (
<Alert variant={'danger'}>
<Trans i18nKey={'common.errorWhileLoading'} values={{ name: componentName }} />

View file

@ -0,0 +1,68 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { render, screen } from '@testing-library/react'
import { AbcFrame } from './abc-frame'
import { mockI18n } from '../../test-utils/mock-i18n'
describe('AbcFrame', () => {
beforeEach(async () => {
jest.resetModules()
jest.restoreAllMocks()
await mockI18n()
})
it('renders a music sheet', async () => {
const element = (
<AbcFrame
code={
'X:1\nT:Speed the Plough\nM:4/4\nC:Trad.\nK:G\n|:GABc dedB|dedB dedB|c2ec B2dB|c2A2 A2BA|\nGABc dedB|dedB dedB|c2ec B2dB|A2F2 G4:|\n|:g2gf gdBd|g2f2 e2d2|c2ec B2dB|c2A2 A2df|\ng2gf g2Bd|g2f2 e2d2|c2ec B2dB|A2F2 G4:|'
}
/>
)
const view = render(element)
expect(view.container).toMatchSnapshot()
expect(await screen.findByText('Sheet Music for "Speed the Plough"')).toBeInTheDocument()
expect(view.container).toMatchSnapshot()
})
it("renders an error if abcjs file can't be loaded", async () => {
jest.mock('abcjs', () => {
throw new Error('abc is exploded!')
})
const element = (
<AbcFrame
code={
'X:1\nT:Speed the Plough\nM:4/4\nC:Trad.\nK:G\n|:GABc dedB|dedB dedB|c2ec B2dB|c2A2 A2BA|\nGABc dedB|dedB dedB|c2ec B2dB|A2F2 G4:|\n|:g2gf gdBd|g2f2 e2d2|c2ec B2dB|c2A2 A2df|\ng2gf g2Bd|g2f2 e2d2|c2ec B2dB|A2F2 G4:|'
}
/>
)
const view = render(element)
expect(view.container).toMatchSnapshot()
expect(await screen.findByText('common.errorWhileLoading')).toBeInTheDocument()
expect(view.container).toMatchSnapshot()
})
it('renders an error if abcjs render function crashes', async () => {
jest.mock('abcjs', () => ({
renderAbc: () => {
throw new Error('abc is exploded!')
}
}))
const element = (
<AbcFrame
code={
'X:1\nT:Speed the Plough\nM:4/4\nC:Trad.\nK:G\n|:GABc dedB|dedB dedB|c2ec B2dB|c2A2 A2BA|\nGABc dedB|dedB dedB|c2ec B2dB|A2F2 G4:|\n|:g2gf gdBd|g2f2 e2d2|c2ec B2dB|c2A2 A2df|\ng2gf g2Bd|g2f2 e2d2|c2ec B2dB|A2F2 G4:|'
}
/>
)
const view = render(element)
expect(view.container).toMatchSnapshot()
expect(await screen.findByText('editor.embeddings.abcJs.errorWhileRendering')).toBeInTheDocument()
expect(view.container).toMatchSnapshot()
})
})

View file

@ -4,36 +4,58 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useEffect, useRef } from 'react'
import React, { useRef } from 'react'
import styles from './abc.module.scss'
import { Logger } from '../../../../utils/logger'
import type { CodeProps } from '../../replace-components/code-block-component-replacer'
import { cypressId } from '../../../../utils/cypress-attribute'
import { useAsync } from 'react-use'
import { AsyncLoadingBoundary } from '../../../common/async-loading-boundary'
import { WaitSpinner } from '../../../common/wait-spinner/wait-spinner'
import { useEffectWithCatch } from '../../../../hooks/common/use-effect-with-catch'
import { Alert } from 'react-bootstrap'
import { ShowIf } from '../../../common/show-if/show-if'
import { Trans } from 'react-i18next'
const log = new Logger('AbcFrame')
export const AbcFrame: React.FC<CodeProps> = ({ code }) => {
const container = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!container.current) {
const {
error: loadingError,
loading,
value: abcLib
} = useAsync(async () => {
try {
return await import(/* webpackChunkName: "abc.js" */ 'abcjs')
} catch (error) {
log.error('Error while loading abcjs', error)
throw error
}
}, [])
const renderError = useEffectWithCatch(() => {
const actualContainer = container.current
if (!actualContainer || !abcLib) {
return
}
const actualContainer = container.current
import(/* webpackChunkName: "abc.js" */ 'abcjs')
.then((importedLibrary) => {
importedLibrary.renderAbc(actualContainer, code, {})
})
.catch((error: Error) => {
log.error('Error while loading abcjs', error)
})
}, [code])
abcLib.renderAbc(actualContainer, code, {})
}, [code, abcLib])
return (
<AsyncLoadingBoundary loading={loading} error={!!loadingError} componentName={'abc.js'}>
<ShowIf condition={!!renderError}>
<Alert variant={'danger'}>
<Trans i18nKey={'editor.embeddings.abcJs.errorWhileRendering'} />
</Alert>
</ShowIf>
<div
ref={container}
className={`${styles['abcjs-score']} bg-white text-black svg-container`}
{...cypressId('abcjs')}
/>
{...cypressId('abcjs')}>
<WaitSpinner />
</div>
</AsyncLoadingBoundary>
)
}

View file

@ -8,7 +8,7 @@ import React from 'react'
import { CopyToClipboardButton } from '../../../common/copyable/copy-to-clipboard-button/copy-to-clipboard-button'
import styles from './highlighted-code.module.scss'
import { cypressAttribute, cypressId } from '../../../../utils/cypress-attribute'
import { AsyncLibraryLoadingBoundary } from '../../../common/async-library-loading-boundary'
import { AsyncLoadingBoundary } from '../../../common/async-loading-boundary'
import { useAsyncHighlightedCodeDom } from './hooks/use-async-highlighted-code-dom'
import { useAttachLineNumbers } from './hooks/use-attach-line-numbers'
@ -33,7 +33,7 @@ export const HighlightedCode: React.FC<HighlightedCodeProps> = ({ code, language
const wrappedDomLines = useAttachLineNumbers(highlightedLines, startLineNumber)
return (
<AsyncLibraryLoadingBoundary loading={loading} error={error} componentName={'highlight.js'}>
<AsyncLoadingBoundary loading={loading} error={!!error} componentName={'highlight.js'}>
<div className={styles['code-highlighter']} {...cypressId('highlighted-code-block')}>
<code
{...cypressId('code-highlighter')}
@ -46,7 +46,7 @@ export const HighlightedCode: React.FC<HighlightedCodeProps> = ({ code, language
<CopyToClipboardButton content={code} {...cypressId('copy-code-button')} />
</div>
</div>
</AsyncLibraryLoadingBoundary>
</AsyncLoadingBoundary>
)
}

View file

@ -0,0 +1,27 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { TFunction } from 'i18next'
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
/**
* Initializes i18n with minimal settings and without any data, so it just returns the used key as translation.
*
* @return A promise that resolves if i18n has been initialized
*/
export const mockI18n = (): Promise<TFunction> => {
return i18n.use(initReactI18next).init({
lng: 'en',
fallbackLng: 'en',
ns: ['translationsNS'],
defaultNS: 'translationsNS',
interpolation: {
escapeValue: false
},
resources: { en: { translationsNS: {} } }
})
}

View file

@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { DependencyList, EffectCallback } from 'react'
import { useEffect, useState } from 'react'
/**
* Executes a side effects but catches any thrown error.
*
* @param effect The side effect to execute
* @param deps The dependencies of the effect
* @return The produced error (if occurred)
*/
export const useEffectWithCatch = (effect: EffectCallback, deps: DependencyList = []): Error | undefined => {
const [error, setError] = useState<Error | undefined>(undefined)
useEffect(() => {
try {
return effect()
} catch (error) {
setError(error as Error)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
return error
}