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

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

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