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:
Erik Michelson 2022-09-21 19:44:26 +02:00 committed by GitHub
parent 7d2c71b392
commit 488876e949
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 812 additions and 17 deletions

View file

@ -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.", "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." "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": { "preferences": {
"title": "Preferences", "title": "Preferences",
"theme": { "theme": {

View file

@ -3,7 +3,7 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * 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 type { MediaUpload } from '../media/types'
import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder' import { GetApiRequestBuilder } from '../common/api-request-builder/get-api-request-builder'
import { PostApiRequestBuilder } from '../common/api-request-builder/post-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() 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. * Returns a list of media objects associated with the specified note.
* *

View file

@ -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>
`;

View file

@ -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>
`;

View file

@ -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>
`;

View file

@ -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>
`;

View file

@ -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()
})
})

View file

@ -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>
)
}

View file

@ -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()
})
})

View file

@ -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>
)
}

View file

@ -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()
})
})

View file

@ -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>
}

View file

@ -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()
})
})

View file

@ -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>
)
}

View file

@ -17,6 +17,7 @@ import { ShareSidebarEntry } from './specific-sidebar-entries/share-sidebar-entr
import styles from './style/sidebar.module.scss' import styles from './style/sidebar.module.scss'
import { DocumentSidebarMenuSelection } from './types' import { DocumentSidebarMenuSelection } from './types'
import { UsersOnlineSidebarMenu } from './users-online-sidebar-menu/users-online-sidebar-menu' 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. * Renders the sidebar for the editor.
@ -50,6 +51,7 @@ export const Sidebar: React.FC = () => {
<NoteInfoSidebarEntry hide={selectionIsNotNone} /> <NoteInfoSidebarEntry hide={selectionIsNotNone} />
<RevisionSidebarEntry hide={selectionIsNotNone} /> <RevisionSidebarEntry hide={selectionIsNotNone} />
<PermissionsSidebarEntry hide={selectionIsNotNone} /> <PermissionsSidebarEntry hide={selectionIsNotNone} />
<AliasesSidebarEntry hide={selectionIsNotNone} />
<ImportMenuSidebarMenu <ImportMenuSidebarMenu
menuId={DocumentSidebarMenuSelection.IMPORT} menuId={DocumentSidebarMenuSelection.IMPORT}
selectedMenuId={selectedMenu} selectedMenuId={selectedMenu}

View file

@ -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>
)
}

View 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

View file

@ -11,10 +11,12 @@ import type {
SetNoteDocumentContentAction, SetNoteDocumentContentAction,
SetNotePermissionsFromServerAction, SetNotePermissionsFromServerAction,
UpdateCursorPositionAction, UpdateCursorPositionAction,
UpdateMetadataAction,
UpdateNoteTitleByFirstHeadingAction UpdateNoteTitleByFirstHeadingAction
} from './types' } from './types'
import { NoteDetailsActionType } from './types' import { NoteDetailsActionType } from './types'
import type { CursorSelection } from '../../components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection' 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. * 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 selection
} as UpdateCursorPositionAction) } 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)
}

View file

@ -14,6 +14,7 @@ import { buildStateFromUpdateCursorPosition } from './reducers/build-state-from-
import { buildStateFromFirstHeadingUpdate } from './reducers/build-state-from-first-heading-update' import { buildStateFromFirstHeadingUpdate } from './reducers/build-state-from-first-heading-update'
import { buildStateFromServerDto } from './reducers/build-state-from-set-note-data-from-server' import { buildStateFromServerDto } from './reducers/build-state-from-set-note-data-from-server'
import { buildStateFromServerPermissions } from './reducers/build-state-from-server-permissions' import { buildStateFromServerPermissions } from './reducers/build-state-from-server-permissions'
import { buildStateFromMetadataUpdate } from './reducers/build-state-from-metadata-update'
export const NoteDetailsReducer: Reducer<NoteDetails, NoteDetailsActions> = ( export const NoteDetailsReducer: Reducer<NoteDetails, NoteDetailsActions> = (
state: NoteDetails = initialState, state: NoteDetails = initialState,
@ -30,6 +31,8 @@ export const NoteDetailsReducer: Reducer<NoteDetails, NoteDetailsActions> = (
return buildStateFromFirstHeadingUpdate(state, action.firstHeading) return buildStateFromFirstHeadingUpdate(state, action.firstHeading)
case NoteDetailsActionType.SET_NOTE_DATA_FROM_SERVER: case NoteDetailsActionType.SET_NOTE_DATA_FROM_SERVER:
return buildStateFromServerDto(action.noteFromServer) return buildStateFromServerDto(action.noteFromServer)
case NoteDetailsActionType.UPDATE_METADATA:
return buildStateFromMetadataUpdate(state, action.updatedMetadata)
default: default:
return state return state
} }

View file

@ -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
})
})
})

View file

@ -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()
}
}

View file

@ -7,9 +7,9 @@
import type { NoteDetails } from '../types/note-details' import type { NoteDetails } from '../types/note-details'
import { buildStateFromUpdatedMarkdownContent } from '../build-state-from-updated-markdown-content' import { buildStateFromUpdatedMarkdownContent } from '../build-state-from-updated-markdown-content'
import { initialState } from '../initial-state' import { initialState } from '../initial-state'
import { DateTime } from 'luxon'
import { calculateLineStartIndexes } from '../calculate-line-start-indexes' import { calculateLineStartIndexes } from '../calculate-line-start-indexes'
import type { Note } from '../../../api/notes/types' 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. * 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. * @return The NoteDetails object corresponding to the DTO.
*/ */
const convertNoteDtoToNoteDetails = (note: Note): NoteDetails => { const convertNoteDtoToNoteDetails = (note: Note): NoteDetails => {
const stateWithMetadata = buildStateFromMetadataUpdate(initialState, note.metadata)
const newLines = note.content.split('\n') const newLines = note.content.split('\n')
return { return {
...initialState, ...stateWithMetadata,
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,
markdownContent: { markdownContent: {
plain: note.content, plain: note.content,
lines: newLines, lines: newLines,
lineStartIndexes: calculateLineStartIndexes(newLines) lineStartIndexes: calculateLineStartIndexes(newLines)
}, },
rawFrontmatter: '', rawFrontmatter: ''
createdAt: DateTime.fromISO(note.metadata.createdAt).toSeconds(),
updatedAt: DateTime.fromISO(note.metadata.updatedAt).toSeconds()
} }
} }

View file

@ -5,7 +5,7 @@
*/ */
import type { Action } from 'redux' 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' import type { CursorSelection } from '../../components/editor-page/editor-pane/tool-bar/formatters/types/cursor-selection'
export enum NoteDetailsActionType { export enum NoteDetailsActionType {
@ -13,7 +13,8 @@ export enum NoteDetailsActionType {
SET_NOTE_DATA_FROM_SERVER = 'note-details/data/server/set', SET_NOTE_DATA_FROM_SERVER = 'note-details/data/server/set',
SET_NOTE_PERMISSIONS_FROM_SERVER = 'note-details/data/permissions/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_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 = export type NoteDetailsActions =
@ -22,6 +23,7 @@ export type NoteDetailsActions =
| SetNotePermissionsFromServerAction | SetNotePermissionsFromServerAction
| UpdateNoteTitleByFirstHeadingAction | UpdateNoteTitleByFirstHeadingAction
| UpdateCursorPositionAction | UpdateCursorPositionAction
| UpdateMetadataAction
/** /**
* Action for updating the document content of the currently loaded note. * 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 type: NoteDetailsActionType.UPDATE_CURSOR_POSITION
selection: CursorSelection selection: CursorSelection
} }
/**
* Action for updating the metadata of the current note.
*/
export interface UpdateMetadataAction extends Action<NoteDetailsActionType> {
type: NoteDetailsActionType.UPDATE_METADATA
updatedMetadata: NoteMetadata
}