mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-12-22 12:31:40 +00:00
feat(frontend): delete revision
Signed-off-by: Avinash <avinash.kumar.cs92@gmail.com>
This commit is contained in:
parent
354a51fd72
commit
a948493410
8 changed files with 296 additions and 48 deletions
96
frontend/cypress/e2e/revision.spec.ts
Normal file
96
frontend/cypress/e2e/revision.spec.ts
Normal file
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { testNoteId } from '../support/visit-test-editor'
|
||||
import { DateTime } from 'luxon'
|
||||
import { join } from 'path'
|
||||
|
||||
describe('Revision modal', () => {
|
||||
const testTitle = 'testContent'
|
||||
const testContent = `---\ntitle: ${testTitle}\n---\nThis is some test content`
|
||||
|
||||
const defaultCreatedAt = '2021-12-29T17:54:11.000Z'
|
||||
const formattedDate = DateTime.fromISO(defaultCreatedAt).toFormat('DDDD T')
|
||||
const revisionPayload = [
|
||||
{
|
||||
id: 1,
|
||||
createdAt: defaultCreatedAt,
|
||||
length: 2788,
|
||||
authorUsernames: [],
|
||||
anonymousAuthorCount: 4,
|
||||
title: 'Features',
|
||||
description: 'Many features, such wow!',
|
||||
tags: ['hedgedoc', 'demo', 'react']
|
||||
},
|
||||
{
|
||||
id: 0,
|
||||
createdAt: defaultCreatedAt,
|
||||
length: 2782,
|
||||
authorUsernames: [],
|
||||
anonymousAuthorCount: 2,
|
||||
title: 'Features',
|
||||
description: 'Many more features, such wow!',
|
||||
tags: ['hedgedoc', 'demo', 'react']
|
||||
}
|
||||
]
|
||||
beforeEach(() => {
|
||||
cy.intercept('GET', `api/private/notes/${testNoteId}/revisions`, revisionPayload)
|
||||
cy.visitTestNote()
|
||||
cy.getByCypressId('sidebar.revision.button').click()
|
||||
})
|
||||
|
||||
it('can delete revisions', () => {
|
||||
cy.intercept('DELETE', `api/private/notes/${testNoteId}/revisions`, {
|
||||
statusCode: 204
|
||||
})
|
||||
const cardsContents = [formattedDate, 'Length: 2788', 'Anonymous authors or guests: 4']
|
||||
|
||||
cardsContents.forEach((content) => cy.getByCypressId('revision.modal.lists').contains(content))
|
||||
|
||||
cy.getByCypressId('revision.modal.revert.button').should('be.disabled')
|
||||
cy.getByCypressId('revision.modal.download.button').should('be.disabled')
|
||||
cy.getByCypressId('revision.modal.delete.button').click()
|
||||
cy.getByCypressId('revision.delete.modal').should('be.visible')
|
||||
|
||||
cy.getByCypressId('revision.delete.button').click()
|
||||
cy.getByCypressId('revision.delete.modal').should('not.exist')
|
||||
cy.getByCypressId('sidebar.revision.modal').should('be.visible')
|
||||
})
|
||||
it('can handle fail of revision deletion', () => {
|
||||
cy.intercept('DELETE', `api/private/notes/${testNoteId}/revisions`, {
|
||||
statusCode: 400
|
||||
})
|
||||
cy.getByCypressId('revision.modal.delete.button').click()
|
||||
cy.getByCypressId('revision.delete.modal').should('be.visible')
|
||||
|
||||
cy.getByCypressId('revision.delete.button').click()
|
||||
cy.getByCypressId('revision.delete.modal').should('not.exist')
|
||||
cy.getByCypressId('notification-toast').should('be.visible')
|
||||
cy.getByCypressId('sidebar.revision.modal').should('be.visible')
|
||||
})
|
||||
it('can download revisions', () => {
|
||||
cy.intercept('GET', '/api/private/notes/mock-note/revisions/1', {
|
||||
id: 1,
|
||||
createdAt: defaultCreatedAt,
|
||||
title: 'Features',
|
||||
description: 'Many more features, such wow!',
|
||||
tags: ['hedgedoc', 'demo', 'react'],
|
||||
patch: testContent,
|
||||
edits: [],
|
||||
length: 2788,
|
||||
authorUsernames: [],
|
||||
anonymousAuthorCount: 4,
|
||||
content: testContent
|
||||
})
|
||||
|
||||
const downloadFolder = Cypress.config('downloadsFolder')
|
||||
const fileName = `mock-note-${defaultCreatedAt.replace(/:/g, '_')}.md`
|
||||
const filePath = join(downloadFolder, fileName)
|
||||
|
||||
cy.getByCypressId('revision.modal.lists').contains(formattedDate).click()
|
||||
cy.getByCypressId('revision.modal.download.button').click()
|
||||
cy.readFile(filePath).should('contain', testContent)
|
||||
})
|
||||
})
|
|
@ -381,6 +381,13 @@
|
|||
"download": "Download selected revision",
|
||||
"guestCount": "Anonymous authors or guests"
|
||||
},
|
||||
"deleteRevision": {
|
||||
"title": "Delete Revisions for current note",
|
||||
"question": "Do you really want to remove all the previous revisions except the current of this note?",
|
||||
"warning": "All the previous revisions except the current will be deleted for this note. This process is irreversible.",
|
||||
"error": "An error occurred while deleting revisions of this note.",
|
||||
"button": "Delete Revisions"
|
||||
},
|
||||
"clipboardImport": {
|
||||
"title": "Import from clipboard",
|
||||
"insertMarkdown": "Paste your markdown or webpage here…"
|
||||
|
|
|
@ -4,10 +4,12 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { useBooleanState } from '../../../../../hooks/common/use-boolean-state'
|
||||
import { cypressId } from '../../../../../utils/cypress-attribute'
|
||||
import { SidebarButton } from '../../sidebar-button/sidebar-button'
|
||||
import type { SpecificSidebarEntryProps } from '../../types'
|
||||
import { RevisionDeleteModal } from './revisions-modal/delete-revision-modal'
|
||||
import { RevisionModal } from './revisions-modal/revision-modal'
|
||||
import React, { Fragment } from 'react'
|
||||
import React, { Fragment, useCallback } from 'react'
|
||||
import { ClockHistory as IconClockHistory } from 'react-bootstrap-icons'
|
||||
import { Trans } from 'react-i18next'
|
||||
|
||||
|
@ -19,13 +21,25 @@ import { Trans } from 'react-i18next'
|
|||
*/
|
||||
export const RevisionSidebarEntry: React.FC<SpecificSidebarEntryProps> = ({ className, hide }) => {
|
||||
const [modalVisibility, showModal, closeModal] = useBooleanState()
|
||||
const [revisionsDeleteModalVisibility, showRevisionsDeleteModal, closeRevisionsDeleteModal] = useBooleanState()
|
||||
|
||||
const onDeleteRevisions = useCallback(() => {
|
||||
closeRevisionsDeleteModal()
|
||||
showModal()
|
||||
}, [closeRevisionsDeleteModal, showModal])
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<SidebarButton hide={hide} className={className} icon={IconClockHistory} onClick={showModal}>
|
||||
<SidebarButton
|
||||
{...cypressId('sidebar.revision.button')}
|
||||
hide={hide}
|
||||
className={className}
|
||||
icon={IconClockHistory}
|
||||
onClick={showModal}>
|
||||
<Trans i18nKey={'editor.modal.revision.title'} />
|
||||
</SidebarButton>
|
||||
<RevisionModal show={modalVisibility} onHide={closeModal} />
|
||||
<RevisionModal show={modalVisibility} onHide={closeModal} onShowDeleteModal={showRevisionsDeleteModal} />
|
||||
<RevisionDeleteModal show={revisionsDeleteModalVisibility} onHide={onDeleteRevisions} />
|
||||
</Fragment>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { deleteRevisionsForNote } from '../../../../../../api/revisions'
|
||||
import { useApplicationState } from '../../../../../../hooks/common/use-application-state'
|
||||
import { cypressId } from '../../../../../../utils/cypress-attribute'
|
||||
import type { ModalVisibilityProps } from '../../../../../common/modals/common-modal'
|
||||
import { CommonModal } from '../../../../../common/modals/common-modal'
|
||||
import { useUiNotifications } from '../../../../../notifications/ui-notification-boundary'
|
||||
import React, { useCallback } from 'react'
|
||||
import { Button, Modal } from 'react-bootstrap'
|
||||
import { Trans } from 'react-i18next'
|
||||
|
||||
/**
|
||||
* This modal for deletion of notes's revision
|
||||
*
|
||||
* @param show true to show the modal, false otherwise.
|
||||
* @param onHide Callback that is fired when the modal is requested to close.
|
||||
*/
|
||||
export const RevisionDeleteModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) => {
|
||||
const { showErrorNotification } = useUiNotifications()
|
||||
const noteId = useApplicationState((state) => state.noteDetails.id)
|
||||
|
||||
const deleteAllRevisions = useCallback(() => {
|
||||
deleteRevisionsForNote(noteId).catch(showErrorNotification('editor.modal.deleteRevision.error')).finally(onHide)
|
||||
}, [noteId, onHide, showErrorNotification])
|
||||
|
||||
return (
|
||||
<CommonModal
|
||||
show={show}
|
||||
onHide={onHide}
|
||||
titleI18nKey={'editor.modal.deleteRevision.title'}
|
||||
showCloseButton={true}
|
||||
{...cypressId('revision.delete.modal')}>
|
||||
<Modal.Body>
|
||||
<h6>
|
||||
<Trans i18nKey={'editor.modal.deleteRevision.warning'} />
|
||||
</h6>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant='danger' onClick={deleteAllRevisions} {...cypressId('revision.delete.button')}>
|
||||
<Trans i18nKey={'editor.modal.deleteRevision.button'} />
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</CommonModal>
|
||||
)
|
||||
}
|
|
@ -3,17 +3,19 @@
|
|||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { getAllRevisions } from '../../../../../../api/revisions'
|
||||
import { useApplicationState } from '../../../../../../hooks/common/use-application-state'
|
||||
import type { RevisionMetadata } from '../../../../../../api/revisions/types'
|
||||
import { cypressId } from '../../../../../../utils/cypress-attribute'
|
||||
import { AsyncLoadingBoundary } from '../../../../../common/async-loading-boundary/async-loading-boundary'
|
||||
import { RevisionListEntry } from './revision-list-entry'
|
||||
import { DateTime } from 'luxon'
|
||||
import React, { useMemo } from 'react'
|
||||
import { ListGroup } from 'react-bootstrap'
|
||||
import { useAsync } from 'react-use'
|
||||
|
||||
export interface RevisionListProps {
|
||||
interface RevisionListProps {
|
||||
selectedRevisionId?: number
|
||||
revisions?: RevisionMetadata[]
|
||||
loadingRevisions: boolean
|
||||
error?: Error | boolean
|
||||
onRevisionSelect: (selectedRevisionId: number) => void
|
||||
}
|
||||
|
||||
|
@ -22,20 +24,19 @@ export interface RevisionListProps {
|
|||
*
|
||||
* @param selectedRevisionId The currently selected revision
|
||||
* @param onRevisionSelect Callback that is executed when a list entry is selected
|
||||
* @param revisions List of all the revisions
|
||||
* @param error Indicates an error occurred
|
||||
* @param loadingRevisions Boolean for showing loading state
|
||||
*/
|
||||
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])
|
||||
|
||||
export const RevisionList: React.FC<RevisionListProps> = ({
|
||||
selectedRevisionId,
|
||||
onRevisionSelect,
|
||||
revisions,
|
||||
loadingRevisions,
|
||||
error
|
||||
}) => {
|
||||
const revisionList = useMemo(() => {
|
||||
if (loading || !revisions) {
|
||||
if (loadingRevisions || !revisions) {
|
||||
return null
|
||||
}
|
||||
return revisions
|
||||
|
@ -52,11 +53,11 @@ export const RevisionList: React.FC<RevisionListProps> = ({ selectedRevisionId,
|
|||
key={revisionListEntry.id}
|
||||
/>
|
||||
))
|
||||
}, [loading, onRevisionSelect, revisions, selectedRevisionId])
|
||||
}, [loadingRevisions, onRevisionSelect, revisions, selectedRevisionId])
|
||||
|
||||
return (
|
||||
<AsyncLoadingBoundary loading={loading || !revisions} error={error} componentName={'revision list'}>
|
||||
<ListGroup>{revisionList}</ListGroup>
|
||||
<AsyncLoadingBoundary loading={loadingRevisions || !revisions} error={error} componentName={'revision list'}>
|
||||
<ListGroup {...cypressId('revision.modal.lists')}>{revisionList}</ListGroup>
|
||||
</AsyncLoadingBoundary>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { getAllRevisions } from '../../../../../../api/revisions'
|
||||
import { useApplicationState } from '../../../../../../hooks/common/use-application-state'
|
||||
import { useIsOwner } from '../../../../../../hooks/common/use-is-owner'
|
||||
import { cypressId } from '../../../../../../utils/cypress-attribute'
|
||||
import { RevisionList } from './revision-list'
|
||||
import type { RevisionModal } from './revision-modal'
|
||||
import { RevisionModalFooter } from './revision-modal-footer'
|
||||
import styles from './revision-modal.module.scss'
|
||||
import { RevisionViewer } from './revision-viewer'
|
||||
import React, { Fragment, useState } from 'react'
|
||||
import { Col, Modal, Row } from 'react-bootstrap'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAsync } from 'react-use'
|
||||
|
||||
/**
|
||||
* Renders list of revisions and all the actions buttons.
|
||||
*
|
||||
* @param onShowDeleteModal Callback to render revisions delete modal
|
||||
* @param onHide Callback that gets triggered when revision modal is about to hide.
|
||||
*/
|
||||
export const RevisionModalBody = ({ onShowDeleteModal, onHide }: RevisionModal) => {
|
||||
useTranslation()
|
||||
const isOwner = useIsOwner()
|
||||
const [selectedRevisionId, setSelectedRevisionId] = useState<number>()
|
||||
const noteIdentifier = useApplicationState((state) => state.noteDetails.id)
|
||||
const { value: revisions, error, loading } = useAsync(() => getAllRevisions(noteIdentifier), [noteIdentifier])
|
||||
|
||||
const revisionLength = revisions?.length ?? 0
|
||||
const enableDeleteRevisions = revisionLength > 1 && isOwner
|
||||
return (
|
||||
<Fragment>
|
||||
<Modal.Body {...cypressId('sidebar.revision.modal')}>
|
||||
<Row>
|
||||
<Col lg={4} className={styles['scroll-col']}>
|
||||
<RevisionList
|
||||
error={error}
|
||||
loadingRevisions={loading}
|
||||
revisions={revisions}
|
||||
onRevisionSelect={setSelectedRevisionId}
|
||||
selectedRevisionId={selectedRevisionId}
|
||||
/>
|
||||
</Col>
|
||||
<Col lg={8} className={styles['scroll-col']}>
|
||||
<RevisionViewer selectedRevisionId={selectedRevisionId} />
|
||||
</Col>
|
||||
</Row>
|
||||
</Modal.Body>
|
||||
<RevisionModalFooter
|
||||
selectedRevisionId={selectedRevisionId}
|
||||
onHide={onHide}
|
||||
disableDeleteRevisions={!enableDeleteRevisions}
|
||||
onShowDeleteModal={onShowDeleteModal}
|
||||
/>
|
||||
</Fragment>
|
||||
)
|
||||
}
|
|
@ -5,27 +5,36 @@
|
|||
*/
|
||||
import { getRevision } from '../../../../../../api/revisions'
|
||||
import { useApplicationState } from '../../../../../../hooks/common/use-application-state'
|
||||
import { cypressId } from '../../../../../../utils/cypress-attribute'
|
||||
import type { ModalVisibilityProps } from '../../../../../common/modals/common-modal'
|
||||
import { useUiNotifications } from '../../../../../notifications/ui-notification-boundary'
|
||||
import type { RevisionModalProps } from './revision-modal'
|
||||
import { downloadRevision } from './utils'
|
||||
import React, { useCallback } from 'react'
|
||||
import { Button, Modal } from 'react-bootstrap'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
|
||||
export interface RevisionModalFooterProps {
|
||||
interface RevisionModalFooter {
|
||||
selectedRevisionId?: number
|
||||
disableDeleteRevisions: boolean
|
||||
}
|
||||
|
||||
type RevisionModalFooterProps = RevisionModalProps & RevisionModalFooter & Pick<ModalVisibilityProps, 'onHide'>
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param onShowDeleteModal Callback to render revision deleteModal.
|
||||
* @param disableDeleteRevisions Boolean to disable delete button.
|
||||
*/
|
||||
export const RevisionModalFooter: React.FC<RevisionModalFooterProps & Pick<ModalVisibilityProps, 'onHide'>> = ({
|
||||
export const RevisionModalFooter: React.FC<RevisionModalFooterProps> = ({
|
||||
selectedRevisionId,
|
||||
onHide
|
||||
onHide,
|
||||
onShowDeleteModal,
|
||||
disableDeleteRevisions
|
||||
}) => {
|
||||
useTranslation()
|
||||
const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress)
|
||||
|
@ -48,15 +57,35 @@ export const RevisionModalFooter: React.FC<RevisionModalFooterProps & Pick<Modal
|
|||
.catch(showErrorNotification(''))
|
||||
}, [noteIdentifier, selectedRevisionId, showErrorNotification])
|
||||
|
||||
const openDeleteModal = useCallback(() => {
|
||||
onHide?.()
|
||||
onShowDeleteModal()
|
||||
}, [onHide, onShowDeleteModal])
|
||||
|
||||
return (
|
||||
<Modal.Footer>
|
||||
<Button variant='secondary' onClick={onHide}>
|
||||
<Trans i18nKey={'common.close'} />
|
||||
</Button>
|
||||
<Button variant='danger' disabled={selectedRevisionId === undefined} onClick={onRevertToRevision}>
|
||||
<Button
|
||||
variant='danger'
|
||||
onClick={openDeleteModal}
|
||||
{...cypressId('revision.modal.delete.button')}
|
||||
disabled={disableDeleteRevisions}>
|
||||
<Trans i18nKey={'editor.modal.deleteRevision.button'} />
|
||||
</Button>
|
||||
<Button
|
||||
variant='danger'
|
||||
disabled={selectedRevisionId === undefined}
|
||||
onClick={onRevertToRevision}
|
||||
{...cypressId('revision.modal.revert.button')}>
|
||||
<Trans i18nKey={'editor.modal.revision.revertButton'} />
|
||||
</Button>
|
||||
<Button variant='primary' disabled={selectedRevisionId === undefined} onClick={onDownloadRevision}>
|
||||
<Button
|
||||
variant='primary'
|
||||
disabled={selectedRevisionId === undefined}
|
||||
onClick={onDownloadRevision}
|
||||
{...cypressId('revision.modal.download.button')}>
|
||||
<Trans i18nKey={'editor.modal.revision.download'} />
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
|
|
|
@ -5,25 +5,26 @@
|
|||
*/
|
||||
import type { ModalVisibilityProps } from '../../../../../common/modals/common-modal'
|
||||
import { CommonModal } from '../../../../../common/modals/common-modal'
|
||||
import { RevisionList } from './revision-list'
|
||||
import { RevisionModalFooter } from './revision-modal-footer'
|
||||
import { RevisionModalBody } from './revision-modal-body'
|
||||
import styles from './revision-modal.module.scss'
|
||||
import { RevisionViewer } from './revision-viewer'
|
||||
import React, { useState } from 'react'
|
||||
import { Col, Modal, Row } from 'react-bootstrap'
|
||||
import React from 'react'
|
||||
import { ClockHistory as IconClockHistory } from 'react-bootstrap-icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export interface RevisionModalProps {
|
||||
onShowDeleteModal: () => void
|
||||
}
|
||||
|
||||
export type RevisionModal = RevisionModalProps & ModalVisibilityProps
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* @param onShowDeleteModal Callback to render revision delete modal.
|
||||
*
|
||||
*/
|
||||
export const RevisionModal: React.FC<ModalVisibilityProps> = ({ show, onHide }) => {
|
||||
useTranslation()
|
||||
const [selectedRevisionId, setSelectedRevisionId] = useState<number>()
|
||||
|
||||
export const RevisionModal: React.FC<RevisionModal> = ({ show, onHide, onShowDeleteModal }) => {
|
||||
return (
|
||||
<CommonModal
|
||||
show={show}
|
||||
|
@ -33,17 +34,7 @@ export const RevisionModal: React.FC<ModalVisibilityProps> = ({ show, onHide })
|
|||
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} />
|
||||
<RevisionModalBody show={show} onShowDeleteModal={onShowDeleteModal} onHide={onHide} />
|
||||
</CommonModal>
|
||||
)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue