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', '^@/components/(.*)$': '<rootDir>/src/components/$1',
}, },
roots: ["<rootDir>/src"], roots: ["<rootDir>/src"],
testEnvironment: 'jsdom',
testPathIgnorePatterns: ["/node_modules/", "/cypress/"] testPathIgnorePatterns: ["/node_modules/", "/cypress/"]
} }

View file

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

View file

@ -11,7 +11,7 @@ import { Alert } from 'react-bootstrap'
export interface AsyncLoadingBoundaryProps { export interface AsyncLoadingBoundaryProps {
loading: boolean loading: boolean
error?: Error error?: boolean
componentName: string 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 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 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 * @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, loading,
error, error,
componentName, componentName,
children children
}) => { }) => {
useTranslation() useTranslation()
if (error) { if (error === true) {
return ( return (
<Alert variant={'danger'}> <Alert variant={'danger'}>
<Trans i18nKey={'common.errorWhileLoading'} values={{ name: componentName }} /> <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 * 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 styles from './abc.module.scss'
import { Logger } from '../../../../utils/logger' import { Logger } from '../../../../utils/logger'
import type { CodeProps } from '../../replace-components/code-block-component-replacer' import type { CodeProps } from '../../replace-components/code-block-component-replacer'
import { cypressId } from '../../../../utils/cypress-attribute' 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') const log = new Logger('AbcFrame')
export const AbcFrame: React.FC<CodeProps> = ({ code }) => { export const AbcFrame: React.FC<CodeProps> = ({ code }) => {
const container = useRef<HTMLDivElement>(null) const container = useRef<HTMLDivElement>(null)
useEffect(() => { const {
if (!container.current) { 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 return
} }
const actualContainer = container.current abcLib.renderAbc(actualContainer, code, {})
import(/* webpackChunkName: "abc.js" */ 'abcjs') }, [code, abcLib])
.then((importedLibrary) => {
importedLibrary.renderAbc(actualContainer, code, {})
})
.catch((error: Error) => {
log.error('Error while loading abcjs', error)
})
}, [code])
return ( return (
<AsyncLoadingBoundary loading={loading} error={!!loadingError} componentName={'abc.js'}>
<ShowIf condition={!!renderError}>
<Alert variant={'danger'}>
<Trans i18nKey={'editor.embeddings.abcJs.errorWhileRendering'} />
</Alert>
</ShowIf>
<div <div
ref={container} ref={container}
className={`${styles['abcjs-score']} bg-white text-black svg-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 { CopyToClipboardButton } from '../../../common/copyable/copy-to-clipboard-button/copy-to-clipboard-button'
import styles from './highlighted-code.module.scss' import styles from './highlighted-code.module.scss'
import { cypressAttribute, cypressId } from '../../../../utils/cypress-attribute' 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 { useAsyncHighlightedCodeDom } from './hooks/use-async-highlighted-code-dom'
import { useAttachLineNumbers } from './hooks/use-attach-line-numbers' 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) const wrappedDomLines = useAttachLineNumbers(highlightedLines, startLineNumber)
return ( 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')}> <div className={styles['code-highlighter']} {...cypressId('highlighted-code-block')}>
<code <code
{...cypressId('code-highlighter')} {...cypressId('code-highlighter')}
@ -46,7 +46,7 @@ export const HighlightedCode: React.FC<HighlightedCodeProps> = ({ code, language
<CopyToClipboardButton content={code} {...cypressId('copy-code-button')} /> <CopyToClipboardButton content={code} {...cypressId('copy-code-button')} />
</div> </div>
</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
}