mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-24 10:46:30 -05:00
Add interface for managing aliases (#1347)
* Add alias management Signed-off-by: Erik Michelson <github@erik.michelson.eu> * Use React components instead of css classes Signed-off-by: Erik Michelson <github@erik.michelson.eu> * Add tests Signed-off-by: Erik Michelson <github@erik.michelson.eu> * Use notifications hook instead of redux methods Signed-off-by: Erik Michelson <github@erik.michelson.eu> * Use test ids Signed-off-by: Erik Michelson <github@erik.michelson.eu> * Use test ids in other place as well Signed-off-by: Erik Michelson <github@erik.michelson.eu> Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
parent
7d2c71b392
commit
488876e949
23 changed files with 812 additions and 17 deletions
|
@ -455,6 +455,17 @@
|
|||
"viewOnlyDescription": "This link points to a read-only version of this note. You can use this e.g. for feedback from friends and colleagues.",
|
||||
"slidesDescription": "This link points to the presentation view of the slides."
|
||||
},
|
||||
"aliases": {
|
||||
"title": "Aliases",
|
||||
"explanation": "Aliases are alternative names for this note. You may access this note under all the listed names below.",
|
||||
"addAlias": "Add alias",
|
||||
"makePrimary": "Mark this alias as primary",
|
||||
"isPrimary": "This is the primary alias",
|
||||
"removeAlias": "Remove this alias",
|
||||
"errorAddingAlias": "The chosen alias can not be added to this note",
|
||||
"errorRemovingAlias": "There was an error removing the alias",
|
||||
"errorMakingPrimary": "There was an error marking the alias as primary"
|
||||
},
|
||||
"preferences": {
|
||||
"title": "Preferences",
|
||||
"theme": {
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { Note, NoteDeletionOptions } from './types'
|
||||
import type { Note, NoteDeletionOptions, NoteMetadata } from './types'
|
||||
import type { MediaUpload } from '../media/types'
|
||||
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
|
||||
import { PostApiRequestBuilder } from '../common/api-request-builder/post-api-request-builder'
|
||||
|
@ -23,6 +23,17 @@ export const getNote = async (noteIdOrAlias: string): Promise<Note> => {
|
|||
return response.asParsedJsonObject()
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the metadata of the specified note.
|
||||
*
|
||||
* @param noteIdOrAlias The id or alias of the note.
|
||||
* @return Metadata of the specified note.
|
||||
*/
|
||||
export const getNoteMetadata = async (noteIdOrAlias: string): Promise<NoteMetadata> => {
|
||||
const response = await new GetApiRequestBuilder<NoteMetadata>(`notes/${noteIdOrAlias}/metadata`).sendRequest()
|
||||
return response.asParsedJsonObject()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of media objects associated with the specified note.
|
||||
*
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AliasesAddForm renders the input form 1`] = `
|
||||
<div>
|
||||
<form>
|
||||
<div
|
||||
class="mr-1 mb-1 input-group has-validation"
|
||||
>
|
||||
<input
|
||||
class="form-control"
|
||||
data-testid="addAliasInput"
|
||||
placeholder="editor.modal.aliases.addAlias"
|
||||
required=""
|
||||
value=""
|
||||
/>
|
||||
<button
|
||||
class="text-secondary ml-2 btn btn-light"
|
||||
data-testid="addAliasButton"
|
||||
disabled=""
|
||||
title="editor.modal.aliases.addAlias"
|
||||
type="submit"
|
||||
>
|
||||
<i
|
||||
class="fa fa-plus "
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,66 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AliasesListEntry renders an AliasesListEntry that is not primary 1`] = `
|
||||
<div>
|
||||
<li
|
||||
class="list-group-item d-flex flex-row justify-content-between align-items-center"
|
||||
>
|
||||
test-non-primary
|
||||
<div>
|
||||
<button
|
||||
class="mr-2 btn btn-light"
|
||||
data-testid="aliasButtonMakePrimary"
|
||||
title="editor.modal.aliases.makePrimary"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="fa fa-star-o "
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="text-danger btn btn-light"
|
||||
data-testid="aliasButtonRemove"
|
||||
title="editor.modal.aliases.removeAlias"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="fa fa-times "
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`AliasesListEntry renders an AliasesListEntry that is primary 1`] = `
|
||||
<div>
|
||||
<li
|
||||
class="list-group-item d-flex flex-row justify-content-between align-items-center"
|
||||
>
|
||||
test-primary
|
||||
<div>
|
||||
<button
|
||||
class="mr-2 text-warning btn btn-light"
|
||||
data-testid="aliasIsPrimary"
|
||||
disabled=""
|
||||
title="editor.modal.aliases.isPrimary"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="fa fa-star "
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="text-danger btn btn-light"
|
||||
data-testid="aliasButtonRemove"
|
||||
title="editor.modal.aliases.removeAlias"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
class="fa fa-times "
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,27 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AliasesList renders the AliasList sorted 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
Alias:
|
||||
a-test
|
||||
(
|
||||
non-primary
|
||||
)
|
||||
</span>
|
||||
<span>
|
||||
Alias:
|
||||
b-test
|
||||
(
|
||||
primary
|
||||
)
|
||||
</span>
|
||||
<span>
|
||||
Alias:
|
||||
z-test
|
||||
(
|
||||
non-primary
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,32 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AliasesModal renders the modal 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
This is a mock implementation of a Modal:
|
||||
<dialog>
|
||||
<div
|
||||
class="modal-body"
|
||||
>
|
||||
<p>
|
||||
editor.modal.aliases.explanation
|
||||
</p>
|
||||
<div
|
||||
class="list-group"
|
||||
>
|
||||
<span>
|
||||
This is a mock for the AliasesList that is tested separately.
|
||||
</span>
|
||||
<div
|
||||
class="list-group-item"
|
||||
>
|
||||
<span>
|
||||
This is a mock for the AliasesAddForm that is tested separately.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { render, act, screen } from '@testing-library/react'
|
||||
import testEvent from '@testing-library/user-event'
|
||||
import React from 'react'
|
||||
import { mockI18n } from '../../../markdown-renderer/test-utils/mock-i18n'
|
||||
import * as AliasModule from '../../../../api/alias'
|
||||
import * as NoteDetailsReduxModule from '../../../../redux/note-details/methods'
|
||||
import * as useApplicationStateModule from '../../../../hooks/common/use-application-state'
|
||||
import { AliasesAddForm } from './aliases-add-form'
|
||||
import * as useUiNotificationsModule from '../../../notifications/ui-notification-boundary'
|
||||
|
||||
jest.mock('../../../../api/alias')
|
||||
jest.mock('../../../../redux/note-details/methods')
|
||||
jest.mock('../../../../hooks/common/use-application-state')
|
||||
jest.mock('../../../notifications/ui-notification-boundary')
|
||||
|
||||
const addPromise = Promise.resolve({ name: 'mock', primaryAlias: true, noteId: 'mock' })
|
||||
|
||||
describe('AliasesAddForm', () => {
|
||||
beforeEach(async () => {
|
||||
await mockI18n()
|
||||
jest.spyOn(AliasModule, 'addAlias').mockImplementation(() => addPromise)
|
||||
jest.spyOn(NoteDetailsReduxModule, 'updateMetadata').mockImplementation(() => Promise.resolve())
|
||||
jest.spyOn(useApplicationStateModule, 'useApplicationState').mockReturnValue('mock-note')
|
||||
jest.spyOn(useUiNotificationsModule, 'useUiNotifications').mockReturnValue({
|
||||
showErrorNotification: jest.fn(),
|
||||
dismissNotification: jest.fn(),
|
||||
dispatchUiNotification: jest.fn()
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks()
|
||||
jest.resetModules()
|
||||
})
|
||||
|
||||
it('renders the input form', async () => {
|
||||
const view = render(<AliasesAddForm />)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
const button = await screen.findByTestId('addAliasButton')
|
||||
expect(button).toBeDisabled()
|
||||
const input = await screen.findByTestId('addAliasInput')
|
||||
await testEvent.type(input, 'abc')
|
||||
expect(button).toBeEnabled()
|
||||
act(() => {
|
||||
button.click()
|
||||
})
|
||||
expect(AliasModule.addAlias).toBeCalledWith('mock-note', 'abc')
|
||||
await addPromise
|
||||
expect(NoteDetailsReduxModule.updateMetadata).toBeCalled()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import type { FormEvent } from 'react'
|
||||
import { Button, Form, InputGroup } from 'react-bootstrap'
|
||||
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useApplicationState } from '../../../../hooks/common/use-application-state'
|
||||
import { addAlias } from '../../../../api/alias'
|
||||
import { updateMetadata } from '../../../../redux/note-details/methods'
|
||||
import { useOnInputChange } from '../../../../hooks/common/use-on-input-change'
|
||||
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
|
||||
import { testId } from '../../../../utils/test-id'
|
||||
|
||||
const validAliasRegex = /^[a-z0-9_-]*$/
|
||||
|
||||
/**
|
||||
* Form for adding a new alias to a note.
|
||||
*/
|
||||
export const AliasesAddForm: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
const noteId = useApplicationState((state) => state.noteDetails.id)
|
||||
const [newAlias, setNewAlias] = useState('')
|
||||
|
||||
const onAddAlias = useCallback(
|
||||
(event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
addAlias(noteId, newAlias)
|
||||
.then(updateMetadata)
|
||||
.catch(showErrorNotification('editor.modal.aliases.errorAddingAlias'))
|
||||
.finally(() => {
|
||||
setNewAlias('')
|
||||
})
|
||||
},
|
||||
[noteId, newAlias, setNewAlias, showErrorNotification]
|
||||
)
|
||||
|
||||
const onNewAliasInputChange = useOnInputChange(setNewAlias)
|
||||
|
||||
const newAliasValid = useMemo(() => {
|
||||
return validAliasRegex.test(newAlias)
|
||||
}, [newAlias])
|
||||
|
||||
return (
|
||||
<form onSubmit={onAddAlias}>
|
||||
<InputGroup className={'mr-1 mb-1'} hasValidation={true}>
|
||||
<Form.Control
|
||||
value={newAlias}
|
||||
placeholder={t('editor.modal.aliases.addAlias')}
|
||||
onChange={onNewAliasInputChange}
|
||||
isInvalid={!newAliasValid}
|
||||
required={true}
|
||||
{...testId('addAliasInput')}
|
||||
/>
|
||||
<Button
|
||||
type={'submit'}
|
||||
variant='light'
|
||||
className={'text-secondary ml-2'}
|
||||
disabled={!newAliasValid || newAlias === ''}
|
||||
title={t('editor.modal.aliases.addAlias')}
|
||||
{...testId('addAliasButton')}>
|
||||
<ForkAwesomeIcon icon={'plus'} />
|
||||
</Button>
|
||||
</InputGroup>
|
||||
</form>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { render, act, screen } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import { mockI18n } from '../../../markdown-renderer/test-utils/mock-i18n'
|
||||
import type { Alias } from '../../../../api/alias/types'
|
||||
import { AliasesListEntry } from './aliases-list-entry'
|
||||
import * as AliasModule from '../../../../api/alias'
|
||||
import * as NoteDetailsReduxModule from '../../../../redux/note-details/methods'
|
||||
import * as useUiNotificationsModule from '../../../notifications/ui-notification-boundary'
|
||||
|
||||
jest.mock('../../../../api/alias')
|
||||
jest.mock('../../../../redux/note-details/methods')
|
||||
jest.mock('../../../notifications/ui-notification-boundary')
|
||||
|
||||
const deletePromise = Promise.resolve()
|
||||
const markAsPrimaryPromise = Promise.resolve({ name: 'mock', primaryAlias: true, noteId: 'mock' })
|
||||
|
||||
describe('AliasesListEntry', () => {
|
||||
beforeEach(async () => {
|
||||
await mockI18n()
|
||||
jest.spyOn(AliasModule, 'deleteAlias').mockImplementation(() => deletePromise)
|
||||
jest.spyOn(AliasModule, 'markAliasAsPrimary').mockImplementation(() => markAsPrimaryPromise)
|
||||
jest.spyOn(NoteDetailsReduxModule, 'updateMetadata').mockImplementation(() => Promise.resolve())
|
||||
jest.spyOn(useUiNotificationsModule, 'useUiNotifications').mockReturnValue({
|
||||
showErrorNotification: jest.fn(),
|
||||
dismissNotification: jest.fn(),
|
||||
dispatchUiNotification: jest.fn()
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks()
|
||||
jest.resetModules()
|
||||
})
|
||||
|
||||
it('renders an AliasesListEntry that is primary', async () => {
|
||||
const testAlias: Alias = {
|
||||
name: 'test-primary',
|
||||
primaryAlias: true,
|
||||
noteId: 'test-note-id'
|
||||
}
|
||||
const view = render(<AliasesListEntry alias={testAlias} />)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
const button = await screen.findByTestId('aliasButtonRemove')
|
||||
act(() => {
|
||||
button.click()
|
||||
})
|
||||
expect(AliasModule.deleteAlias).toBeCalledWith(testAlias.name)
|
||||
await deletePromise
|
||||
expect(NoteDetailsReduxModule.updateMetadata).toBeCalled()
|
||||
})
|
||||
|
||||
it('renders an AliasesListEntry that is not primary', async () => {
|
||||
const testAlias: Alias = {
|
||||
name: 'test-non-primary',
|
||||
primaryAlias: false,
|
||||
noteId: 'test-note-id'
|
||||
}
|
||||
const view = render(<AliasesListEntry alias={testAlias} />)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
const buttonRemove = await screen.findByTestId('aliasButtonRemove')
|
||||
act(() => {
|
||||
buttonRemove.click()
|
||||
})
|
||||
expect(AliasModule.deleteAlias).toBeCalledWith(testAlias.name)
|
||||
await deletePromise
|
||||
expect(NoteDetailsReduxModule.updateMetadata).toBeCalled()
|
||||
const buttonMakePrimary = await screen.findByTestId('aliasButtonMakePrimary')
|
||||
act(() => {
|
||||
buttonMakePrimary.click()
|
||||
})
|
||||
expect(AliasModule.markAliasAsPrimary).toBeCalledWith(testAlias.name)
|
||||
await markAsPrimaryPromise
|
||||
expect(NoteDetailsReduxModule.updateMetadata).toBeCalled()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import React, { useCallback } from 'react'
|
||||
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ShowIf } from '../../../common/show-if/show-if'
|
||||
import type { Alias } from '../../../../api/alias/types'
|
||||
import { deleteAlias, markAliasAsPrimary } from '../../../../api/alias'
|
||||
import { updateMetadata } from '../../../../redux/note-details/methods'
|
||||
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
|
||||
import { testId } from '../../../../utils/test-id'
|
||||
|
||||
export interface AliasesListEntryProps {
|
||||
alias: Alias
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that shows an entry in the aliases list with buttons to remove it or mark it as primary.
|
||||
*
|
||||
* @param alias The alias.
|
||||
*/
|
||||
export const AliasesListEntry: React.FC<AliasesListEntryProps> = ({ alias }) => {
|
||||
const { t } = useTranslation()
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
|
||||
const onRemoveClick = useCallback(() => {
|
||||
deleteAlias(alias.name)
|
||||
.then(updateMetadata)
|
||||
.catch(showErrorNotification(t('editor.modal.aliases.errorRemovingAlias')))
|
||||
}, [alias, t, showErrorNotification])
|
||||
|
||||
const onMakePrimaryClick = useCallback(() => {
|
||||
markAliasAsPrimary(alias.name)
|
||||
.then(updateMetadata)
|
||||
.catch(showErrorNotification(t('editor.modal.aliases.errorMakingPrimary')))
|
||||
}, [alias, t, showErrorNotification])
|
||||
|
||||
return (
|
||||
<li className={'list-group-item d-flex flex-row justify-content-between align-items-center'}>
|
||||
{alias.name}
|
||||
<div>
|
||||
<ShowIf condition={alias.primaryAlias}>
|
||||
<Button
|
||||
className={'mr-2 text-warning'}
|
||||
variant='light'
|
||||
disabled={true}
|
||||
title={t('editor.modal.aliases.isPrimary')}
|
||||
{...testId('aliasIsPrimary')}>
|
||||
<ForkAwesomeIcon icon={'star'} />
|
||||
</Button>
|
||||
</ShowIf>
|
||||
<ShowIf condition={!alias.primaryAlias}>
|
||||
<Button
|
||||
className={'mr-2'}
|
||||
variant='light'
|
||||
title={t('editor.modal.aliases.makePrimary')}
|
||||
onClick={onMakePrimaryClick}
|
||||
{...testId('aliasButtonMakePrimary')}>
|
||||
<ForkAwesomeIcon icon={'star-o'} />
|
||||
</Button>
|
||||
</ShowIf>
|
||||
<Button
|
||||
variant='light'
|
||||
className={'text-danger'}
|
||||
title={t('editor.modal.aliases.removeAlias')}
|
||||
onClick={onRemoveClick}
|
||||
{...testId('aliasButtonRemove')}>
|
||||
<ForkAwesomeIcon icon={'times'} />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { render } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import { mockI18n } from '../../../markdown-renderer/test-utils/mock-i18n'
|
||||
import type { Alias } from '../../../../api/alias/types'
|
||||
import * as useApplicationStateModule from '../../../../hooks/common/use-application-state'
|
||||
import * as AliasesListEntryModule from './aliases-list-entry'
|
||||
import type { AliasesListEntryProps } from './aliases-list-entry'
|
||||
import { AliasesList } from './aliases-list'
|
||||
|
||||
jest.mock('../../../../hooks/common/use-application-state')
|
||||
jest.mock('./aliases-list-entry')
|
||||
|
||||
describe('AliasesList', () => {
|
||||
beforeEach(async () => {
|
||||
await mockI18n()
|
||||
jest.spyOn(useApplicationStateModule, 'useApplicationState').mockReturnValue([
|
||||
{
|
||||
name: 'a-test',
|
||||
noteId: 'note-id',
|
||||
primaryAlias: false
|
||||
},
|
||||
{
|
||||
name: 'z-test',
|
||||
noteId: 'note-id',
|
||||
primaryAlias: false
|
||||
},
|
||||
{
|
||||
name: 'b-test',
|
||||
noteId: 'note-id',
|
||||
primaryAlias: true
|
||||
}
|
||||
] as Alias[])
|
||||
jest.spyOn(AliasesListEntryModule, 'AliasesListEntry').mockImplementation((({ alias }) => {
|
||||
return (
|
||||
<span>
|
||||
Alias: {alias.name} ({alias.primaryAlias ? 'primary' : 'non-primary'})
|
||||
</span>
|
||||
)
|
||||
}) as React.FC<AliasesListEntryProps>)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks()
|
||||
jest.resetModules()
|
||||
})
|
||||
|
||||
it('renders the AliasList sorted', () => {
|
||||
const view = render(<AliasesList />)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { Fragment, useMemo } from 'react'
|
||||
import { useApplicationState } from '../../../../hooks/common/use-application-state'
|
||||
import type { ApplicationState } from '../../../../redux/application-state'
|
||||
import { AliasesListEntry } from './aliases-list-entry'
|
||||
|
||||
/**
|
||||
* Renders the list of aliases.
|
||||
*/
|
||||
export const AliasesList: React.FC = () => {
|
||||
const aliases = useApplicationState((state: ApplicationState) => state.noteDetails.aliases)
|
||||
|
||||
const aliasesDom = useMemo(() => {
|
||||
return aliases
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((alias) => <AliasesListEntry alias={alias} key={alias.name} />)
|
||||
}, [aliases])
|
||||
|
||||
return <Fragment>{aliasesDom}</Fragment>
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { render } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import type { CommonModalProps } from '../../../common/modals/common-modal'
|
||||
import * as CommonModalModule from '../../../common/modals/common-modal'
|
||||
import * as AliasesListModule from './aliases-list'
|
||||
import * as AliasesAddFormModule from './aliases-add-form'
|
||||
import * as useUiNotificationsModule from '../../../notifications/ui-notification-boundary'
|
||||
import { AliasesModal } from './aliases-modal'
|
||||
import { mockI18n } from '../../../markdown-renderer/test-utils/mock-i18n'
|
||||
|
||||
jest.mock('./aliases-list')
|
||||
jest.mock('./aliases-add-form')
|
||||
jest.mock('../../../common/modals/common-modal')
|
||||
jest.mock('../../../notifications/ui-notification-boundary')
|
||||
|
||||
describe('AliasesModal', () => {
|
||||
beforeEach(async () => {
|
||||
await mockI18n()
|
||||
jest.spyOn(CommonModalModule, 'CommonModal').mockImplementation((({ children }) => {
|
||||
return (
|
||||
<span>
|
||||
This is a mock implementation of a Modal: <dialog>{children}</dialog>
|
||||
</span>
|
||||
)
|
||||
}) as React.FC<PropsWithChildren<CommonModalProps>>)
|
||||
jest.spyOn(AliasesListModule, 'AliasesList').mockImplementation((() => {
|
||||
return <span>This is a mock for the AliasesList that is tested separately.</span>
|
||||
}) as React.FC)
|
||||
jest.spyOn(AliasesAddFormModule, 'AliasesAddForm').mockImplementation((() => {
|
||||
return <span>This is a mock for the AliasesAddForm that is tested separately.</span>
|
||||
}) as React.FC)
|
||||
jest.spyOn(useUiNotificationsModule, 'useUiNotifications').mockReturnValue({
|
||||
showErrorNotification: jest.fn(),
|
||||
dismissNotification: jest.fn(),
|
||||
dispatchUiNotification: jest.fn()
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks()
|
||||
jest.resetModules()
|
||||
})
|
||||
|
||||
it('renders the modal', () => {
|
||||
const view = render(<AliasesModal show={true} />)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import React from 'react'
|
||||
import { ListGroup, ListGroupItem, Modal } from 'react-bootstrap'
|
||||
import type { CommonModalProps } from '../../../common/modals/common-modal'
|
||||
import { CommonModal } from '../../../common/modals/common-modal'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { AliasesList } from './aliases-list'
|
||||
import { AliasesAddForm } from './aliases-add-form'
|
||||
|
||||
/**
|
||||
* Component that holds a modal containing a list of aliases associated with the current note.
|
||||
*
|
||||
* @param show True when the modal should be visible, false otherwise.
|
||||
* @param onHide Callback that is executed when the modal is dismissed.
|
||||
*/
|
||||
export const AliasesModal: React.FC<CommonModalProps> = ({ show, onHide }) => {
|
||||
useTranslation()
|
||||
|
||||
return (
|
||||
<CommonModal show={show} onHide={onHide} title={'editor.modal.aliases.title'} showCloseButton={true}>
|
||||
<Modal.Body>
|
||||
<p>
|
||||
<Trans i18nKey={'editor.modal.aliases.explanation'} />
|
||||
</p>
|
||||
<ListGroup>
|
||||
<AliasesList />
|
||||
<ListGroupItem>
|
||||
<AliasesAddForm />
|
||||
</ListGroupItem>
|
||||
</ListGroup>
|
||||
</Modal.Body>
|
||||
</CommonModal>
|
||||
)
|
||||
}
|
|
@ -17,6 +17,7 @@ import { ShareSidebarEntry } from './specific-sidebar-entries/share-sidebar-entr
|
|||
import styles from './style/sidebar.module.scss'
|
||||
import { DocumentSidebarMenuSelection } from './types'
|
||||
import { UsersOnlineSidebarMenu } from './users-online-sidebar-menu/users-online-sidebar-menu'
|
||||
import { AliasesSidebarEntry } from './specific-sidebar-entries/aliases-sidebar-entry'
|
||||
|
||||
/**
|
||||
* Renders the sidebar for the editor.
|
||||
|
@ -50,6 +51,7 @@ export const Sidebar: React.FC = () => {
|
|||
<NoteInfoSidebarEntry hide={selectionIsNotNone} />
|
||||
<RevisionSidebarEntry hide={selectionIsNotNone} />
|
||||
<PermissionsSidebarEntry hide={selectionIsNotNone} />
|
||||
<AliasesSidebarEntry hide={selectionIsNotNone} />
|
||||
<ImportMenuSidebarMenu
|
||||
menuId={DocumentSidebarMenuSelection.IMPORT}
|
||||
selectedMenuId={selectedMenu}
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { Fragment } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import type { SpecificSidebarEntryProps } from '../types'
|
||||
import { SidebarButton } from '../sidebar-button/sidebar-button'
|
||||
import { AliasesModal } from '../../document-bar/aliases/aliases-modal'
|
||||
import { useBooleanState } from '../../../../hooks/common/use-boolean-state'
|
||||
|
||||
/**
|
||||
* Component that shows a button in the editor sidebar for opening the aliases modal.
|
||||
*
|
||||
* @param className Additional CSS classes that should be added to the sidebar button.
|
||||
* @param hide True when the sidebar button should be hidden, False otherwise.
|
||||
*/
|
||||
export const AliasesSidebarEntry: React.FC<SpecificSidebarEntryProps> = ({ className, hide }) => {
|
||||
useTranslation()
|
||||
const [showModal, setShowModal, setHideModal] = useBooleanState(false)
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<SidebarButton hide={hide} className={className} icon={'tags'} onClick={setShowModal}>
|
||||
<Trans i18nKey={'editor.modal.aliases.title'} />
|
||||
</SidebarButton>
|
||||
<AliasesModal show={showModal} onHide={setHideModal} />
|
||||
</Fragment>
|
||||
)
|
||||
}
|
25
src/pages/api/mock-backend/private/alias.ts
Normal file
25
src/pages/api/mock-backend/private/alias.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { HttpMethod, respondToMatchingRequest } from '../../../../handler-utils/respond-to-matching-request'
|
||||
import type { Alias, NewAliasDto } from '../../../../api/alias/types'
|
||||
|
||||
const handler = (req: NextApiRequest, res: NextApiResponse) => {
|
||||
respondToMatchingRequest<Alias>(
|
||||
HttpMethod.POST,
|
||||
req,
|
||||
res,
|
||||
{
|
||||
name: (req.body as NewAliasDto).newAlias,
|
||||
noteId: (req.body as NewAliasDto).noteIdOrAlias,
|
||||
primaryAlias: false
|
||||
},
|
||||
201
|
||||
)
|
||||
}
|
||||
|
||||
export default handler
|
|
@ -11,10 +11,12 @@ import type {
|
|||
SetNoteDocumentContentAction,
|
||||
SetNotePermissionsFromServerAction,
|
||||
UpdateCursorPositionAction,
|
||||
UpdateMetadataAction,
|
||||
UpdateNoteTitleByFirstHeadingAction
|
||||
} from './types'
|
||||
import { NoteDetailsActionType } from './types'
|
||||
import type { CursorSelection } from '../../components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection'
|
||||
import { getNoteMetadata } from '../../api/notes'
|
||||
|
||||
/**
|
||||
* Sets the content of the current note, extracts and parses the frontmatter and extracts the markdown content part.
|
||||
|
@ -66,3 +68,14 @@ export const updateCursorPositions = (selection: CursorSelection): void => {
|
|||
selection
|
||||
} as UpdateCursorPositionAction)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the current note's metadata from the server.
|
||||
*/
|
||||
export const updateMetadata = async (): Promise<void> => {
|
||||
const updatedMetadata = await getNoteMetadata(store.getState().noteDetails.id)
|
||||
store.dispatch({
|
||||
type: NoteDetailsActionType.UPDATE_METADATA,
|
||||
updatedMetadata
|
||||
} as UpdateMetadataAction)
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ import { buildStateFromUpdateCursorPosition } from './reducers/build-state-from-
|
|||
import { buildStateFromFirstHeadingUpdate } from './reducers/build-state-from-first-heading-update'
|
||||
import { buildStateFromServerDto } from './reducers/build-state-from-set-note-data-from-server'
|
||||
import { buildStateFromServerPermissions } from './reducers/build-state-from-server-permissions'
|
||||
import { buildStateFromMetadataUpdate } from './reducers/build-state-from-metadata-update'
|
||||
|
||||
export const NoteDetailsReducer: Reducer<NoteDetails, NoteDetailsActions> = (
|
||||
state: NoteDetails = initialState,
|
||||
|
@ -30,6 +31,8 @@ export const NoteDetailsReducer: Reducer<NoteDetails, NoteDetailsActions> = (
|
|||
return buildStateFromFirstHeadingUpdate(state, action.firstHeading)
|
||||
case NoteDetailsActionType.SET_NOTE_DATA_FROM_SERVER:
|
||||
return buildStateFromServerDto(action.noteFromServer)
|
||||
case NoteDetailsActionType.UPDATE_METADATA:
|
||||
return buildStateFromMetadataUpdate(state, action.updatedMetadata)
|
||||
default:
|
||||
return state
|
||||
}
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { initialState } from '../initial-state'
|
||||
import type { NoteMetadata } from '../../../api/notes/types'
|
||||
import type { NoteDetails } from '../types/note-details'
|
||||
import { buildStateFromMetadataUpdate } from './build-state-from-metadata-update'
|
||||
|
||||
describe('build state from server permissions', () => {
|
||||
it('creates a new state with the given permissions', () => {
|
||||
const state: NoteDetails = { ...initialState }
|
||||
const metadata: NoteMetadata = {
|
||||
updateUsername: 'test',
|
||||
permissions: {
|
||||
owner: null,
|
||||
sharedToGroups: [],
|
||||
sharedToUsers: []
|
||||
},
|
||||
editedBy: [],
|
||||
primaryAddress: 'test-id',
|
||||
tags: ['test'],
|
||||
description: 'test',
|
||||
id: 'test-id',
|
||||
aliases: [],
|
||||
title: 'test',
|
||||
version: 2,
|
||||
viewCount: 42,
|
||||
createdAt: '2022-09-18T18:51:00.000+02:00',
|
||||
updatedAt: '2022-09-18T18:52:00.000+02:00'
|
||||
}
|
||||
expect(buildStateFromMetadataUpdate(state, metadata)).toStrictEqual({
|
||||
...state,
|
||||
updateUsername: 'test',
|
||||
permissions: {
|
||||
owner: null,
|
||||
sharedToGroups: [],
|
||||
sharedToUsers: []
|
||||
},
|
||||
editedBy: [],
|
||||
primaryAddress: 'test-id',
|
||||
id: 'test-id',
|
||||
aliases: [],
|
||||
title: 'test',
|
||||
version: 2,
|
||||
viewCount: 42,
|
||||
createdAt: 1663519860,
|
||||
updatedAt: 1663519920
|
||||
})
|
||||
})
|
||||
})
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import type { NoteMetadata } from '../../../api/notes/types'
|
||||
import type { NoteDetails } from '../types/note-details'
|
||||
import { DateTime } from 'luxon'
|
||||
|
||||
/**
|
||||
* Builds a {@link NoteDetails} redux state from a note metadata DTO received from the HTTP API.
|
||||
* @param state The previous state to update.
|
||||
* @param noteMetadata The updated metadata from the API.
|
||||
* @return An updated {@link NoteDetails} redux state.
|
||||
*/
|
||||
export const buildStateFromMetadataUpdate = (state: NoteDetails, noteMetadata: NoteMetadata): NoteDetails => {
|
||||
return {
|
||||
...state,
|
||||
updateUsername: noteMetadata.updateUsername,
|
||||
permissions: noteMetadata.permissions,
|
||||
editedBy: noteMetadata.editedBy,
|
||||
primaryAddress: noteMetadata.primaryAddress,
|
||||
id: noteMetadata.id,
|
||||
aliases: noteMetadata.aliases,
|
||||
title: noteMetadata.title,
|
||||
version: noteMetadata.version,
|
||||
viewCount: noteMetadata.viewCount,
|
||||
createdAt: DateTime.fromISO(noteMetadata.createdAt).toSeconds(),
|
||||
updatedAt: DateTime.fromISO(noteMetadata.updatedAt).toSeconds()
|
||||
}
|
||||
}
|
|
@ -7,9 +7,9 @@
|
|||
import type { NoteDetails } from '../types/note-details'
|
||||
import { buildStateFromUpdatedMarkdownContent } from '../build-state-from-updated-markdown-content'
|
||||
import { initialState } from '../initial-state'
|
||||
import { DateTime } from 'luxon'
|
||||
import { calculateLineStartIndexes } from '../calculate-line-start-indexes'
|
||||
import type { Note } from '../../../api/notes/types'
|
||||
import { buildStateFromMetadataUpdate } from './build-state-from-metadata-update'
|
||||
|
||||
/**
|
||||
* Builds a {@link NoteDetails} redux state from a DTO received as an API response.
|
||||
|
@ -28,25 +28,15 @@ export const buildStateFromServerDto = (dto: Note): NoteDetails => {
|
|||
* @return The NoteDetails object corresponding to the DTO.
|
||||
*/
|
||||
const convertNoteDtoToNoteDetails = (note: Note): NoteDetails => {
|
||||
const stateWithMetadata = buildStateFromMetadataUpdate(initialState, note.metadata)
|
||||
const newLines = note.content.split('\n')
|
||||
return {
|
||||
...initialState,
|
||||
updateUsername: note.metadata.updateUsername,
|
||||
permissions: note.metadata.permissions,
|
||||
editedBy: note.metadata.editedBy,
|
||||
primaryAddress: note.metadata.primaryAddress,
|
||||
id: note.metadata.id,
|
||||
aliases: note.metadata.aliases,
|
||||
title: note.metadata.title,
|
||||
version: note.metadata.version,
|
||||
viewCount: note.metadata.viewCount,
|
||||
...stateWithMetadata,
|
||||
markdownContent: {
|
||||
plain: note.content,
|
||||
lines: newLines,
|
||||
lineStartIndexes: calculateLineStartIndexes(newLines)
|
||||
},
|
||||
rawFrontmatter: '',
|
||||
createdAt: DateTime.fromISO(note.metadata.createdAt).toSeconds(),
|
||||
updatedAt: DateTime.fromISO(note.metadata.updatedAt).toSeconds()
|
||||
rawFrontmatter: ''
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
*/
|
||||
|
||||
import type { Action } from 'redux'
|
||||
import type { Note, NotePermissions } from '../../api/notes/types'
|
||||
import type { Note, NoteMetadata, NotePermissions } from '../../api/notes/types'
|
||||
import type { CursorSelection } from '../../components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection'
|
||||
|
||||
export enum NoteDetailsActionType {
|
||||
|
@ -13,7 +13,8 @@ export enum NoteDetailsActionType {
|
|||
SET_NOTE_DATA_FROM_SERVER = 'note-details/data/server/set',
|
||||
SET_NOTE_PERMISSIONS_FROM_SERVER = 'note-details/data/permissions/set',
|
||||
UPDATE_NOTE_TITLE_BY_FIRST_HEADING = 'note-details/update-note-title-by-first-heading',
|
||||
UPDATE_CURSOR_POSITION = 'note-details/updateCursorPosition'
|
||||
UPDATE_CURSOR_POSITION = 'note-details/updateCursorPosition',
|
||||
UPDATE_METADATA = 'note-details/update-metadata'
|
||||
}
|
||||
|
||||
export type NoteDetailsActions =
|
||||
|
@ -22,6 +23,7 @@ export type NoteDetailsActions =
|
|||
| SetNotePermissionsFromServerAction
|
||||
| UpdateNoteTitleByFirstHeadingAction
|
||||
| UpdateCursorPositionAction
|
||||
| UpdateMetadataAction
|
||||
|
||||
/**
|
||||
* Action for updating the document content of the currently loaded note.
|
||||
|
@ -59,3 +61,11 @@ export interface UpdateCursorPositionAction extends Action<NoteDetailsActionType
|
|||
type: NoteDetailsActionType.UPDATE_CURSOR_POSITION
|
||||
selection: CursorSelection
|
||||
}
|
||||
|
||||
/**
|
||||
* Action for updating the metadata of the current note.
|
||||
*/
|
||||
export interface UpdateMetadataAction extends Action<NoteDetailsActionType> {
|
||||
type: NoteDetailsActionType.UPDATE_METADATA
|
||||
updatedMetadata: NoteMetadata
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue