feat(frontend): delete revision

Signed-off-by: Avinash <avinash.kumar.cs92@gmail.com>
This commit is contained in:
Avinash 2023-06-29 19:26:36 +05:30 committed by Tilman Vatteroth
parent 354a51fd72
commit a948493410
8 changed files with 296 additions and 48 deletions

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

View file

@ -381,6 +381,13 @@
"download": "Download selected revision", "download": "Download selected revision",
"guestCount": "Anonymous authors or guests" "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": { "clipboardImport": {
"title": "Import from clipboard", "title": "Import from clipboard",
"insertMarkdown": "Paste your markdown or webpage here…" "insertMarkdown": "Paste your markdown or webpage here…"

View file

@ -4,10 +4,12 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { useBooleanState } from '../../../../../hooks/common/use-boolean-state' import { useBooleanState } from '../../../../../hooks/common/use-boolean-state'
import { cypressId } from '../../../../../utils/cypress-attribute'
import { SidebarButton } from '../../sidebar-button/sidebar-button' import { SidebarButton } from '../../sidebar-button/sidebar-button'
import type { SpecificSidebarEntryProps } from '../../types' import type { SpecificSidebarEntryProps } from '../../types'
import { RevisionDeleteModal } from './revisions-modal/delete-revision-modal'
import { RevisionModal } from './revisions-modal/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 { ClockHistory as IconClockHistory } from 'react-bootstrap-icons'
import { Trans } from 'react-i18next' import { Trans } from 'react-i18next'
@ -19,13 +21,25 @@ import { Trans } from 'react-i18next'
*/ */
export const RevisionSidebarEntry: React.FC<SpecificSidebarEntryProps> = ({ className, hide }) => { export const RevisionSidebarEntry: React.FC<SpecificSidebarEntryProps> = ({ className, hide }) => {
const [modalVisibility, showModal, closeModal] = useBooleanState() const [modalVisibility, showModal, closeModal] = useBooleanState()
const [revisionsDeleteModalVisibility, showRevisionsDeleteModal, closeRevisionsDeleteModal] = useBooleanState()
const onDeleteRevisions = useCallback(() => {
closeRevisionsDeleteModal()
showModal()
}, [closeRevisionsDeleteModal, showModal])
return ( return (
<Fragment> <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'} /> <Trans i18nKey={'editor.modal.revision.title'} />
</SidebarButton> </SidebarButton>
<RevisionModal show={modalVisibility} onHide={closeModal} /> <RevisionModal show={modalVisibility} onHide={closeModal} onShowDeleteModal={showRevisionsDeleteModal} />
<RevisionDeleteModal show={revisionsDeleteModalVisibility} onHide={onDeleteRevisions} />
</Fragment> </Fragment>
) )
} }

View file

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

View file

@ -3,17 +3,19 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { getAllRevisions } from '../../../../../../api/revisions' import type { RevisionMetadata } from '../../../../../../api/revisions/types'
import { useApplicationState } from '../../../../../../hooks/common/use-application-state' import { cypressId } from '../../../../../../utils/cypress-attribute'
import { AsyncLoadingBoundary } from '../../../../../common/async-loading-boundary/async-loading-boundary' import { AsyncLoadingBoundary } from '../../../../../common/async-loading-boundary/async-loading-boundary'
import { RevisionListEntry } from './revision-list-entry' import { RevisionListEntry } from './revision-list-entry'
import { DateTime } from 'luxon' import { DateTime } from 'luxon'
import React, { useMemo } from 'react' import React, { useMemo } from 'react'
import { ListGroup } from 'react-bootstrap' import { ListGroup } from 'react-bootstrap'
import { useAsync } from 'react-use'
export interface RevisionListProps { interface RevisionListProps {
selectedRevisionId?: number selectedRevisionId?: number
revisions?: RevisionMetadata[]
loadingRevisions: boolean
error?: Error | boolean
onRevisionSelect: (selectedRevisionId: number) => void onRevisionSelect: (selectedRevisionId: number) => void
} }
@ -22,20 +24,19 @@ export interface RevisionListProps {
* *
* @param selectedRevisionId The currently selected revision * @param selectedRevisionId The currently selected revision
* @param onRevisionSelect Callback that is executed when a list entry is selected * @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 }) => { export const RevisionList: React.FC<RevisionListProps> = ({
const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress) selectedRevisionId,
onRevisionSelect,
const { revisions,
value: revisions, loadingRevisions,
error, error
loading }) => {
} = useAsync(() => {
return getAllRevisions(noteIdentifier)
}, [noteIdentifier])
const revisionList = useMemo(() => { const revisionList = useMemo(() => {
if (loading || !revisions) { if (loadingRevisions || !revisions) {
return null return null
} }
return revisions return revisions
@ -52,11 +53,11 @@ export const RevisionList: React.FC<RevisionListProps> = ({ selectedRevisionId,
key={revisionListEntry.id} key={revisionListEntry.id}
/> />
)) ))
}, [loading, onRevisionSelect, revisions, selectedRevisionId]) }, [loadingRevisions, onRevisionSelect, revisions, selectedRevisionId])
return ( return (
<AsyncLoadingBoundary loading={loading || !revisions} error={error} componentName={'revision list'}> <AsyncLoadingBoundary loading={loadingRevisions || !revisions} error={error} componentName={'revision list'}>
<ListGroup>{revisionList}</ListGroup> <ListGroup {...cypressId('revision.modal.lists')}>{revisionList}</ListGroup>
</AsyncLoadingBoundary> </AsyncLoadingBoundary>
) )
} }

View file

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

View file

@ -5,27 +5,36 @@
*/ */
import { getRevision } from '../../../../../../api/revisions' import { getRevision } from '../../../../../../api/revisions'
import { useApplicationState } from '../../../../../../hooks/common/use-application-state' import { useApplicationState } from '../../../../../../hooks/common/use-application-state'
import { cypressId } from '../../../../../../utils/cypress-attribute'
import type { ModalVisibilityProps } from '../../../../../common/modals/common-modal' import type { ModalVisibilityProps } from '../../../../../common/modals/common-modal'
import { useUiNotifications } from '../../../../../notifications/ui-notification-boundary' import { useUiNotifications } from '../../../../../notifications/ui-notification-boundary'
import type { RevisionModalProps } from './revision-modal'
import { downloadRevision } from './utils' import { downloadRevision } from './utils'
import React, { useCallback } from 'react' import React, { useCallback } from 'react'
import { Button, Modal } from 'react-bootstrap' import { Button, Modal } from 'react-bootstrap'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
export interface RevisionModalFooterProps { interface RevisionModalFooter {
selectedRevisionId?: number 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 * 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. * revert the note content back to that revision.
* *
* @param selectedRevisionId The currently selected revision id or undefined if no revision was selected. * @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 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, selectedRevisionId,
onHide onHide,
onShowDeleteModal,
disableDeleteRevisions
}) => { }) => {
useTranslation() useTranslation()
const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress) const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress)
@ -48,15 +57,35 @@ export const RevisionModalFooter: React.FC<RevisionModalFooterProps & Pick<Modal
.catch(showErrorNotification('')) .catch(showErrorNotification(''))
}, [noteIdentifier, selectedRevisionId, showErrorNotification]) }, [noteIdentifier, selectedRevisionId, showErrorNotification])
const openDeleteModal = useCallback(() => {
onHide?.()
onShowDeleteModal()
}, [onHide, onShowDeleteModal])
return ( return (
<Modal.Footer> <Modal.Footer>
<Button variant='secondary' onClick={onHide}> <Button variant='secondary' onClick={onHide}>
<Trans i18nKey={'common.close'} /> <Trans i18nKey={'common.close'} />
</Button> </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'} /> <Trans i18nKey={'editor.modal.revision.revertButton'} />
</Button> </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'} /> <Trans i18nKey={'editor.modal.revision.download'} />
</Button> </Button>
</Modal.Footer> </Modal.Footer>

View file

@ -5,25 +5,26 @@
*/ */
import type { ModalVisibilityProps } from '../../../../../common/modals/common-modal' import type { ModalVisibilityProps } from '../../../../../common/modals/common-modal'
import { CommonModal } from '../../../../../common/modals/common-modal' import { CommonModal } from '../../../../../common/modals/common-modal'
import { RevisionList } from './revision-list' import { RevisionModalBody } from './revision-modal-body'
import { RevisionModalFooter } from './revision-modal-footer'
import styles from './revision-modal.module.scss' import styles from './revision-modal.module.scss'
import { RevisionViewer } from './revision-viewer' import React from 'react'
import React, { useState } from 'react'
import { Col, Modal, Row } from 'react-bootstrap'
import { ClockHistory as IconClockHistory } from 'react-bootstrap-icons' 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. * Modal that shows the available revisions and allows for comparison between them.
* *
* @param show true to show the modal, false otherwise. * @param show true to show the modal, false otherwise.
* @param onHide Callback that is fired when the modal is requested to close. * @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 }) => { export const RevisionModal: React.FC<RevisionModal> = ({ show, onHide, onShowDeleteModal }) => {
useTranslation()
const [selectedRevisionId, setSelectedRevisionId] = useState<number>()
return ( return (
<CommonModal <CommonModal
show={show} show={show}
@ -33,17 +34,7 @@ export const RevisionModal: React.FC<ModalVisibilityProps> = ({ show, onHide })
showCloseButton={true} showCloseButton={true}
modalSize={'xl'} modalSize={'xl'}
additionalClasses={styles['revision-modal']}> additionalClasses={styles['revision-modal']}>
<Modal.Body> <RevisionModalBody show={show} onShowDeleteModal={onShowDeleteModal} onHide={onHide} />
<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> </CommonModal>
) )
} }