feat: migrate frontend app to nextjs app router

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2023-05-29 17:32:44 +02:00
parent 5b5dabc84e
commit 8602645bea
108 changed files with 893 additions and 1188 deletions

View file

@ -1,10 +1,11 @@
'use client'
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { PropsWithChildren } from 'react'
import React, { createContext, useState } from 'react'
import React, { createContext } from 'react'
export interface BaseUrls {
renderer: string
@ -27,10 +28,9 @@ export const BaseUrlContextProvider: React.FC<PropsWithChildren<BaseUrlContextPr
baseUrls,
children
}) => {
const [baseUrlState] = useState<undefined | BaseUrls>(() => baseUrls)
return baseUrlState === undefined ? (
return baseUrls === undefined ? (
<span className={'text-white bg-dark'}>HedgeDoc is not configured correctly! Please check the server log.</span>
) : (
<baseUrlContext.Provider value={baseUrlState}>{children}</baseUrlContext.Provider>
<baseUrlContext.Provider value={baseUrls}>{children}</baseUrlContext.Provider>
)
}

View file

@ -3,7 +3,6 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { isClientSideRendering } from '../../../../utils/is-client-side-rendering'
import { Logger } from '../../../../utils/logger'
import { UiIcon } from '../../icons/ui-icon'
import { ShowIf } from '../../show-if/show-if'
@ -30,7 +29,7 @@ export const CopyableField: React.FC<CopyableFieldProps> = ({ content, shareOrig
useTranslation()
const sharingSupported = useMemo(
() => shareOriginUrl !== undefined && isClientSideRendering() && typeof navigator.share === 'function',
() => shareOriginUrl !== undefined && typeof navigator.share === 'function',
[shareOriginUrl]
)

View file

@ -3,7 +3,6 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { isClientSideRendering } from '../../../../utils/is-client-side-rendering'
import { Logger } from '../../../../utils/logger'
import { ShowIf } from '../../show-if/show-if'
import type { ReactElement, RefObject } from 'react'
@ -45,11 +44,6 @@ export const useCopyOverlay = (
}, [reset, showState])
const copyToClipboard = useCallback(() => {
if (!isClientSideRendering()) {
setShowState(SHOW_STATE.ERROR)
log.error('Clipboard not available in server side rendering')
return
}
if (typeof navigator.clipboard === 'undefined') {
setShowState(SHOW_STATE.ERROR)
return

View file

@ -1,17 +1,13 @@
'use client'
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { getConfig } from '../../../api/config'
import type { FrontendConfig } from '../../../api/config/types'
import { useBaseUrl } from '../../../hooks/common/use-base-url'
import { Logger } from '../../../utils/logger'
import { frontendConfigContext } from './context'
import type { PropsWithChildren } from 'react'
import React, { useEffect, useState } from 'react'
const logger = new Logger('FrontendConfigContextProvider')
import React from 'react'
interface FrontendConfigContextProviderProps extends PropsWithChildren {
config?: FrontendConfig
@ -24,22 +20,9 @@ interface FrontendConfigContextProviderProps extends PropsWithChildren {
* @param children the react elements to show if the config is valid
*/
export const FrontendConfigContextProvider: React.FC<FrontendConfigContextProviderProps> = ({ config, children }) => {
const [configState, setConfigState] = useState<undefined | FrontendConfig>(() => config)
const baseUrl = useBaseUrl()
useEffect(() => {
if (config === undefined && configState === undefined) {
logger.debug('Fetching Config client side')
getConfig(baseUrl)
.then((config) => setConfigState(config))
.catch((error) => logger.error(error))
}
}, [baseUrl, config, configState])
return configState === undefined ? (
return config === undefined ? (
<span className={'text-white bg-dark'}>No frontend config received! Please check the server log.</span>
) : (
<frontendConfigContext.Provider value={configState}>{children}</frontendConfigContext.Provider>
<frontendConfigContext.Provider value={config}>{children}</frontendConfigContext.Provider>
)
}

View file

@ -38,6 +38,8 @@ exports[`create non existing note hint renders an button as initial state 1`] =
</div>
`;
exports[`create non existing note hint renders nothing if no note id has been provided 1`] = `<div />`;
exports[`create non existing note hint shows an error message if note couldn't be created 1`] = `
<div>
<div

View file

@ -5,7 +5,6 @@
*/
import * as createNoteWithPrimaryAliasModule from '../../../api/notes'
import type { Note, NoteMetadata } from '../../../api/notes/types'
import * as useSingleStringUrlParameterModule from '../../../hooks/common/use-single-string-url-parameter'
import { mockI18n } from '../../../test-utils/mock-i18n'
import { CreateNonExistingNoteHint } from './create-non-existing-note-hint'
import { waitForOtherPromisesToFinish } from '@hedgedoc/commons'
@ -18,14 +17,6 @@ jest.mock('../../../hooks/common/use-single-string-url-parameter')
describe('create non existing note hint', () => {
const mockedNoteId = 'mockedNoteId'
const mockGetNoteIdQueryParameter = () => {
const expectedQueryParameter = 'noteId'
jest.spyOn(useSingleStringUrlParameterModule, 'useSingleStringUrlParameter').mockImplementation((parameter) => {
expect(parameter).toBe(expectedQueryParameter)
return mockedNoteId
})
}
const mockCreateNoteWithPrimaryAlias = () => {
jest
.spyOn(createNoteWithPrimaryAliasModule, 'createNoteWithPrimaryAlias')
@ -59,14 +50,24 @@ describe('create non existing note hint', () => {
jest.resetModules()
})
beforeEach(() => {
mockGetNoteIdQueryParameter()
it('renders nothing if no note id has been provided', async () => {
const onNoteCreatedCallback = jest.fn()
const view = render(
<CreateNonExistingNoteHint noteId={undefined} onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>
)
await waitForOtherPromisesToFinish()
expect(onNoteCreatedCallback).not.toBeCalled()
expect(view.container).toMatchSnapshot()
})
it('renders an button as initial state', async () => {
mockCreateNoteWithPrimaryAlias()
const onNoteCreatedCallback = jest.fn()
const view = render(<CreateNonExistingNoteHint onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>)
const view = render(
<CreateNonExistingNoteHint
noteId={mockedNoteId}
onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>
)
await screen.findByTestId('createNoteMessage')
await waitForOtherPromisesToFinish()
expect(onNoteCreatedCallback).not.toBeCalled()
@ -76,7 +77,11 @@ describe('create non existing note hint', () => {
it('renders a waiting message when button is clicked', async () => {
mockCreateNoteWithPrimaryAlias()
const onNoteCreatedCallback = jest.fn()
const view = render(<CreateNonExistingNoteHint onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>)
const view = render(
<CreateNonExistingNoteHint
noteId={mockedNoteId}
onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>
)
const button = await screen.findByTestId('createNoteButton')
await act<void>(() => {
button.click()
@ -92,7 +97,11 @@ describe('create non existing note hint', () => {
it('shows success message when the note has been created', async () => {
mockCreateNoteWithPrimaryAlias()
const onNoteCreatedCallback = jest.fn()
const view = render(<CreateNonExistingNoteHint onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>)
const view = render(
<CreateNonExistingNoteHint
noteId={mockedNoteId}
onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>
)
const button = await screen.findByTestId('createNoteButton')
await act<void>(() => {
button.click()
@ -108,7 +117,11 @@ describe('create non existing note hint', () => {
it("shows an error message if note couldn't be created", async () => {
mockFailingCreateNoteWithPrimaryAlias()
const onNoteCreatedCallback = jest.fn()
const view = render(<CreateNonExistingNoteHint onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>)
const view = render(
<CreateNonExistingNoteHint
noteId={mockedNoteId}
onNoteCreated={onNoteCreatedCallback}></CreateNonExistingNoteHint>
)
const button = await screen.findByTestId('createNoteButton')
await act<void>(() => {
button.click()

View file

@ -4,7 +4,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { createNoteWithPrimaryAlias } from '../../../api/notes'
import { useSingleStringUrlParameter } from '../../../hooks/common/use-single-string-url-parameter'
import { testId } from '../../../utils/test-id'
import { UiIcon } from '../icons/ui-icon'
import { ShowIf } from '../show-if/show-if'
@ -20,6 +19,7 @@ import { useAsyncFn } from 'react-use'
export interface CreateNonExistingNoteHintProps {
onNoteCreated: () => void
noteId: string | undefined
}
/**
@ -27,17 +27,16 @@ export interface CreateNonExistingNoteHintProps {
* When the button was clicked it also shows the progress.
*
* @param onNoteCreated A function that will be called after the note was created.
* @param noteId The wanted id for the note to create
*/
export const CreateNonExistingNoteHint: React.FC<CreateNonExistingNoteHintProps> = ({ onNoteCreated }) => {
export const CreateNonExistingNoteHint: React.FC<CreateNonExistingNoteHintProps> = ({ onNoteCreated, noteId }) => {
useTranslation()
const noteIdFromUrl = useSingleStringUrlParameter('noteId', undefined)
const [returnState, createNote] = useAsyncFn(async () => {
if (noteIdFromUrl === undefined) {
throw new Error('Note id not set')
if (noteId !== undefined) {
return await createNoteWithPrimaryAlias('', noteId)
}
return await createNoteWithPrimaryAlias('', noteIdFromUrl)
}, [noteIdFromUrl])
}, [noteId])
const onClickHandler = useCallback(() => {
void createNote()
@ -49,7 +48,7 @@ export const CreateNonExistingNoteHint: React.FC<CreateNonExistingNoteHintProps>
}
}, [onNoteCreated, returnState.value])
if (noteIdFromUrl === undefined) {
if (noteId === undefined) {
return null
} else if (returnState.value) {
return (
@ -76,7 +75,7 @@ export const CreateNonExistingNoteHint: React.FC<CreateNonExistingNoteHintProps>
return (
<Alert variant={'info'} {...testId('createNoteMessage')} className={'mt-5'}>
<span>
<Trans i18nKey={'noteLoadingBoundary.createNote.question'} values={{ aliasName: noteIdFromUrl }} />
<Trans i18nKey={'noteLoadingBoundary.createNote.question'} values={{ aliasName: noteId }} />
</span>
<div className={'mt-3'}>
<Button

View file

@ -4,7 +4,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { getNote } from '../../../../api/notes'
import { useSingleStringUrlParameter } from '../../../../hooks/common/use-single-string-url-parameter'
import { setNoteDataFromServer } from '../../../../redux/note-details/methods'
import { useAsyncFn } from 'react-use'
import type { AsyncState } from 'react-use/lib/useAsyncFn'
@ -14,15 +13,13 @@ import type { AsyncState } from 'react-use/lib/useAsyncFn'
*
* @return An {@link AsyncState async state} that represents the current state of the loading process.
*/
export const useLoadNoteFromServer = (): [AsyncState<boolean>, () => void] => {
const id = useSingleStringUrlParameter('noteId', undefined)
export const useLoadNoteFromServer = (noteId: string | undefined): [AsyncState<boolean>, () => void] => {
return useAsyncFn(async (): Promise<boolean> => {
if (id === undefined) {
if (noteId === undefined) {
throw new Error('Invalid id')
}
const noteFromServer = await getNote(id)
const noteFromServer = await getNote(noteId)
setNoteDataFromServer(noteFromServer)
return true
}, [id])
}, [noteId])
}

View file

@ -7,7 +7,6 @@ import { ApiError } from '../../../api/common/api-error'
import * as getNoteModule from '../../../api/notes'
import type { Note } from '../../../api/notes/types'
import * as LoadingScreenModule from '../../../components/application-loader/loading-screen/loading-screen'
import * as useSingleStringUrlParameterModule from '../../../hooks/common/use-single-string-url-parameter'
import * as setNoteDataFromServerModule from '../../../redux/note-details/methods'
import { mockI18n } from '../../../test-utils/mock-i18n'
import { testId } from '../../../utils/test-id'
@ -64,17 +63,8 @@ describe('Note loading boundary', () => {
</Fragment>
)
})
mockGetNoteIdQueryParameter()
})
const mockGetNoteIdQueryParameter = () => {
const expectedQueryParameter = 'noteId'
jest.spyOn(useSingleStringUrlParameterModule, 'useSingleStringUrlParameter').mockImplementation((parameter) => {
expect(parameter).toBe(expectedQueryParameter)
return mockedNoteId
})
}
const mockGetNoteApiCall = (returnValue: Note) => {
jest.spyOn(getNoteModule, 'getNote').mockImplementation((id) => {
expect(id).toBe(mockedNoteId)
@ -105,7 +95,7 @@ describe('Note loading boundary', () => {
const setNoteInReduxFunctionMock = mockSetNoteInRedux(mockedNote)
const view = render(
<NoteLoadingBoundary>
<NoteLoadingBoundary noteId={mockedNoteId}>
<span data-testid={'success'}>success!</span>
</NoteLoadingBoundary>
)
@ -121,7 +111,7 @@ describe('Note loading boundary', () => {
const setNoteInReduxFunctionMock = mockSetNoteInRedux(mockedNote)
const view = render(
<NoteLoadingBoundary>
<NoteLoadingBoundary noteId={mockedNoteId}>
<span data-testid={'success'}>success!</span>
</NoteLoadingBoundary>
)

View file

@ -1,3 +1,4 @@
'use client'
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
@ -17,15 +18,20 @@ import React, { useEffect, useMemo } from 'react'
const logger = new Logger('NoteLoadingBoundary')
export interface NoteIdProps {
noteId: string | undefined
}
/**
* Loads the note identified by the note-id in the URL.
* During the loading a {@link LoadingScreen loading screen} will be rendered instead of the child elements.
* The boundary also shows errors that occur during the loading process.
*
* @param children The react elements that will be shown when the loading was successful.
* @param children The react elements that will be shown when the loading was successful
* @param noteId the id of the note to load
*/
export const NoteLoadingBoundary: React.FC<PropsWithChildren> = ({ children }) => {
const [{ error, loading, value }, loadNoteFromServer] = useLoadNoteFromServer()
export const NoteLoadingBoundary: React.FC<PropsWithChildren<NoteIdProps>> = ({ children, noteId }) => {
const [{ error, loading, value }, loadNoteFromServer] = useLoadNoteFromServer(noteId)
useEffect(() => {
loadNoteFromServer()
@ -46,11 +52,11 @@ export const NoteLoadingBoundary: React.FC<PropsWithChildren> = ({ children }) =
titleI18nKey={`${errorI18nKeyPrefix}.title`}
descriptionI18nKey={`${errorI18nKeyPrefix}.description`}>
<ShowIf condition={error instanceof ApiError && error.statusCode === 404}>
<CreateNonExistingNoteHint onNoteCreated={loadNoteFromServer} />
<CreateNonExistingNoteHint onNoteCreated={loadNoteFromServer} noteId={noteId} />
</ShowIf>
</CommonErrorPage>
)
}, [error, loadNoteFromServer])
}, [error, loadNoteFromServer, noteId])
return (
<CustomAsyncLoadingBoundary

View file

@ -3,7 +3,7 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useRouter } from 'next/router'
import { useRouter } from 'next/navigation'
import React, { useEffect } from 'react'
/**

View file

@ -3,10 +3,9 @@
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Logger } from '../../utils/logger'
import { testId } from '../../utils/test-id'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useRouter } from 'next/navigation'
import React, { useEffect } from 'react'
export interface RedirectProps {
@ -14,8 +13,6 @@ export interface RedirectProps {
replace?: boolean
}
const logger = new Logger('Redirect')
/**
* Redirects the user to another URL. Can be external or internal.
*
@ -26,9 +23,7 @@ export const Redirect: React.FC<RedirectProps> = ({ to, replace }) => {
const router = useRouter()
useEffect(() => {
;(replace ? router.replace(to) : router.push(to)).catch((error: Error) => {
logger.error(`Error while redirecting to ${to}`, error)
})
replace ? router.replace(to) : router.push(to)
}, [replace, router, to])
return (