From 488876e9491f6908abf53b3b61e73981b324b088 Mon Sep 17 00:00:00 2001 From: Erik Michelson Date: Wed, 21 Sep 2022 19:44:26 +0200 Subject: [PATCH] Add interface for managing aliases (#1347) * Add alias management Signed-off-by: Erik Michelson * Use React components instead of css classes Signed-off-by: Erik Michelson * Add tests Signed-off-by: Erik Michelson * Use notifications hook instead of redux methods Signed-off-by: Erik Michelson * Use test ids Signed-off-by: Erik Michelson * Use test ids in other place as well Signed-off-by: Erik Michelson Signed-off-by: Erik Michelson --- locales/en.json | 11 +++ src/api/notes/index.ts | 13 ++- .../aliases-add-form.test.tsx.snap | 30 +++++++ .../aliases-list-entry.test.tsx.snap | 66 +++++++++++++++ .../__snapshots__/aliases-list.test.tsx.snap | 27 +++++++ .../__snapshots__/aliases-modal.test.tsx.snap | 32 ++++++++ .../aliases/aliases-add-form.test.tsx | 57 +++++++++++++ .../document-bar/aliases/aliases-add-form.tsx | 71 ++++++++++++++++ .../aliases/aliases-list-entry.test.tsx | 81 +++++++++++++++++++ .../aliases/aliases-list-entry.tsx | 77 ++++++++++++++++++ .../aliases/aliases-list.test.tsx | 57 +++++++++++++ .../document-bar/aliases/aliases-list.tsx | 25 ++++++ .../aliases/aliases-modal.test.tsx | 54 +++++++++++++ .../document-bar/aliases/aliases-modal.tsx | 38 +++++++++ .../editor-page/sidebar/sidebar.tsx | 2 + .../aliases-sidebar-entry.tsx | 32 ++++++++ src/pages/api/mock-backend/private/alias.ts | 25 ++++++ src/redux/note-details/methods.ts | 13 +++ src/redux/note-details/reducer.ts | 3 + .../build-state-from-metadata-update.test.ts | 52 ++++++++++++ .../build-state-from-metadata-update.ts | 31 +++++++ ...ld-state-from-set-note-data-from-server.ts | 18 +---- src/redux/note-details/types.ts | 14 +++- 23 files changed, 812 insertions(+), 17 deletions(-) create mode 100644 src/components/editor-page/document-bar/aliases/__snapshots__/aliases-add-form.test.tsx.snap create mode 100644 src/components/editor-page/document-bar/aliases/__snapshots__/aliases-list-entry.test.tsx.snap create mode 100644 src/components/editor-page/document-bar/aliases/__snapshots__/aliases-list.test.tsx.snap create mode 100644 src/components/editor-page/document-bar/aliases/__snapshots__/aliases-modal.test.tsx.snap create mode 100644 src/components/editor-page/document-bar/aliases/aliases-add-form.test.tsx create mode 100644 src/components/editor-page/document-bar/aliases/aliases-add-form.tsx create mode 100644 src/components/editor-page/document-bar/aliases/aliases-list-entry.test.tsx create mode 100644 src/components/editor-page/document-bar/aliases/aliases-list-entry.tsx create mode 100644 src/components/editor-page/document-bar/aliases/aliases-list.test.tsx create mode 100644 src/components/editor-page/document-bar/aliases/aliases-list.tsx create mode 100644 src/components/editor-page/document-bar/aliases/aliases-modal.test.tsx create mode 100644 src/components/editor-page/document-bar/aliases/aliases-modal.tsx create mode 100644 src/components/editor-page/sidebar/specific-sidebar-entries/aliases-sidebar-entry.tsx create mode 100644 src/pages/api/mock-backend/private/alias.ts create mode 100644 src/redux/note-details/reducers/build-state-from-metadata-update.test.ts create mode 100644 src/redux/note-details/reducers/build-state-from-metadata-update.ts diff --git a/locales/en.json b/locales/en.json index 3f7e98da6..92aa27cd3 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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": { diff --git a/src/api/notes/index.ts b/src/api/notes/index.ts index e434a60dc..ab23b67f0 100644 --- a/src/api/notes/index.ts +++ b/src/api/notes/index.ts @@ -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 => { 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 => { + const response = await new GetApiRequestBuilder(`notes/${noteIdOrAlias}/metadata`).sendRequest() + return response.asParsedJsonObject() +} + /** * Returns a list of media objects associated with the specified note. * diff --git a/src/components/editor-page/document-bar/aliases/__snapshots__/aliases-add-form.test.tsx.snap b/src/components/editor-page/document-bar/aliases/__snapshots__/aliases-add-form.test.tsx.snap new file mode 100644 index 000000000..a4dbed899 --- /dev/null +++ b/src/components/editor-page/document-bar/aliases/__snapshots__/aliases-add-form.test.tsx.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AliasesAddForm renders the input form 1`] = ` +
+
+
+ + +
+ +
+`; diff --git a/src/components/editor-page/document-bar/aliases/__snapshots__/aliases-list-entry.test.tsx.snap b/src/components/editor-page/document-bar/aliases/__snapshots__/aliases-list-entry.test.tsx.snap new file mode 100644 index 000000000..2ecfe9068 --- /dev/null +++ b/src/components/editor-page/document-bar/aliases/__snapshots__/aliases-list-entry.test.tsx.snap @@ -0,0 +1,66 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AliasesListEntry renders an AliasesListEntry that is not primary 1`] = ` +
+
  • + test-non-primary +
    + + +
    +
  • +
    +`; + +exports[`AliasesListEntry renders an AliasesListEntry that is primary 1`] = ` +
    +
  • + test-primary +
    + + +
    +
  • +
    +`; diff --git a/src/components/editor-page/document-bar/aliases/__snapshots__/aliases-list.test.tsx.snap b/src/components/editor-page/document-bar/aliases/__snapshots__/aliases-list.test.tsx.snap new file mode 100644 index 000000000..94e161e2d --- /dev/null +++ b/src/components/editor-page/document-bar/aliases/__snapshots__/aliases-list.test.tsx.snap @@ -0,0 +1,27 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AliasesList renders the AliasList sorted 1`] = ` +
    + + Alias: + a-test + ( + non-primary + ) + + + Alias: + b-test + ( + primary + ) + + + Alias: + z-test + ( + non-primary + ) + +
    +`; diff --git a/src/components/editor-page/document-bar/aliases/__snapshots__/aliases-modal.test.tsx.snap b/src/components/editor-page/document-bar/aliases/__snapshots__/aliases-modal.test.tsx.snap new file mode 100644 index 000000000..d33776a8b --- /dev/null +++ b/src/components/editor-page/document-bar/aliases/__snapshots__/aliases-modal.test.tsx.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AliasesModal renders the modal 1`] = ` +
    + + This is a mock implementation of a Modal: + + + + +
    +`; diff --git a/src/components/editor-page/document-bar/aliases/aliases-add-form.test.tsx b/src/components/editor-page/document-bar/aliases/aliases-add-form.test.tsx new file mode 100644 index 000000000..78f855e5a --- /dev/null +++ b/src/components/editor-page/document-bar/aliases/aliases-add-form.test.tsx @@ -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() + 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() + }) +}) diff --git a/src/components/editor-page/document-bar/aliases/aliases-add-form.tsx b/src/components/editor-page/document-bar/aliases/aliases-add-form.tsx new file mode 100644 index 000000000..0c9dd4a01 --- /dev/null +++ b/src/components/editor-page/document-bar/aliases/aliases-add-form.tsx @@ -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) => { + 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 ( +
    + + + + +
    + ) +} diff --git a/src/components/editor-page/document-bar/aliases/aliases-list-entry.test.tsx b/src/components/editor-page/document-bar/aliases/aliases-list-entry.test.tsx new file mode 100644 index 000000000..262744e59 --- /dev/null +++ b/src/components/editor-page/document-bar/aliases/aliases-list-entry.test.tsx @@ -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() + 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() + 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() + }) +}) diff --git a/src/components/editor-page/document-bar/aliases/aliases-list-entry.tsx b/src/components/editor-page/document-bar/aliases/aliases-list-entry.tsx new file mode 100644 index 000000000..793dced8e --- /dev/null +++ b/src/components/editor-page/document-bar/aliases/aliases-list-entry.tsx @@ -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 = ({ 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 ( +
  • + {alias.name} +
    + + + + + + + +
    +
  • + ) +} diff --git a/src/components/editor-page/document-bar/aliases/aliases-list.test.tsx b/src/components/editor-page/document-bar/aliases/aliases-list.test.tsx new file mode 100644 index 000000000..ee6668432 --- /dev/null +++ b/src/components/editor-page/document-bar/aliases/aliases-list.test.tsx @@ -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 ( + + Alias: {alias.name} ({alias.primaryAlias ? 'primary' : 'non-primary'}) + + ) + }) as React.FC) + }) + + afterAll(() => { + jest.resetAllMocks() + jest.resetModules() + }) + + it('renders the AliasList sorted', () => { + const view = render() + expect(view.container).toMatchSnapshot() + }) +}) diff --git a/src/components/editor-page/document-bar/aliases/aliases-list.tsx b/src/components/editor-page/document-bar/aliases/aliases-list.tsx new file mode 100644 index 000000000..32e9fc007 --- /dev/null +++ b/src/components/editor-page/document-bar/aliases/aliases-list.tsx @@ -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) => ) + }, [aliases]) + + return {aliasesDom} +} diff --git a/src/components/editor-page/document-bar/aliases/aliases-modal.test.tsx b/src/components/editor-page/document-bar/aliases/aliases-modal.test.tsx new file mode 100644 index 000000000..a0b519043 --- /dev/null +++ b/src/components/editor-page/document-bar/aliases/aliases-modal.test.tsx @@ -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 ( + + This is a mock implementation of a Modal: {children} + + ) + }) as React.FC>) + jest.spyOn(AliasesListModule, 'AliasesList').mockImplementation((() => { + return This is a mock for the AliasesList that is tested separately. + }) as React.FC) + jest.spyOn(AliasesAddFormModule, 'AliasesAddForm').mockImplementation((() => { + return This is a mock for the AliasesAddForm that is tested separately. + }) 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() + expect(view.container).toMatchSnapshot() + }) +}) diff --git a/src/components/editor-page/document-bar/aliases/aliases-modal.tsx b/src/components/editor-page/document-bar/aliases/aliases-modal.tsx new file mode 100644 index 000000000..e2276f7fc --- /dev/null +++ b/src/components/editor-page/document-bar/aliases/aliases-modal.tsx @@ -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 = ({ show, onHide }) => { + useTranslation() + + return ( + + +

    + +

    + + + + + + +
    +
    + ) +} diff --git a/src/components/editor-page/sidebar/sidebar.tsx b/src/components/editor-page/sidebar/sidebar.tsx index 73c018d06..5cb65cbbc 100644 --- a/src/components/editor-page/sidebar/sidebar.tsx +++ b/src/components/editor-page/sidebar/sidebar.tsx @@ -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 = () => { + = ({ className, hide }) => { + useTranslation() + const [showModal, setShowModal, setHideModal] = useBooleanState(false) + + return ( + + + + + + + ) +} diff --git a/src/pages/api/mock-backend/private/alias.ts b/src/pages/api/mock-backend/private/alias.ts new file mode 100644 index 000000000..32e4e01d4 --- /dev/null +++ b/src/pages/api/mock-backend/private/alias.ts @@ -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( + HttpMethod.POST, + req, + res, + { + name: (req.body as NewAliasDto).newAlias, + noteId: (req.body as NewAliasDto).noteIdOrAlias, + primaryAlias: false + }, + 201 + ) +} + +export default handler diff --git a/src/redux/note-details/methods.ts b/src/redux/note-details/methods.ts index 00b01578d..7e6332f6a 100644 --- a/src/redux/note-details/methods.ts +++ b/src/redux/note-details/methods.ts @@ -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 => { + const updatedMetadata = await getNoteMetadata(store.getState().noteDetails.id) + store.dispatch({ + type: NoteDetailsActionType.UPDATE_METADATA, + updatedMetadata + } as UpdateMetadataAction) +} diff --git a/src/redux/note-details/reducer.ts b/src/redux/note-details/reducer.ts index 3c144d492..29d58c2ff 100644 --- a/src/redux/note-details/reducer.ts +++ b/src/redux/note-details/reducer.ts @@ -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 = ( state: NoteDetails = initialState, @@ -30,6 +31,8 @@ export const NoteDetailsReducer: Reducer = ( 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 } diff --git a/src/redux/note-details/reducers/build-state-from-metadata-update.test.ts b/src/redux/note-details/reducers/build-state-from-metadata-update.test.ts new file mode 100644 index 000000000..147d46b20 --- /dev/null +++ b/src/redux/note-details/reducers/build-state-from-metadata-update.test.ts @@ -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 + }) + }) +}) diff --git a/src/redux/note-details/reducers/build-state-from-metadata-update.ts b/src/redux/note-details/reducers/build-state-from-metadata-update.ts new file mode 100644 index 000000000..6378e258d --- /dev/null +++ b/src/redux/note-details/reducers/build-state-from-metadata-update.ts @@ -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() + } +} diff --git a/src/redux/note-details/reducers/build-state-from-set-note-data-from-server.ts b/src/redux/note-details/reducers/build-state-from-set-note-data-from-server.ts index 766d29ace..5450d41de 100644 --- a/src/redux/note-details/reducers/build-state-from-set-note-data-from-server.ts +++ b/src/redux/note-details/reducers/build-state-from-set-note-data-from-server.ts @@ -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: '' } } diff --git a/src/redux/note-details/types.ts b/src/redux/note-details/types.ts index 8d6666216..c29b72f80 100644 --- a/src/redux/note-details/types.ts +++ b/src/redux/note-details/types.ts @@ -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 { + type: NoteDetailsActionType.UPDATE_METADATA + updatedMetadata: NoteMetadata +}