Add button to create note if not found (#2173)

* feat: add CreateNonExistentNote component

This component asks the user if they want to create the note that was request, but is not there.

Signed-off-by: Philip Molares <philip.molares@udo.edu>
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-07-03 20:34:14 +02:00 committed by GitHub
parent cfab287200
commit 85a1c694b3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 297 additions and 33 deletions

View file

@ -41,12 +41,23 @@
} }
}, },
"noteLoadingBoundary": { "noteLoadingBoundary": {
"errorWhileLoadingContent": "Error while loading note." "createNote": {
"question": "Do you want to create a note with alias '{{aliasName}}'?",
"create": "Create note",
"creating": "Creating note...",
"error": "Note couldn't be created."
}
}, },
"api": { "api": {
"note": { "note": {
"notFound": "Note not found.", "notFound": {
"accessDenied": "You don't have the needed permission to access this note." "title": "Note not found",
"description": "The requested note doesn't exist."
},
"forbidden": {
"title": "Access denied",
"description": "You don't have the needed permission to access this note"
}
} }
}, },
"landing": { "landing": {
@ -495,12 +506,7 @@
"presentation": {}, "presentation": {},
"readOnly": { "readOnly": {
"viewCount": "views", "viewCount": "views",
"editNote": "Edit this note", "editNote": "Edit this note"
"loading": "Loading note contents ...",
"error": {
"title": "Error while loading note",
"description": "Probably the requested note does not exist or was deleted."
}
} }
}, },
"common": { "common": {
@ -564,7 +570,7 @@
}, },
"errors": { "errors": {
"notFound": { "notFound": {
"title": "404 - page not found", "title": "Page not found",
"description": "The requested page either does not exist or you don't have permission to access it." "description": "The requested page either does not exist or you don't have permission to access it."
}, },
"noteCreationFailed": { "noteCreationFailed": {

View file

@ -16,7 +16,7 @@ import { DeleteApiRequestBuilder } from '../common/api-request-builder/delete-ap
*/ */
export const getNote = async (noteIdOrAlias: string): Promise<Note> => { export const getNote = async (noteIdOrAlias: string): Promise<Note> => {
const response = await new GetApiRequestBuilder<Note>('notes/' + noteIdOrAlias) const response = await new GetApiRequestBuilder<Note>('notes/' + noteIdOrAlias)
.withStatusCodeErrorMapping({ 404: 'api.note.notFound', 403: 'api.note.accessDenied' }) .withStatusCodeErrorMapping({ 404: 'api.note.notFound', 403: 'api.note.forbidden' })
.sendRequest() .sendRequest()
return response.asParsedJsonObject() return response.asParsedJsonObject()
} }

View file

@ -0,0 +1,72 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`create non existing note hint redirects when the note has been created 1`] = `
<div>
<span
data-testid="redirect"
>
Redirecting to
<a
href="/n/mockedPrimaryAlias"
>
/n/mockedPrimaryAlias
</a>
</span>
</div>
`;
exports[`create non existing note hint renders a waiting message when button is clicked 1`] = `
<div>
<div
class="fade mt-5 alert alert-info show"
data-testid="loadingMessage"
role="alert"
>
<i
class="fa fa-spinner fa-spin mr-2 "
/>
noteLoadingBoundary.createNote.creating
</div>
</div>
`;
exports[`create non existing note hint renders an button as initial state 1`] = `
<div>
<div
class="fade mt-5 alert alert-info show"
data-testid="failedMessage"
role="alert"
>
<span>
noteLoadingBoundary.createNote.question
</span>
<div
class="mt-3"
>
<button
class="mx-2 btn btn-primary"
data-testid="createNoteButton"
type="submit"
>
noteLoadingBoundary.createNote.create
</button>
</div>
</div>
</div>
`;
exports[`create non existing note hint shows an error message if note couldn't be created 1`] = `
<div>
<div
class="fade mt-5 alert alert-info show"
data-testid="loadingMessage"
role="alert"
>
<i
class="fa fa-spinner fa-spin mr-2 "
/>
noteLoadingBoundary.createNote.creating
</div>
</div>
`;

View file

@ -19,14 +19,17 @@ exports[`Note loading boundary shows an error 1`] = `
</span> </span>
<span> <span>
titleI18nKey: titleI18nKey:
noteLoadingBoundary.errorWhileLoadingContent api.note.notFound.title
</span> </span>
<span> <span>
descriptionI18nKey: descriptionI18nKey:
CRAAAAASH api.note.notFound.description
</span> </span>
<span> <span>
children: children:
<span>
This is a mock for CreateNonExistingNoteHint
</span>
</span> </span>
</div> </div>
`; `;

View file

@ -0,0 +1,89 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as useSingleStringUrlParameterModule from '../../../hooks/common/use-single-string-url-parameter'
import { mockI18n } from '../../markdown-renderer/test-utils/mock-i18n'
import { render, screen } from '@testing-library/react'
import { CreateNonExistingNoteHint } from './create-non-existing-note-hint'
import * as createNoteWithPrimaryAliasModule from '../../../api/notes'
import type { Note, NoteMetadata } from '../../../api/notes/types'
import { Mock } from 'ts-mockery'
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')
.mockImplementation((markdown, primaryAlias): Promise<Note> => {
expect(markdown).toBe('')
expect(primaryAlias).toBe(mockedNoteId)
const metadata: NoteMetadata = Mock.of<NoteMetadata>({ primaryAddress: 'mockedPrimaryAlias' })
return Promise.resolve(Mock.of<Note>({ metadata }))
})
}
const mockFailingCreateNoteWithPrimaryAlias = () => {
jest
.spyOn(createNoteWithPrimaryAliasModule, 'createNoteWithPrimaryAlias')
.mockImplementation((markdown, primaryAlias): Promise<Note> => {
expect(markdown).toBe('')
expect(primaryAlias).toBe(mockedNoteId)
return Promise.reject("couldn't create note")
})
}
afterEach(() => {
jest.resetAllMocks()
jest.resetModules()
})
beforeEach(async () => {
await mockI18n()
mockGetNoteIdQueryParameter()
})
it('renders an button as initial state', () => {
mockCreateNoteWithPrimaryAlias()
const view = render(<CreateNonExistingNoteHint></CreateNonExistingNoteHint>)
expect(view.container).toMatchSnapshot()
})
it('renders a waiting message when button is clicked', async () => {
mockCreateNoteWithPrimaryAlias()
const view = render(<CreateNonExistingNoteHint></CreateNonExistingNoteHint>)
const button = await screen.findByTestId('createNoteButton')
button.click()
await screen.findByTestId('loadingMessage')
expect(view.container).toMatchSnapshot()
})
it('redirects when the note has been created', async () => {
mockCreateNoteWithPrimaryAlias()
const view = render(<CreateNonExistingNoteHint></CreateNonExistingNoteHint>)
const button = await screen.findByTestId('createNoteButton')
button.click()
await screen.findByTestId('redirect')
expect(view.container).toMatchSnapshot()
})
it("shows an error message if note couldn't be created", async () => {
mockFailingCreateNoteWithPrimaryAlias()
const view = render(<CreateNonExistingNoteHint></CreateNonExistingNoteHint>)
const button = await screen.findByTestId('createNoteButton')
button.click()
await screen.findByTestId('failedMessage')
expect(view.container).toMatchSnapshot()
})
})

View file

@ -0,0 +1,78 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Trans, useTranslation } from 'react-i18next'
import React, { useCallback } from 'react'
import { Alert, Button } from 'react-bootstrap'
import { useSingleStringUrlParameter } from '../../../hooks/common/use-single-string-url-parameter'
import { createNoteWithPrimaryAlias } from '../../../api/notes'
import { useAsyncFn } from 'react-use'
import { ShowIf } from '../show-if/show-if'
import { Redirect } from '../redirect'
import { ForkAwesomeIcon } from '../fork-awesome/fork-awesome-icon'
import { testId } from '../../../utils/test-id'
/**
* Shows a button that creates an empty note with the alias from the current window URL.
* When the button was clicked it also shows the progress.
*/
export const CreateNonExistingNoteHint: React.FC = () => {
useTranslation()
const noteIdFromUrl = useSingleStringUrlParameter('noteId', undefined)
const [returnState, createNote] = useAsyncFn(async () => {
if (noteIdFromUrl === undefined) {
throw new Error('Note id not set')
}
return await createNoteWithPrimaryAlias('', noteIdFromUrl)
}, [noteIdFromUrl])
const onClickHandler = useCallback(() => {
void createNote()
}, [createNote])
if (noteIdFromUrl === undefined) {
return null
} else if (returnState.value) {
return <Redirect to={`/n/${returnState.value.metadata.primaryAddress}`} />
} else if (returnState.loading) {
return (
<Alert variant={'info'} {...testId('loadingMessage')} className={'mt-5'}>
<ForkAwesomeIcon icon={'spinner'} className={'fa-spin mr-2'} />
<Trans i18nKey={'noteLoadingBoundary.createNote.creating'} />
</Alert>
)
} else if (returnState.error !== undefined) {
return (
<Alert variant={'danger'} {...testId('failedMessage')} className={'mt-5'}>
<ForkAwesomeIcon icon={'exclamation-triangle'} className={'mr-1'} />
<Trans i18nKey={'noteLoadingBoundary.createNote.error'} />
</Alert>
)
} else {
return (
<Alert variant={'info'} {...testId('failedMessage')} className={'mt-5'}>
<span>
<Trans i18nKey={'noteLoadingBoundary.createNote.question'} values={{ aliasName: noteIdFromUrl }} />
</span>
<div className={'mt-3'}>
<Button
autoFocus
type='submit'
variant='primary'
className='mx-2'
onClick={onClickHandler}
{...testId('createNoteButton')}>
<ShowIf condition={returnState.loading}>
<ForkAwesomeIcon icon={'spinner'} className={'fa-spin mr-2'} />
</ShowIf>
<Trans i18nKey={'noteLoadingBoundary.createNote.create'} />
</Button>
</div>
</Alert>
)
}
}

View file

@ -15,6 +15,7 @@ import { Fragment } from 'react'
import { mockI18n } from '../../markdown-renderer/test-utils/mock-i18n' import { mockI18n } from '../../markdown-renderer/test-utils/mock-i18n'
import * as CommonErrorPageModule from '../../error-pages/common-error-page' import * as CommonErrorPageModule from '../../error-pages/common-error-page'
import * as LoadingScreenModule from '../../../components/application-loader/loading-screen/loading-screen' import * as LoadingScreenModule from '../../../components/application-loader/loading-screen/loading-screen'
import * as CreateNonExistingNoteHintModule from './create-non-existing-note-hint'
describe('Note loading boundary', () => { describe('Note loading boundary', () => {
const mockedNoteId = 'mockedNoteId' const mockedNoteId = 'mockedNoteId'
@ -26,6 +27,13 @@ describe('Note loading boundary', () => {
beforeEach(async () => { beforeEach(async () => {
await mockI18n() await mockI18n()
jest.spyOn(CreateNonExistingNoteHintModule, 'CreateNonExistingNoteHint').mockImplementation(() => {
return (
<Fragment>
<span>This is a mock for CreateNonExistingNoteHint</span>
</Fragment>
)
})
jest.spyOn(LoadingScreenModule, 'LoadingScreen').mockImplementation(({ errorMessage }) => { jest.spyOn(LoadingScreenModule, 'LoadingScreen').mockImplementation(({ errorMessage }) => {
return ( return (
<Fragment> <Fragment>
@ -70,7 +78,7 @@ describe('Note loading boundary', () => {
jest.spyOn(getNoteModule, 'getNote').mockImplementation((id) => { jest.spyOn(getNoteModule, 'getNote').mockImplementation((id) => {
expect(id).toBe(mockedNoteId) expect(id).toBe(mockedNoteId)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
setTimeout(() => reject(new Error('CRAAAAASH')), 0) setTimeout(() => reject(new Error('api.note.notFound')), 0)
}) })
}) })
} }

View file

@ -9,6 +9,8 @@ import React, { Fragment } from 'react'
import { useLoadNoteFromServer } from './hooks/use-load-note-from-server' import { useLoadNoteFromServer } from './hooks/use-load-note-from-server'
import { LoadingScreen } from '../../application-loader/loading-screen/loading-screen' import { LoadingScreen } from '../../application-loader/loading-screen/loading-screen'
import { CommonErrorPage } from '../../error-pages/common-error-page' import { CommonErrorPage } from '../../error-pages/common-error-page'
import { CreateNonExistingNoteHint } from './create-non-existing-note-hint'
import { ShowIf } from '../show-if/show-if'
/** /**
* Loads the note identified by the note-id in the URL. * Loads the note identified by the note-id in the URL.
@ -24,10 +26,11 @@ export const NoteLoadingBoundary: React.FC<PropsWithChildren<unknown>> = ({ chil
return <LoadingScreen /> return <LoadingScreen />
} else if (error) { } else if (error) {
return ( return (
<CommonErrorPage <CommonErrorPage titleI18nKey={`${error.message}.title`} descriptionI18nKey={`${error.message}.description`}>
titleI18nKey={'noteLoadingBoundary.errorWhileLoadingContent'} <ShowIf condition={error.message === 'api.note.notFound'}>
descriptionI18nKey={error.message} <CreateNonExistingNoteHint />
/> </ShowIf>
</CommonErrorPage>
) )
} else { } else {
return <Fragment>{children}</Fragment> return <Fragment>{children}</Fragment>

View file

@ -1,5 +1,5 @@
/* /*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) * SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
@ -8,6 +8,7 @@ import Link from 'next/link'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { Logger } from '../../utils/logger' import { Logger } from '../../utils/logger'
import { testId } from '../../utils/test-id'
export interface RedirectProps { export interface RedirectProps {
to: string to: string
@ -24,13 +25,13 @@ export const Redirect: React.FC<RedirectProps> = ({ to }) => {
const router = useRouter() const router = useRouter()
useEffect(() => { useEffect(() => {
router.push(to).catch((error: Error) => { router?.push(to).catch((error: Error) => {
logger.error(`Error while redirecting to ${to}`, error) logger.error(`Error while redirecting to ${to}`, error)
}) })
}) })
return ( return (
<span> <span {...testId('redirect')}>
Redirecting to{' '} Redirecting to{' '}
<Link href={to}> <Link href={to}>
<a>{to}</a> <a>{to}</a>

View file

@ -4,10 +4,11 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import React from 'react'
import type { PropsWithChildren } from 'react' import type { PropsWithChildren } from 'react'
import React from 'react'
import { LandingLayout } from '../landing-layout/landing-layout' import { LandingLayout } from '../landing-layout/landing-layout'
import { useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { ShowIf } from '../common/show-if/show-if'
export interface CommonErrorPageProps { export interface CommonErrorPageProps {
titleI18nKey: string titleI18nKey: string
@ -26,17 +27,20 @@ export const CommonErrorPage: React.FC<PropsWithChildren<CommonErrorPageProps>>
descriptionI18nKey, descriptionI18nKey,
children children
}) => { }) => {
const { t } = useTranslation() useTranslation()
return ( return (
<LandingLayout> <LandingLayout>
<div className='text-light d-flex align-items-center justify-content-center my-5'> <div className='text-light d-flex flex-column align-items-center justify-content-center my-5'>
<div> <h1>
<h1>{t(titleI18nKey)}</h1> <Trans i18nKey={titleI18nKey} />
<br /> </h1>
{descriptionI18nKey ? t(descriptionI18nKey) : null} <ShowIf condition={!!descriptionI18nKey}>
{children} <h3>
</div> <Trans i18nKey={descriptionI18nKey} />
</h3>
</ShowIf>
{children}
</div> </div>
</LandingLayout> </LandingLayout>
) )

View file

@ -10,7 +10,7 @@ import type { NextPage } from 'next'
import { useAsync } from 'react-use' import { useAsync } from 'react-use'
import { Redirect } from '../components/common/redirect' import { Redirect } from '../components/common/redirect'
import { useSingleStringUrlParameter } from '../hooks/common/use-single-string-url-parameter' import { useSingleStringUrlParameter } from '../hooks/common/use-single-string-url-parameter'
import { CommonErrorPage } from '../components/error-pages/common-error-page' import Custom404 from './404'
/** /**
* Redirects the user to the editor if the link is a root level direct link to a version 1 note. * Redirects the user to the editor if the link is a root level direct link to a version 1 note.
@ -30,7 +30,7 @@ export const DirectLinkFallback: NextPage = () => {
}) })
if (error !== undefined) { if (error !== undefined) {
return <CommonErrorPage titleI18nKey={'errors.notFound.title'} descriptionI18nKey={'errors.notFound.description'} /> return <Custom404 />
} else if (value !== undefined) { } else if (value !== undefined) {
return <Redirect to={`/n/${value}`} /> return <Redirect to={`/n/${value}`} />
} else { } else {