fix: Move content into to frontend directory

Doing this BEFORE the merge prevents a lot of merge conflicts.

Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
Tilman Vatteroth 2022-11-11 11:16:18 +01:00
parent 4e18ce38f3
commit 762a0a850e
No known key found for this signature in database
GPG key ID: B97799103358209B
1051 changed files with 0 additions and 35 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="me-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 ms-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="me-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="me-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 type { FormEvent } from 'react'
import React, { useCallback, useMemo, useState } 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={'me-1 mb-1'} hasValidation={true}>
<Form.Control
value={newAlias}
placeholder={t('editor.modal.aliases.addAlias') ?? undefined}
onChange={onNewAliasInputChange}
isInvalid={!newAliasValid}
required={true}
{...testId('addAliasInput')}
/>
<Button
type={'submit'}
variant='light'
className={'text-secondary ms-2'}
disabled={!newAliasValid || newAlias === ''}
title={t('editor.modal.aliases.addAlias') ?? undefined}
{...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={'me-2 text-warning'}
variant='light'
disabled={true}
title={t('editor.modal.aliases.isPrimary') ?? undefined}
{...testId('aliasIsPrimary')}>
<ForkAwesomeIcon icon={'star'} />
</Button>
</ShowIf>
<ShowIf condition={!alias.primaryAlias}>
<Button
className={'me-2'}
variant='light'
title={t('editor.modal.aliases.makePrimary') ?? undefined}
onClick={onMakePrimaryClick}
{...testId('aliasButtonMakePrimary')}>
<ForkAwesomeIcon icon={'star-o'} />
</Button>
</ShowIf>
<Button
variant='light'
className={'text-danger'}
title={t('editor.modal.aliases.removeAlias') ?? undefined}
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

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { Trans } from 'react-i18next'
import { UnitalicBoldContent } from './unitalic-bold-content'
import { NoteInfoLine } from './note-info-line'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
/**
* Renders an info line about the number of contributors for the note.
*/
export const NoteInfoLineContributors: React.FC = () => {
const contributors = useApplicationState((state) => state.noteDetails.editedBy.length)
return (
<NoteInfoLine icon={'users'} size={'2x'}>
<Trans i18nKey={'editor.modal.documentInfo.usersContributed'}>
<UnitalicBoldContent text={contributors} />
</Trans>
</NoteInfoLine>
)
}

View file

@ -0,0 +1,30 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useMemo } from 'react'
import { Trans } from 'react-i18next'
import { NoteInfoLine } from './note-info-line'
import type { NoteInfoTimeLineProps } from './note-info-time-line'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { UnitalicBoldTimeFromNow } from './utils/unitalic-bold-time-from-now'
import { DateTime } from 'luxon'
/**
* Renders an info line about the creation of the current note.
*
* @param size The size in which the line should be displayed.
*/
export const NoteInfoLineCreated: React.FC<NoteInfoTimeLineProps> = ({ size }) => {
const noteCreateTime = useApplicationState((state) => state.noteDetails.createdAt)
const noteCreateDateTime = useMemo(() => DateTime.fromSeconds(noteCreateTime), [noteCreateTime])
return (
<NoteInfoLine icon={'plus'} size={size}>
<Trans i18nKey={'editor.modal.documentInfo.created'}>
<UnitalicBoldTimeFromNow time={noteCreateDateTime} />
</Trans>
</NoteInfoLine>
)
}

View file

@ -0,0 +1,48 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useMemo } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { NoteInfoLine } from './note-info-line'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import type { NoteInfoTimeLineProps } from './note-info-time-line'
import { UnitalicBoldTimeFromNow } from './utils/unitalic-bold-time-from-now'
import { UnitalicBoldTrans } from './utils/unitalic-bold-trans'
import { UserAvatarForUsername } from '../../../common/user-avatar/user-avatar-for-username'
import { DateTime } from 'luxon'
/**
* Renders an info line about the last update of the current note.
*
* @param size The size in which line and user avatar should be displayed.
*/
export const NoteInfoLineUpdated: React.FC<NoteInfoTimeLineProps> = ({ size }) => {
useTranslation()
const noteUpdateTime = useApplicationState((state) => state.noteDetails.updatedAt)
const noteUpdateDateTime = useMemo(() => DateTime.fromSeconds(noteUpdateTime), [noteUpdateTime])
const noteUpdateUser = useApplicationState((state) => state.noteDetails.updateUsername)
const userBlock = useMemo(() => {
if (!noteUpdateUser) {
return <UnitalicBoldTrans i18nKey={'common.guestUser'} />
}
return (
<UserAvatarForUsername
username={noteUpdateUser}
additionalClasses={'font-style-normal bold font-weight-bold'}
size={size ? 'lg' : undefined}
/>
)
}, [noteUpdateUser, size])
return (
<NoteInfoLine icon={'pencil'} size={size}>
<Trans i18nKey={'editor.modal.documentInfo.edited'}>
{userBlock}
<UnitalicBoldTimeFromNow time={noteUpdateDateTime} />
</Trans>
</NoteInfoLine>
)
}

View file

@ -0,0 +1,53 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { PropsWithChildren } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { ShowIf } from '../../../common/show-if/show-if'
import { NoteInfoLine } from './note-info-line'
import { UnitalicBoldContent } from './unitalic-bold-content'
import { useEditorToRendererCommunicator } from '../../render-context/editor-to-renderer-communicator-context-provider'
import type { OnWordCountCalculatedMessage } from '../../../render-page/window-post-message-communicator/rendering-message'
import { CommunicationMessageType } from '../../../render-page/window-post-message-communicator/rendering-message'
import { useEditorReceiveHandler } from '../../../render-page/window-post-message-communicator/hooks/use-editor-receive-handler'
import { cypressId } from '../../../../utils/cypress-attribute'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
/**
* Creates a new info line for the document information dialog that holds the
* word count of the note, based on counting in the rendered output.
*/
export const NoteInfoLineWordCount: React.FC<PropsWithChildren<unknown>> = () => {
useTranslation()
const editorToRendererCommunicator = useEditorToRendererCommunicator()
const [wordCount, setWordCount] = useState<number | null>(null)
useEditorReceiveHandler(
CommunicationMessageType.ON_WORD_COUNT_CALCULATED,
useCallback((values: OnWordCountCalculatedMessage) => setWordCount(values.words), [setWordCount])
)
const rendererReady = useApplicationState((state) => state.rendererStatus.rendererReady)
useEffect(() => {
if (rendererReady) {
editorToRendererCommunicator.sendMessageToOtherSide({ type: CommunicationMessageType.GET_WORD_COUNT })
}
}, [editorToRendererCommunicator, rendererReady])
return (
<NoteInfoLine icon={'align-left'} size={'2x'}>
<ShowIf condition={wordCount === null}>
<Trans i18nKey={'common.loading'} />
</ShowIf>
<ShowIf condition={wordCount !== null}>
<Trans i18nKey={'editor.modal.documentInfo.words'}>
<UnitalicBoldContent text={wordCount ?? ''} {...cypressId('document-info-word-count')} />
</Trans>
</ShowIf>
</NoteInfoLine>
)
}

View file

@ -0,0 +1,32 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { PropsWithChildren } from 'react'
import React from 'react'
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
import type { IconName } from '../../../common/fork-awesome/types'
export interface NoteInfoLineProps {
icon: IconName
size?: '2x' | '3x' | '4x' | '5x' | undefined
}
/**
* This is the base component for all note info lines.
* It renders an icon and some children in italic.
*
* @param icon The icon be shown
* @param size Which size the icon should be
* @param children The children to render
*/
export const NoteInfoLine: React.FC<PropsWithChildren<NoteInfoLineProps>> = ({ icon, size, children }) => {
return (
<span className={'d-flex align-items-center'}>
<ForkAwesomeIcon icon={icon} size={size} fixedWidth={true} className={'mx-2'} />
<i className={'d-flex align-items-center'}>{children}</i>
</span>
)
}

View file

@ -0,0 +1,52 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { ListGroup, Modal } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import type { ModalVisibilityProps } from '../../../common/modals/common-modal'
import { CommonModal } from '../../../common/modals/common-modal'
import { NoteInfoLineWordCount } from './note-info-line-word-count'
import { cypressId } from '../../../../utils/cypress-attribute'
import { NoteInfoLineCreated } from './note-info-line-created'
import { NoteInfoLineUpdated } from './note-info-line-updated'
import { NoteInfoLineContributors } from './note-info-line-contributors'
/**
* Modal that shows informational data about the current note.
*
* @param show true when the modal should be visible, false otherwise.
* @param onHide Callback that is fired when the modal is going to be closed.
*/
export const NoteInfoModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) => {
useTranslation()
return (
<CommonModal
show={show}
onHide={onHide}
showCloseButton={true}
title={'editor.modal.documentInfo.title'}
{...cypressId('document-info-modal')}>
<Modal.Body>
<ListGroup>
<ListGroup.Item>
<NoteInfoLineCreated size={'2x'} />
</ListGroup.Item>
<ListGroup.Item>
<NoteInfoLineUpdated size={'2x'} />
</ListGroup.Item>
<ListGroup.Item>
<NoteInfoLineContributors />
</ListGroup.Item>
<ListGroup.Item>
<NoteInfoLineWordCount />
</ListGroup.Item>
</ListGroup>
</Modal.Body>
</CommonModal>
)
}

View file

@ -0,0 +1,8 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export interface NoteInfoTimeLineProps {
size?: '2x' | '3x' | '4x' | '5x'
}

View file

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { DateTime } from 'luxon'
import React from 'react'
export interface TimeFromNowProps {
time: DateTime
}
/**
* Renders a given time relative to the current time.
*
* @param time The time to be rendered.
*/
export const TimeFromNow: React.FC<TimeFromNowProps> = ({ time }) => {
return (
<time className={'mx-1'} title={time.toFormat('DDDD T')} dateTime={time.toString()}>
{time.toRelative()}
</time>
)
}

View file

@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { PropsWithChildren } from 'react'
import React from 'react'
import type { PropsWithDataCypressId } from '../../../../utils/cypress-attribute'
import { cypressId } from '../../../../utils/cypress-attribute'
export interface UnitalicBoldContentProps extends PropsWithDataCypressId {
text?: string | number
}
/**
* Renders the children elements in a non-italic but bold style.
*
* @param text Optional text content that should be rendered.
* @param children Children that may be rendered.
* @param props Additional props for cypressId
*/
export const UnitalicBoldContent: React.FC<PropsWithChildren<UnitalicBoldContentProps>> = ({
text,
children,
...props
}) => {
return (
<strong className={'font-style-normal me-1'} {...cypressId(props)}>
{text}
{children}
</strong>
)
}

View file

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import type { TimeFromNowProps } from '../time-from-now'
import { TimeFromNow } from '../time-from-now'
import { UnitalicBoldContent } from '../unitalic-bold-content'
export const UnitalicBoldTimeFromNow: React.FC<TimeFromNowProps> = ({ time }) => {
return (
<UnitalicBoldContent>
<TimeFromNow time={time} />
</UnitalicBoldContent>
)
}

View file

@ -0,0 +1,21 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { Trans } from 'react-i18next'
import { UnitalicBoldContent } from '../unitalic-bold-content'
export interface UnitalicBoldTransProps {
i18nKey?: string
}
export const UnitalicBoldTrans: React.FC<UnitalicBoldTransProps> = ({ i18nKey }) => {
return (
<UnitalicBoldContent>
<Trans i18nKey={i18nKey} />
</UnitalicBoldContent>
)
}

View file

@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback, useState } from 'react'
import { Button, FormControl, InputGroup } from 'react-bootstrap'
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
import { useOnInputChange } from '../../../../hooks/common/use-on-input-change'
import { useTranslation } from 'react-i18next'
export interface PermissionAddEntryFieldProps {
onAddEntry: (identifier: string) => void
i18nKey: string
}
/**
* Permission entry row containing a field for adding new user permission entries.
*
* @param onAddEntry Callback that is fired with the entered username as identifier of the entry to add.
* @param i18nKey The localization key for the submit button.
*/
export const PermissionAddEntryField: React.FC<PermissionAddEntryFieldProps> = ({ onAddEntry, i18nKey }) => {
const { t } = useTranslation()
const [newEntryIdentifier, setNewEntryIdentifier] = useState('')
const onChange = useOnInputChange(setNewEntryIdentifier)
const onSubmit = useCallback(() => {
onAddEntry(newEntryIdentifier)
}, [newEntryIdentifier, onAddEntry])
return (
<li className={'list-group-item'}>
<InputGroup className={'me-1 mb-1'}>
<FormControl value={newEntryIdentifier} placeholder={t(i18nKey) ?? undefined} onChange={onChange} />
<Button variant='light' className={'text-secondary ms-2'} title={t(i18nKey) ?? undefined} onClick={onSubmit}>
<ForkAwesomeIcon icon={'plus'} />
</Button>
</InputGroup>
</li>
)
}

View file

@ -0,0 +1,94 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useMemo } from 'react'
import { Button, ToggleButtonGroup } from 'react-bootstrap'
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
import { AccessLevel } from './types'
import { useTranslation } from 'react-i18next'
interface PermissionEntryButtonI18nKeys {
remove: string
setReadOnly: string
setWriteable: string
}
export enum PermissionType {
USER,
GROUP
}
export interface PermissionEntryButtonsProps {
type: PermissionType
currentSetting: AccessLevel
name: string
onSetReadOnly: () => void
onSetWriteable: () => void
onRemove: () => void
}
/**
* Buttons next to a user or group permission entry to change the permissions or remove the entry.
*
* @param name The name of the user or group.
* @param type The type of the entry. Either {@link PermissionType.USER} or {@link PermissionType.GROUP}.
* @param currentSetting How the permission is currently set.
* @param onSetReadOnly Callback that is fired when the entry is changed to read-only permission.
* @param onSetWriteable Callback that is fired when the entry is changed to writeable permission.
* @param onRemove Callback that is fired when the entry is removed.
*/
export const PermissionEntryButtons: React.FC<PermissionEntryButtonsProps> = ({
name,
type,
currentSetting,
onSetReadOnly,
onSetWriteable,
onRemove
}) => {
const { t } = useTranslation()
const i18nKeys: PermissionEntryButtonI18nKeys = useMemo(() => {
switch (type) {
case PermissionType.USER:
return {
remove: 'editor.modal.permissions.removeUser',
setReadOnly: 'editor.modal.permissions.viewOnlyUser',
setWriteable: 'editor.modal.permissions.editUser'
}
case PermissionType.GROUP:
return {
remove: 'editor.modal.permissions.removeGroup',
setReadOnly: 'editor.modal.permissions.viewOnlyGroup',
setWriteable: 'editor.modal.permissions.editGroup'
}
}
}, [type])
return (
<div>
<Button
variant='light'
className={'text-danger me-2'}
title={t(i18nKeys.remove, { name }) ?? undefined}
onClick={onRemove}>
<ForkAwesomeIcon icon={'times'} />
</Button>
<ToggleButtonGroup type='radio' name='edit-mode' value={currentSetting}>
<Button
title={t(i18nKeys.setReadOnly, { name }) ?? undefined}
variant={currentSetting === AccessLevel.READ_ONLY ? 'secondary' : 'outline-secondary'}
onClick={onSetReadOnly}>
<ForkAwesomeIcon icon='eye' />
</Button>
<Button
title={t(i18nKeys.setWriteable, { name }) ?? undefined}
variant={currentSetting === AccessLevel.WRITEABLE ? 'secondary' : 'outline-secondary'}
onClick={onSetWriteable}>
<ForkAwesomeIcon icon='pencil' />
</Button>
</ToggleButtonGroup>
</div>
)
}

View file

@ -0,0 +1,92 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { AccessLevel, SpecialGroup } from './types'
import { Button, ToggleButtonGroup } from 'react-bootstrap'
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
import { removeGroupPermission, setGroupPermission } from '../../../../api/permissions'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { setNotePermissionsFromServer } from '../../../../redux/note-details/methods'
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
export interface PermissionEntrySpecialGroupProps {
level: AccessLevel
type: SpecialGroup
}
/**
* Permission entry that represents one of the built-in special groups.
*
* @param level The access level that is currently set for the group.
* @param type The type of the special group. Must be either {@link SpecialGroup.EVERYONE} or {@link SpecialGroup.LOGGED_IN}.
*/
export const PermissionEntrySpecialGroup: React.FC<PermissionEntrySpecialGroupProps> = ({ level, type }) => {
const noteId = useApplicationState((state) => state.noteDetails.primaryAddress)
const { t } = useTranslation()
const { showErrorNotification } = useUiNotifications()
const onSetEntryReadOnly = useCallback(() => {
setGroupPermission(noteId, type, false)
.then((updatedPermissions) => {
setNotePermissionsFromServer(updatedPermissions)
})
.catch(showErrorNotification('editor.modal.permissions.error'))
}, [noteId, showErrorNotification, type])
const onSetEntryWriteable = useCallback(() => {
setGroupPermission(noteId, type, true)
.then((updatedPermissions) => {
setNotePermissionsFromServer(updatedPermissions)
})
.catch(showErrorNotification('editor.modal.permissions.error'))
}, [noteId, showErrorNotification, type])
const onSetEntryDenied = useCallback(() => {
removeGroupPermission(noteId, type)
.then((updatedPermissions) => {
setNotePermissionsFromServer(updatedPermissions)
})
.catch(showErrorNotification('editor.modal.permissions.error'))
}, [noteId, showErrorNotification, type])
const name = useMemo(() => {
switch (type) {
case SpecialGroup.LOGGED_IN:
return t('editor.modal.permissions.allLoggedInUser')
case SpecialGroup.EVERYONE:
return t('editor.modal.permissions.allUser')
}
}, [type, t])
return (
<li className={'list-group-item d-flex flex-row justify-content-between align-items-center'}>
<span>{name}</span>
<div>
<ToggleButtonGroup type='radio' name='edit-mode'>
<Button
title={t('editor.modal.permissions.denyGroup', { name }) ?? undefined}
variant={level === AccessLevel.NONE ? 'secondary' : 'outline-secondary'}
onClick={onSetEntryDenied}>
<ForkAwesomeIcon icon={'ban'} />
</Button>
<Button
title={t('editor.modal.permissions.viewOnlyGroup', { name }) ?? undefined}
variant={level === AccessLevel.READ_ONLY ? 'secondary' : 'outline-secondary'}
onClick={onSetEntryReadOnly}>
<ForkAwesomeIcon icon={'eye'} />
</Button>
<Button
title={t('editor.modal.permissions.editGroup', { name }) ?? undefined}
variant={level === AccessLevel.WRITEABLE ? 'secondary' : 'outline-secondary'}
onClick={() => onSetEntryWriteable}>
<ForkAwesomeIcon icon={'pencil'} />
</Button>
</ToggleButtonGroup>
</div>
</li>
)
}

View file

@ -0,0 +1,79 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback } from 'react'
import { UserAvatar } from '../../../common/user-avatar/user-avatar'
import type { NoteUserPermissionEntry } from '../../../../api/notes/types'
import { PermissionEntryButtons, PermissionType } from './permission-entry-buttons'
import { AccessLevel } from './types'
import { useAsync } from 'react-use'
import { getUser } from '../../../../api/users'
import { ShowIf } from '../../../common/show-if/show-if'
import { removeUserPermission, setUserPermission } from '../../../../api/permissions'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { setNotePermissionsFromServer } from '../../../../redux/note-details/methods'
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
export interface PermissionEntryUserProps {
entry: NoteUserPermissionEntry
}
/**
* Permission entry for a user that can be set to read-only or writeable and can be removed.
*
* @param entry The permission entry.
*/
export const PermissionEntryUser: React.FC<PermissionEntryUserProps> = ({ entry }) => {
const noteId = useApplicationState((state) => state.noteDetails.primaryAddress)
const { showErrorNotification } = useUiNotifications()
const onRemoveEntry = useCallback(() => {
removeUserPermission(noteId, entry.username)
.then((updatedPermissions) => {
setNotePermissionsFromServer(updatedPermissions)
})
.catch(showErrorNotification('editor.modal.permissions.error'))
}, [noteId, entry.username, showErrorNotification])
const onSetEntryReadOnly = useCallback(() => {
setUserPermission(noteId, entry.username, false)
.then((updatedPermissions) => {
setNotePermissionsFromServer(updatedPermissions)
})
.catch(showErrorNotification('editor.modal.permissions.error'))
}, [noteId, entry.username, showErrorNotification])
const onSetEntryWriteable = useCallback(() => {
setUserPermission(noteId, entry.username, true)
.then((updatedPermissions) => {
setNotePermissionsFromServer(updatedPermissions)
})
.catch(showErrorNotification('editor.modal.permissions.error'))
}, [noteId, entry.username, showErrorNotification])
const { value, loading, error } = useAsync(async () => {
return await getUser(entry.username)
}, [entry.username])
if (!value) {
return null
}
return (
<ShowIf condition={!loading && !error}>
<li className={'list-group-item d-flex flex-row justify-content-between align-items-center'}>
<UserAvatar user={value} />
<PermissionEntryButtons
type={PermissionType.USER}
currentSetting={entry.canEdit ? AccessLevel.WRITEABLE : AccessLevel.READ_ONLY}
name={value.displayName}
onSetReadOnly={onSetEntryReadOnly}
onSetWriteable={onSetEntryWriteable}
onRemove={onRemoveEntry}
/>
</li>
</ShowIf>
)
}

View file

@ -0,0 +1,31 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { Modal } from 'react-bootstrap'
import type { ModalVisibilityProps } from '../../../common/modals/common-modal'
import { CommonModal } from '../../../common/modals/common-modal'
import { PermissionSectionOwner } from './permission-section-owner'
import { PermissionSectionUsers } from './permission-section-users'
import { PermissionSectionSpecialGroups } from './permission-section-special-groups'
/**
* Modal for viewing and managing the permissions of the note.
*
* @param show true to show the modal, false otherwise.
* @param onHide Callback that is fired when the modal is about to be closed.
*/
export const PermissionModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) => {
return (
<CommonModal show={show} onHide={onHide} showCloseButton={true} title={'editor.modal.permissions.title'}>
<Modal.Body>
<PermissionSectionOwner />
<PermissionSectionUsers />
<PermissionSectionSpecialGroups />
</Modal.Body>
</CommonModal>
)
}

View file

@ -0,0 +1,52 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useOnInputChange } from '../../../../hooks/common/use-on-input-change'
import { Button, FormControl, InputGroup } from 'react-bootstrap'
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
export interface PermissionOwnerChangeProps {
onConfirmOwnerChange: (newOwner: string) => void
}
/**
* Renders an input group to change the permission owner.
*
* @param onConfirmOwnerChange The callback to call if the owner was changed.
*/
export const PermissionOwnerChange: React.FC<PermissionOwnerChangeProps> = ({ onConfirmOwnerChange }) => {
const { t } = useTranslation()
const [ownerFieldValue, setOwnerFieldValue] = useState('')
const onChangeField = useOnInputChange(setOwnerFieldValue)
const onClickConfirm = useCallback(() => {
onConfirmOwnerChange(ownerFieldValue)
}, [ownerFieldValue, onConfirmOwnerChange])
const confirmButtonDisabled = useMemo(() => {
return ownerFieldValue.trim() === ''
}, [ownerFieldValue])
return (
<InputGroup className={'me-1 mb-1'}>
<FormControl
value={ownerFieldValue}
placeholder={t('editor.modal.permissions.ownerChange.placeholder') ?? undefined}
onChange={onChangeField}
/>
<Button
variant='light'
title={t('common.save') ?? undefined}
onClick={onClickConfirm}
className={'ms-2'}
disabled={confirmButtonDisabled}>
<ForkAwesomeIcon icon={'check'} />
</Button>
</InputGroup>
)
}

View file

@ -0,0 +1,38 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment } from 'react'
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
import { Button } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { UserAvatarForUsername } from '../../../common/user-avatar/user-avatar-for-username'
export interface PermissionOwnerInfoProps {
onEditOwner: () => void
}
/**
* Content for the owner section of the permission modal that shows the current note owner.
*
* @param onEditOwner Callback that is fired when the user chooses to change the note owner.
*/
export const PermissionOwnerInfo: React.FC<PermissionOwnerInfoProps> = ({ onEditOwner }) => {
const { t } = useTranslation()
const noteOwner = useApplicationState((state) => state.noteDetails.permissions.owner)
return (
<Fragment>
<UserAvatarForUsername username={noteOwner} />
<Button
variant='light'
title={t('editor.modal.permissions.ownerChange.button') ?? undefined}
onClick={onEditOwner}>
<ForkAwesomeIcon icon={'pencil'} />
</Button>
</Fragment>
)
}

View file

@ -0,0 +1,57 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment, useCallback, useState } from 'react'
import { Trans } from 'react-i18next'
import { PermissionOwnerChange } from './permission-owner-change'
import { PermissionOwnerInfo } from './permission-owner-info'
import { setNoteOwner } from '../../../../api/permissions'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { setNotePermissionsFromServer } from '../../../../redux/note-details/methods'
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
/**
* Section in the permissions modal for managing the owner of a note.
*/
export const PermissionSectionOwner: React.FC = () => {
const noteId = useApplicationState((state) => state.noteDetails.primaryAddress)
const [changeOwner, setChangeOwner] = useState(false)
const { showErrorNotification } = useUiNotifications()
const onSetChangeOwner = useCallback(() => {
setChangeOwner(true)
}, [])
const onOwnerChange = useCallback(
(newOwner: string) => {
setNoteOwner(noteId, newOwner)
.then((updatedPermissions) => {
setNotePermissionsFromServer(updatedPermissions)
})
.catch(showErrorNotification('editor.modal.permissions.ownerChange.error'))
.finally(() => {
setChangeOwner(false)
})
},
[noteId, showErrorNotification]
)
return (
<Fragment>
<h5 className={'mb-3'}>
<Trans i18nKey={'editor.modal.permissions.owner'} />
</h5>
<ul className={'list-group'}>
<li className={'list-group-item d-flex flex-row align-items-center justify-content-between'}>
{changeOwner ? (
<PermissionOwnerChange onConfirmOwnerChange={onOwnerChange} />
) : (
<PermissionOwnerInfo onEditOwner={onSetChangeOwner} />
)}
</li>
</ul>
</Fragment>
)
}

View file

@ -0,0 +1,48 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment, useMemo } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { PermissionEntrySpecialGroup } from './permission-entry-special-group'
import { AccessLevel, SpecialGroup } from './types'
/**
* Section of the permission modal for managing special group access to the note.
*/
export const PermissionSectionSpecialGroups: React.FC = () => {
useTranslation()
const groupPermissions = useApplicationState((state) => state.noteDetails.permissions.sharedToGroups)
const specialGroupEntries = useMemo(() => {
const groupEveryone = groupPermissions.find((entry) => entry.groupName === SpecialGroup.EVERYONE)
const groupLoggedIn = groupPermissions.find((entry) => entry.groupName === SpecialGroup.LOGGED_IN)
return {
everyone: groupEveryone
? groupEveryone.canEdit
? AccessLevel.WRITEABLE
: AccessLevel.READ_ONLY
: AccessLevel.NONE,
loggedIn: groupLoggedIn
? groupLoggedIn.canEdit
? AccessLevel.WRITEABLE
: AccessLevel.READ_ONLY
: AccessLevel.NONE
}
}, [groupPermissions])
return (
<Fragment>
<h5 className={'my-3'}>
<Trans i18nKey={'editor.modal.permissions.sharedWithElse'} />
</h5>
<ul className={'list-group'}>
<PermissionEntrySpecialGroup level={specialGroupEntries.loggedIn} type={SpecialGroup.LOGGED_IN} />
<PermissionEntrySpecialGroup level={specialGroupEntries.everyone} type={SpecialGroup.EVERYONE} />
</ul>
</Fragment>
)
}

View file

@ -0,0 +1,50 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { Fragment, useCallback, useMemo } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { PermissionEntryUser } from './permission-entry-user'
import { PermissionAddEntryField } from './permission-add-entry-field'
import { setUserPermission } from '../../../../api/permissions'
import { setNotePermissionsFromServer } from '../../../../redux/note-details/methods'
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
/**
* Section of the permission modal for managing user access to the note.
*/
export const PermissionSectionUsers: React.FC = () => {
useTranslation()
const userPermissions = useApplicationState((state) => state.noteDetails.permissions.sharedToUsers)
const noteId = useApplicationState((state) => state.noteDetails.primaryAddress)
const { showErrorNotification } = useUiNotifications()
const userEntries = useMemo(() => {
return userPermissions.map((entry) => <PermissionEntryUser key={entry.username} entry={entry} />)
}, [userPermissions])
const onAddEntry = useCallback(
(username: string) => {
setUserPermission(noteId, username, false)
.then((updatedPermissions) => {
setNotePermissionsFromServer(updatedPermissions)
})
.catch(showErrorNotification('editor.modal.permissions.error'))
},
[noteId, showErrorNotification]
)
return (
<Fragment>
<h5 className={'my-3'}>
<Trans i18nKey={'editor.modal.permissions.sharedWithUsers'} />
</h5>
<ul className={'list-group'}>
{userEntries}
<PermissionAddEntryField onAddEntry={onAddEntry} i18nKey={'editor.modal.permissions.addUser'} />
</ul>
</Fragment>
)
}

View file

@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
export enum AccessLevel {
NONE,
READ_ONLY,
WRITEABLE
}
export enum SpecialGroup {
EVERYONE = '_EVERYONE',
LOGGED_IN = '_LOGGED_IN'
}

View file

@ -0,0 +1,62 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { invertUnifiedPatch } from './invert-unified-patch'
import { parsePatch } from 'diff'
describe('invert unified patch', () => {
it('inverts a patch correctly', () => {
const parsedPatch = parsePatch(`--- a\t2022-07-03 21:21:07.499933337 +0200
+++ b\t2022-07-03 21:22:28.650972217 +0200
@@ -1,5 +1,4 @@
-a
-b
c
d
+d
e`)[0]
const result = invertUnifiedPatch(parsedPatch)
expect(result).toMatchInlineSnapshot(`
{
"hunks": [
{
"linedelimiters": [
"
",
"
",
"
",
"
",
"
",
"
",
],
"lines": [
"+a",
"+b",
" c",
" d",
"-d",
" e",
],
"newLines": 5,
"newStart": 1,
"oldLines": 4,
"oldStart": 1,
},
],
"index": undefined,
"newFileName": "a",
"newHeader": "2022-07-03 21:21:07.499933337 +0200",
"oldFileName": "b",
"oldHeader": "2022-07-03 21:22:28.650972217 +0200",
}
`)
})
})

View file

@ -0,0 +1,47 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { Hunk, ParsedDiff } from 'diff'
/**
* Inverts a given unified patch.
* A patch that e.g. adds a line, will remove it then.
*
* @param parsedDiff The patch to invert
* @return The inverted patch
*/
export const invertUnifiedPatch = (parsedDiff: ParsedDiff): ParsedDiff => {
const { oldFileName, newFileName, oldHeader, newHeader, hunks, index } = parsedDiff
const newHunks: Hunk[] = hunks.map((hunk) => {
const { oldLines, oldStart, newLines, newStart, lines, linedelimiters } = hunk
return {
oldLines: newLines,
oldStart: newStart,
newLines: oldLines,
newStart: oldStart,
linedelimiters: linedelimiters,
lines: lines.map((line) => {
if (line.startsWith('-')) {
return `+${line.slice(1)}`
} else if (line.startsWith('+')) {
return `-${line.slice(1)}`
} else {
return line
}
})
}
})
return {
hunks: newHunks,
index: index,
newFileName: oldFileName,
newHeader: oldHeader,
oldFileName: newFileName,
oldHeader: newHeader
}
}

View file

@ -0,0 +1,13 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.revision-item {
cursor: pointer;
span > img {
height: 1.25rem;
}
}

View file

@ -0,0 +1,76 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { DateTime } from 'luxon'
import React, { useMemo } from 'react'
import { ListGroup } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
import { UserAvatar } from '../../../common/user-avatar/user-avatar'
import styles from './revision-list-entry.module.scss'
import type { RevisionMetadata } from '../../../../api/revisions/types'
import { getUserDataForRevision } from './utils'
import { useAsync } from 'react-use'
import { ShowIf } from '../../../common/show-if/show-if'
import { WaitSpinner } from '../../../common/wait-spinner/wait-spinner'
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
export interface RevisionListEntryProps {
active: boolean
onSelect: () => void
revision: RevisionMetadata
}
/**
* Renders an entry in the revision list.
*
* @param active true if this is the currently selected revision entry.
* @param onSelect Callback that is fired when this revision entry is selected.
* @param revision The metadata for this revision entry.
*/
export const RevisionListEntry: React.FC<RevisionListEntryProps> = ({ active, onSelect, revision }) => {
useTranslation()
const { showErrorNotification } = useUiNotifications()
const revisionCreationTime = useMemo(() => {
return DateTime.fromISO(revision.createdAt).toFormat('DDDD T')
}, [revision.createdAt])
const revisionAuthors = useAsync(async () => {
try {
const authorDetails = await getUserDataForRevision(revision.authorUsernames)
return authorDetails.map((author) => (
<UserAvatar user={author} key={author.username} showName={false} additionalClasses={'mx-1'} />
))
} catch (error) {
showErrorNotification('editor.modal.revision.errorUser')(error as Error)
return null
}
}, [])
return (
<ListGroup.Item
active={active}
onClick={onSelect}
className={`user-select-none ${styles['revision-item']} d-flex flex-column`}>
<span>
<ForkAwesomeIcon icon={'clock-o'} className='mx-2' />
{revisionCreationTime}
</span>
<span>
<ForkAwesomeIcon icon={'file-text-o'} className='mx-2' />
<Trans i18nKey={'editor.modal.revision.length'} />: {revision.length}
</span>
<span className={'d-flex flex-row my-1 align-items-center'}>
<ForkAwesomeIcon icon={'user-o'} className={'mx-2'} />
<ShowIf condition={revisionAuthors.loading}>
<WaitSpinner />
</ShowIf>
<ShowIf condition={!revisionAuthors.error && !revisionAuthors.loading}>{revisionAuthors.value}</ShowIf>
</span>
</ListGroup.Item>
)
}

View file

@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useMemo } from 'react'
import { RevisionListEntry } from './revision-list-entry'
import { useAsync } from 'react-use'
import { getAllRevisions } from '../../../../api/revisions'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { ListGroup } from 'react-bootstrap'
import { AsyncLoadingBoundary } from '../../../common/async-loading-boundary'
import { DateTime } from 'luxon'
export interface RevisionListProps {
selectedRevisionId?: number
onRevisionSelect: (selectedRevisionId: number) => void
}
/**
* The list of selectable revisions of the current note.
*
* @param selectedRevisionId The currently selected revision
* @param onRevisionSelect Callback that is executed when a list entry is selected
*/
export const RevisionList: React.FC<RevisionListProps> = ({ selectedRevisionId, onRevisionSelect }) => {
const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress)
const {
value: revisions,
error,
loading
} = useAsync(() => {
return getAllRevisions(noteIdentifier)
}, [noteIdentifier])
const revisionList = useMemo(() => {
if (loading || !revisions) {
return null
}
return revisions
.sort((a, b) => {
const timestampA = DateTime.fromISO(a.createdAt).toSeconds()
const timestampB = DateTime.fromISO(b.createdAt).toSeconds()
return timestampB - timestampA
})
.map((revisionListEntry) => (
<RevisionListEntry
active={selectedRevisionId === revisionListEntry.id}
onSelect={() => onRevisionSelect(revisionListEntry.id)}
revision={revisionListEntry}
key={revisionListEntry.id}
/>
))
}, [loading, onRevisionSelect, revisions, selectedRevisionId])
return (
<AsyncLoadingBoundary loading={loading} error={error} componentName={'revision list'}>
<ListGroup>{revisionList}</ListGroup>
</AsyncLoadingBoundary>
)
}

View file

@ -0,0 +1,64 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useCallback } from 'react'
import { Button, Modal } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import { downloadRevision } from './utils'
import type { ModalVisibilityProps } from '../../../common/modals/common-modal'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { getRevision } from '../../../../api/revisions'
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
export interface RevisionModalFooterProps {
selectedRevisionId?: number
}
/**
* Renders the footer of the revision modal that includes buttons to download the currently selected revision or to
* revert the note content back to that revision.
*
* @param selectedRevisionId The currently selected revision id or undefined if no revision was selected.
* @param onHide Callback that is fired when the modal is about to be closed.
*/
export const RevisionModalFooter: React.FC<RevisionModalFooterProps & Pick<ModalVisibilityProps, 'onHide'>> = ({
selectedRevisionId,
onHide
}) => {
useTranslation()
const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress)
const { showErrorNotification } = useUiNotifications()
const onRevertToRevision = useCallback(() => {
// TODO Websocket message handler missing
// see https://github.com/hedgedoc/hedgedoc/issues/1984
window.alert('Not yet implemented. Requires websocket.')
}, [])
const onDownloadRevision = useCallback(() => {
if (selectedRevisionId === undefined) {
return
}
getRevision(noteIdentifier, selectedRevisionId)
.then((revision) => {
downloadRevision(noteIdentifier, revision)
})
.catch(showErrorNotification(''))
}, [noteIdentifier, selectedRevisionId, showErrorNotification])
return (
<Modal.Footer>
<Button variant='secondary' onClick={onHide}>
<Trans i18nKey={'common.close'} />
</Button>
<Button variant='danger' disabled={selectedRevisionId === undefined} onClick={onRevertToRevision}>
<Trans i18nKey={'editor.modal.revision.revertButton'} />
</Button>
<Button variant='primary' disabled={selectedRevisionId === undefined} onClick={onDownloadRevision}>
<Trans i18nKey={'editor.modal.revision.download'} />
</Button>
</Modal.Footer>
)
}

View file

@ -0,0 +1,10 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
.revision-modal .scroll-col {
max-height: 75vh;
overflow-y: auto;
}

View file

@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useState } from 'react'
import { Col, Modal, Row } from 'react-bootstrap'
import { useTranslation } from 'react-i18next'
import type { ModalVisibilityProps } from '../../../common/modals/common-modal'
import { CommonModal } from '../../../common/modals/common-modal'
import styles from './revision-modal.module.scss'
import { RevisionModalFooter } from './revision-modal-footer'
import { RevisionViewer } from './revision-viewer'
import { RevisionList } from './revision-list'
/**
* Modal that shows the available revisions and allows for comparison between them.
*
* @param show true to show the modal, false otherwise.
* @param onHide Callback that is fired when the modal is requested to close.
*/
export const RevisionModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) => {
useTranslation()
const [selectedRevisionId, setSelectedRevisionId] = useState<number>()
return (
<CommonModal
show={show}
onHide={onHide}
title={'editor.modal.revision.title'}
titleIcon={'history'}
showCloseButton={true}
modalSize={'xl'}
additionalClasses={styles['revision-modal']}>
<Modal.Body>
<Row>
<Col lg={4} className={styles['scroll-col']}>
<RevisionList onRevisionSelect={setSelectedRevisionId} selectedRevisionId={selectedRevisionId} />
</Col>
<Col lg={8} className={styles['scroll-col']}>
<RevisionViewer selectedRevisionId={selectedRevisionId} />
</Col>
</Row>
</Modal.Body>
<RevisionModalFooter selectedRevisionId={selectedRevisionId} onHide={onHide} />
</CommonModal>
)
}

View file

@ -0,0 +1,64 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React, { useMemo } from 'react'
import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer'
import { useAsync } from 'react-use'
import { getRevision } from '../../../../api/revisions'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { useDarkModeState } from '../../../../hooks/common/use-dark-mode-state'
import { AsyncLoadingBoundary } from '../../../common/async-loading-boundary'
import { applyPatch, parsePatch } from 'diff'
import { invertUnifiedPatch } from './invert-unified-patch'
import { Optional } from '@mrdrogdrog/optional'
export interface RevisionViewerProps {
selectedRevisionId?: number
}
/**
* Renders the diff viewer for a given revision and its previous one.
*
* @param selectedRevisionId The id of the currently selected revision.
* @param allRevisions List of metadata for all available revisions.
*/
export const RevisionViewer: React.FC<RevisionViewerProps> = ({ selectedRevisionId }) => {
const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress)
const darkModeEnabled = useDarkModeState()
const { value, error, loading } = useAsync(async () => {
if (selectedRevisionId === undefined) {
throw new Error('No revision selected')
} else {
return await getRevision(noteIdentifier, selectedRevisionId)
}
}, [selectedRevisionId, noteIdentifier])
const previousRevisionContent = useMemo(() => {
return Optional.ofNullable(value)
.flatMap((revision) =>
Optional.ofNullable(parsePatch(revision.patch)[0])
.map((patch) => invertUnifiedPatch(patch))
.map((patch) => applyPatch(revision.content, patch))
)
.orElse('')
}, [value])
if (selectedRevisionId === undefined) {
return null
}
return (
<AsyncLoadingBoundary loading={loading} componentName={'RevisionViewer'} error={error}>
<ReactDiffViewer
oldValue={previousRevisionContent ?? ''}
newValue={value?.content ?? ''}
splitView={false}
compareMethod={DiffMethod.WORDS}
useDarkTheme={darkModeEnabled}
/>
</AsyncLoadingBoundary>
)
}

View file

@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { RevisionDetails } from '../../../../api/revisions/types'
import { getUser } from '../../../../api/users'
import type { UserInfo } from '../../../../api/users/types'
import { download } from '../../../common/download/download'
const DISPLAY_MAX_USERS_PER_REVISION = 9
/**
* Downloads a given revision's content as markdown document in the browser.
*
* @param noteId The id of the note from which to download the revision.
* @param revision The revision details object containing the content to download.
*/
export const downloadRevision = (noteId: string, revision: RevisionDetails | null): void => {
if (!revision) {
return
}
download(revision.content, `${noteId}-${revision.createdAt}.md`, 'text/markdown')
}
/**
* Fetches user details for the given usernames while returning a maximum of 9 users.
*
* @param usernames The list of usernames to fetch.
* @throws {Error} in case the user-data request failed.
* @return An array of user details.
*/
export const getUserDataForRevision = async (usernames: string[]): Promise<UserInfo[]> => {
const users: UserInfo[] = []
const usersToFetch = Math.min(usernames.length, DISPLAY_MAX_USERS_PER_REVISION) - 1
for (let i = 0; i <= usersToFetch; i++) {
const user = await getUser(usernames[i])
users.push(user)
}
return users
}

View file

@ -0,0 +1,46 @@
/*
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import React from 'react'
import { Modal } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next'
import { CopyableField } from '../../../common/copyable/copyable-field/copyable-field'
import type { ModalVisibilityProps } from '../../../common/modals/common-modal'
import { CommonModal } from '../../../common/modals/common-modal'
import { ShowIf } from '../../../common/show-if/show-if'
import { useApplicationState } from '../../../../hooks/common/use-application-state'
import { NoteType } from '../../../../redux/note-details/types/note-details'
import { useBaseUrl } from '../../../../hooks/common/use-base-url'
/**
* Renders a modal which provides shareable URLs of this note.
*
* @param show If the modal should be shown
* @param onHide The callback when the modal should be closed
*/
export const ShareModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) => {
useTranslation()
const noteFrontmatter = useApplicationState((state) => state.noteDetails.frontmatter)
const baseUrl = useBaseUrl()
const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress)
return (
<CommonModal show={show} onHide={onHide} showCloseButton={true} title={'editor.modal.shareLink.title'}>
<Modal.Body>
<Trans i18nKey={'editor.modal.shareLink.editorDescription'} />
<CopyableField content={`${baseUrl}n/${noteIdentifier}`} shareOriginUrl={`${baseUrl}n/${noteIdentifier}`} />
<ShowIf condition={noteFrontmatter.type === NoteType.SLIDE}>
<Trans i18nKey={'editor.modal.shareLink.slidesDescription'} />
<CopyableField content={`${baseUrl}p/${noteIdentifier}`} shareOriginUrl={`${baseUrl}p/${noteIdentifier}`} />
</ShowIf>
<ShowIf condition={noteFrontmatter.type === NoteType.DOCUMENT}>
<Trans i18nKey={'editor.modal.shareLink.viewOnlyDescription'} />
<CopyableField content={`${baseUrl}s/${noteIdentifier}`} shareOriginUrl={`${baseUrl}s/${noteIdentifier}`} />
</ShowIf>
</Modal.Body>
</CommonModal>
)
}