mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-05-23 03:27:05 -04:00
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:
parent
4e18ce38f3
commit
762a0a850e
1051 changed files with 0 additions and 35 deletions
|
@ -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>
|
||||
`;
|
|
@ -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>
|
||||
`;
|
|
@ -0,0 +1,27 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AliasesList renders the AliasList sorted 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
Alias:
|
||||
a-test
|
||||
(
|
||||
non-primary
|
||||
)
|
||||
</span>
|
||||
<span>
|
||||
Alias:
|
||||
b-test
|
||||
(
|
||||
primary
|
||||
)
|
||||
</span>
|
||||
<span>
|
||||
Alias:
|
||||
z-test
|
||||
(
|
||||
non-primary
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,32 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`AliasesModal renders the modal 1`] = `
|
||||
<div>
|
||||
<span>
|
||||
This is a mock implementation of a Modal:
|
||||
<dialog>
|
||||
<div
|
||||
class="modal-body"
|
||||
>
|
||||
<p>
|
||||
editor.modal.aliases.explanation
|
||||
</p>
|
||||
<div
|
||||
class="list-group"
|
||||
>
|
||||
<span>
|
||||
This is a mock for the AliasesList that is tested separately.
|
||||
</span>
|
||||
<div
|
||||
class="list-group-item"
|
||||
>
|
||||
<span>
|
||||
This is a mock for the AliasesAddForm that is tested separately.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</span>
|
||||
</div>
|
||||
`;
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { render, act, screen } from '@testing-library/react'
|
||||
import testEvent from '@testing-library/user-event'
|
||||
import React from 'react'
|
||||
import { mockI18n } from '../../../markdown-renderer/test-utils/mock-i18n'
|
||||
import * as AliasModule from '../../../../api/alias'
|
||||
import * as NoteDetailsReduxModule from '../../../../redux/note-details/methods'
|
||||
import * as useApplicationStateModule from '../../../../hooks/common/use-application-state'
|
||||
import { AliasesAddForm } from './aliases-add-form'
|
||||
import * as useUiNotificationsModule from '../../../notifications/ui-notification-boundary'
|
||||
|
||||
jest.mock('../../../../api/alias')
|
||||
jest.mock('../../../../redux/note-details/methods')
|
||||
jest.mock('../../../../hooks/common/use-application-state')
|
||||
jest.mock('../../../notifications/ui-notification-boundary')
|
||||
|
||||
const addPromise = Promise.resolve({ name: 'mock', primaryAlias: true, noteId: 'mock' })
|
||||
|
||||
describe('AliasesAddForm', () => {
|
||||
beforeEach(async () => {
|
||||
await mockI18n()
|
||||
jest.spyOn(AliasModule, 'addAlias').mockImplementation(() => addPromise)
|
||||
jest.spyOn(NoteDetailsReduxModule, 'updateMetadata').mockImplementation(() => Promise.resolve())
|
||||
jest.spyOn(useApplicationStateModule, 'useApplicationState').mockReturnValue('mock-note')
|
||||
jest.spyOn(useUiNotificationsModule, 'useUiNotifications').mockReturnValue({
|
||||
showErrorNotification: jest.fn(),
|
||||
dismissNotification: jest.fn(),
|
||||
dispatchUiNotification: jest.fn()
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks()
|
||||
jest.resetModules()
|
||||
})
|
||||
|
||||
it('renders the input form', async () => {
|
||||
const view = render(<AliasesAddForm />)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
const button = await screen.findByTestId('addAliasButton')
|
||||
expect(button).toBeDisabled()
|
||||
const input = await screen.findByTestId('addAliasInput')
|
||||
await testEvent.type(input, 'abc')
|
||||
expect(button).toBeEnabled()
|
||||
act(() => {
|
||||
button.click()
|
||||
})
|
||||
expect(AliasModule.addAlias).toBeCalledWith('mock-note', 'abc')
|
||||
await addPromise
|
||||
expect(NoteDetailsReduxModule.updateMetadata).toBeCalled()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import 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>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { render, act, screen } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import { mockI18n } from '../../../markdown-renderer/test-utils/mock-i18n'
|
||||
import type { Alias } from '../../../../api/alias/types'
|
||||
import { AliasesListEntry } from './aliases-list-entry'
|
||||
import * as AliasModule from '../../../../api/alias'
|
||||
import * as NoteDetailsReduxModule from '../../../../redux/note-details/methods'
|
||||
import * as useUiNotificationsModule from '../../../notifications/ui-notification-boundary'
|
||||
|
||||
jest.mock('../../../../api/alias')
|
||||
jest.mock('../../../../redux/note-details/methods')
|
||||
jest.mock('../../../notifications/ui-notification-boundary')
|
||||
|
||||
const deletePromise = Promise.resolve()
|
||||
const markAsPrimaryPromise = Promise.resolve({ name: 'mock', primaryAlias: true, noteId: 'mock' })
|
||||
|
||||
describe('AliasesListEntry', () => {
|
||||
beforeEach(async () => {
|
||||
await mockI18n()
|
||||
jest.spyOn(AliasModule, 'deleteAlias').mockImplementation(() => deletePromise)
|
||||
jest.spyOn(AliasModule, 'markAliasAsPrimary').mockImplementation(() => markAsPrimaryPromise)
|
||||
jest.spyOn(NoteDetailsReduxModule, 'updateMetadata').mockImplementation(() => Promise.resolve())
|
||||
jest.spyOn(useUiNotificationsModule, 'useUiNotifications').mockReturnValue({
|
||||
showErrorNotification: jest.fn(),
|
||||
dismissNotification: jest.fn(),
|
||||
dispatchUiNotification: jest.fn()
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks()
|
||||
jest.resetModules()
|
||||
})
|
||||
|
||||
it('renders an AliasesListEntry that is primary', async () => {
|
||||
const testAlias: Alias = {
|
||||
name: 'test-primary',
|
||||
primaryAlias: true,
|
||||
noteId: 'test-note-id'
|
||||
}
|
||||
const view = render(<AliasesListEntry alias={testAlias} />)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
const button = await screen.findByTestId('aliasButtonRemove')
|
||||
act(() => {
|
||||
button.click()
|
||||
})
|
||||
expect(AliasModule.deleteAlias).toBeCalledWith(testAlias.name)
|
||||
await deletePromise
|
||||
expect(NoteDetailsReduxModule.updateMetadata).toBeCalled()
|
||||
})
|
||||
|
||||
it('renders an AliasesListEntry that is not primary', async () => {
|
||||
const testAlias: Alias = {
|
||||
name: 'test-non-primary',
|
||||
primaryAlias: false,
|
||||
noteId: 'test-note-id'
|
||||
}
|
||||
const view = render(<AliasesListEntry alias={testAlias} />)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
const buttonRemove = await screen.findByTestId('aliasButtonRemove')
|
||||
act(() => {
|
||||
buttonRemove.click()
|
||||
})
|
||||
expect(AliasModule.deleteAlias).toBeCalledWith(testAlias.name)
|
||||
await deletePromise
|
||||
expect(NoteDetailsReduxModule.updateMetadata).toBeCalled()
|
||||
const buttonMakePrimary = await screen.findByTestId('aliasButtonMakePrimary')
|
||||
act(() => {
|
||||
buttonMakePrimary.click()
|
||||
})
|
||||
expect(AliasModule.markAliasAsPrimary).toBeCalledWith(testAlias.name)
|
||||
await markAsPrimaryPromise
|
||||
expect(NoteDetailsReduxModule.updateMetadata).toBeCalled()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,77 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import React, { useCallback } from 'react'
|
||||
import { ForkAwesomeIcon } from '../../../common/fork-awesome/fork-awesome-icon'
|
||||
import { Button } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ShowIf } from '../../../common/show-if/show-if'
|
||||
import type { Alias } from '../../../../api/alias/types'
|
||||
import { deleteAlias, markAliasAsPrimary } from '../../../../api/alias'
|
||||
import { updateMetadata } from '../../../../redux/note-details/methods'
|
||||
import { useUiNotifications } from '../../../notifications/ui-notification-boundary'
|
||||
import { testId } from '../../../../utils/test-id'
|
||||
|
||||
export interface AliasesListEntryProps {
|
||||
alias: Alias
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that shows an entry in the aliases list with buttons to remove it or mark it as primary.
|
||||
*
|
||||
* @param alias The alias.
|
||||
*/
|
||||
export const AliasesListEntry: React.FC<AliasesListEntryProps> = ({ alias }) => {
|
||||
const { t } = useTranslation()
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
|
||||
const onRemoveClick = useCallback(() => {
|
||||
deleteAlias(alias.name)
|
||||
.then(updateMetadata)
|
||||
.catch(showErrorNotification(t('editor.modal.aliases.errorRemovingAlias')))
|
||||
}, [alias, t, showErrorNotification])
|
||||
|
||||
const onMakePrimaryClick = useCallback(() => {
|
||||
markAliasAsPrimary(alias.name)
|
||||
.then(updateMetadata)
|
||||
.catch(showErrorNotification(t('editor.modal.aliases.errorMakingPrimary')))
|
||||
}, [alias, t, showErrorNotification])
|
||||
|
||||
return (
|
||||
<li className={'list-group-item d-flex flex-row justify-content-between align-items-center'}>
|
||||
{alias.name}
|
||||
<div>
|
||||
<ShowIf condition={alias.primaryAlias}>
|
||||
<Button
|
||||
className={'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>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { render } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import { mockI18n } from '../../../markdown-renderer/test-utils/mock-i18n'
|
||||
import type { Alias } from '../../../../api/alias/types'
|
||||
import * as useApplicationStateModule from '../../../../hooks/common/use-application-state'
|
||||
import * as AliasesListEntryModule from './aliases-list-entry'
|
||||
import type { AliasesListEntryProps } from './aliases-list-entry'
|
||||
import { AliasesList } from './aliases-list'
|
||||
|
||||
jest.mock('../../../../hooks/common/use-application-state')
|
||||
jest.mock('./aliases-list-entry')
|
||||
|
||||
describe('AliasesList', () => {
|
||||
beforeEach(async () => {
|
||||
await mockI18n()
|
||||
jest.spyOn(useApplicationStateModule, 'useApplicationState').mockReturnValue([
|
||||
{
|
||||
name: 'a-test',
|
||||
noteId: 'note-id',
|
||||
primaryAlias: false
|
||||
},
|
||||
{
|
||||
name: 'z-test',
|
||||
noteId: 'note-id',
|
||||
primaryAlias: false
|
||||
},
|
||||
{
|
||||
name: 'b-test',
|
||||
noteId: 'note-id',
|
||||
primaryAlias: true
|
||||
}
|
||||
] as Alias[])
|
||||
jest.spyOn(AliasesListEntryModule, 'AliasesListEntry').mockImplementation((({ alias }) => {
|
||||
return (
|
||||
<span>
|
||||
Alias: {alias.name} ({alias.primaryAlias ? 'primary' : 'non-primary'})
|
||||
</span>
|
||||
)
|
||||
}) as React.FC<AliasesListEntryProps>)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks()
|
||||
jest.resetModules()
|
||||
})
|
||||
|
||||
it('renders the AliasList sorted', () => {
|
||||
const view = render(<AliasesList />)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import React, { Fragment, useMemo } from 'react'
|
||||
import { useApplicationState } from '../../../../hooks/common/use-application-state'
|
||||
import type { ApplicationState } from '../../../../redux/application-state'
|
||||
import { AliasesListEntry } from './aliases-list-entry'
|
||||
|
||||
/**
|
||||
* Renders the list of aliases.
|
||||
*/
|
||||
export const AliasesList: React.FC = () => {
|
||||
const aliases = useApplicationState((state: ApplicationState) => state.noteDetails.aliases)
|
||||
|
||||
const aliasesDom = useMemo(() => {
|
||||
return aliases
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((alias) => <AliasesListEntry alias={alias} key={alias.name} />)
|
||||
}, [aliases])
|
||||
|
||||
return <Fragment>{aliasesDom}</Fragment>
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { render } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import type { PropsWithChildren } from 'react'
|
||||
import type { CommonModalProps } from '../../../common/modals/common-modal'
|
||||
import * as CommonModalModule from '../../../common/modals/common-modal'
|
||||
import * as AliasesListModule from './aliases-list'
|
||||
import * as AliasesAddFormModule from './aliases-add-form'
|
||||
import * as useUiNotificationsModule from '../../../notifications/ui-notification-boundary'
|
||||
import { AliasesModal } from './aliases-modal'
|
||||
import { mockI18n } from '../../../markdown-renderer/test-utils/mock-i18n'
|
||||
|
||||
jest.mock('./aliases-list')
|
||||
jest.mock('./aliases-add-form')
|
||||
jest.mock('../../../common/modals/common-modal')
|
||||
jest.mock('../../../notifications/ui-notification-boundary')
|
||||
|
||||
describe('AliasesModal', () => {
|
||||
beforeEach(async () => {
|
||||
await mockI18n()
|
||||
jest.spyOn(CommonModalModule, 'CommonModal').mockImplementation((({ children }) => {
|
||||
return (
|
||||
<span>
|
||||
This is a mock implementation of a Modal: <dialog>{children}</dialog>
|
||||
</span>
|
||||
)
|
||||
}) as React.FC<PropsWithChildren<CommonModalProps>>)
|
||||
jest.spyOn(AliasesListModule, 'AliasesList').mockImplementation((() => {
|
||||
return <span>This is a mock for the AliasesList that is tested separately.</span>
|
||||
}) as React.FC)
|
||||
jest.spyOn(AliasesAddFormModule, 'AliasesAddForm').mockImplementation((() => {
|
||||
return <span>This is a mock for the AliasesAddForm that is tested separately.</span>
|
||||
}) as React.FC)
|
||||
jest.spyOn(useUiNotificationsModule, 'useUiNotifications').mockReturnValue({
|
||||
showErrorNotification: jest.fn(),
|
||||
dismissNotification: jest.fn(),
|
||||
dispatchUiNotification: jest.fn()
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
jest.resetAllMocks()
|
||||
jest.resetModules()
|
||||
})
|
||||
|
||||
it('renders the modal', () => {
|
||||
const view = render(<AliasesModal show={true} />)
|
||||
expect(view.container).toMatchSnapshot()
|
||||
})
|
||||
})
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import React from 'react'
|
||||
import { ListGroup, ListGroupItem, Modal } from 'react-bootstrap'
|
||||
import type { CommonModalProps } from '../../../common/modals/common-modal'
|
||||
import { CommonModal } from '../../../common/modals/common-modal'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { AliasesList } from './aliases-list'
|
||||
import { AliasesAddForm } from './aliases-add-form'
|
||||
|
||||
/**
|
||||
* Component that holds a modal containing a list of aliases associated with the current note.
|
||||
*
|
||||
* @param show True when the modal should be visible, false otherwise.
|
||||
* @param onHide Callback that is executed when the modal is dismissed.
|
||||
*/
|
||||
export const AliasesModal: React.FC<CommonModalProps> = ({ show, onHide }) => {
|
||||
useTranslation()
|
||||
|
||||
return (
|
||||
<CommonModal show={show} onHide={onHide} title={'editor.modal.aliases.title'} showCloseButton={true}>
|
||||
<Modal.Body>
|
||||
<p>
|
||||
<Trans i18nKey={'editor.modal.aliases.explanation'} />
|
||||
</p>
|
||||
<ListGroup>
|
||||
<AliasesList />
|
||||
<ListGroupItem>
|
||||
<AliasesAddForm />
|
||||
</ListGroupItem>
|
||||
</ListGroup>
|
||||
</Modal.Body>
|
||||
</CommonModal>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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'
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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'
|
||||
}
|
|
@ -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",
|
||||
}
|
||||
`)
|
||||
})
|
||||
})
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue