From a948493410723644182cced541588d8b6a757f1b Mon Sep 17 00:00:00 2001 From: Avinash Date: Thu, 29 Jun 2023 19:26:36 +0530 Subject: [PATCH] feat(frontend): delete revision Signed-off-by: Avinash --- frontend/cypress/e2e/revision.spec.ts | 96 +++++++++++++++++++ frontend/locales/en.json | 7 ++ .../revision-sidebar-entry.tsx | 20 +++- .../revisions-modal/delete-revision-modal.tsx | 49 ++++++++++ .../revisions-modal/revision-list.tsx | 39 ++++---- .../revisions-modal/revision-modal-body.tsx | 61 ++++++++++++ .../revisions-modal/revision-modal-footer.tsx | 39 +++++++- .../revisions-modal/revision-modal.tsx | 33 +++---- 8 files changed, 296 insertions(+), 48 deletions(-) create mode 100644 frontend/cypress/e2e/revision.spec.ts create mode 100644 frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/delete-revision-modal.tsx create mode 100644 frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-modal-body.tsx diff --git a/frontend/cypress/e2e/revision.spec.ts b/frontend/cypress/e2e/revision.spec.ts new file mode 100644 index 000000000..12eb76106 --- /dev/null +++ b/frontend/cypress/e2e/revision.spec.ts @@ -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) + }) +}) diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 68946a968..b9b83597a 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -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ā€¦" diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revision-sidebar-entry.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revision-sidebar-entry.tsx index 82c360964..fa84ed47f 100644 --- a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revision-sidebar-entry.tsx +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revision-sidebar-entry.tsx @@ -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 = ({ className, hide }) => { const [modalVisibility, showModal, closeModal] = useBooleanState() + const [revisionsDeleteModalVisibility, showRevisionsDeleteModal, closeRevisionsDeleteModal] = useBooleanState() + + const onDeleteRevisions = useCallback(() => { + closeRevisionsDeleteModal() + showModal() + }, [closeRevisionsDeleteModal, showModal]) return ( - + - + + ) } diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/delete-revision-modal.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/delete-revision-modal.tsx new file mode 100644 index 000000000..20aca9484 --- /dev/null +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/delete-revision-modal.tsx @@ -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 = ({ 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 ( + + +
+ +
+
+ + + +
+ ) +} diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-list.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-list.tsx index f3ccf27a4..3d8b01c34 100644 --- a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-list.tsx +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-list.tsx @@ -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 = ({ selectedRevisionId, onRevisionSelect }) => { - const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress) - - const { - value: revisions, - error, - loading - } = useAsync(() => { - return getAllRevisions(noteIdentifier) - }, [noteIdentifier]) - +export const RevisionList: React.FC = ({ + 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 = ({ selectedRevisionId, key={revisionListEntry.id} /> )) - }, [loading, onRevisionSelect, revisions, selectedRevisionId]) + }, [loadingRevisions, onRevisionSelect, revisions, selectedRevisionId]) return ( - - {revisionList} + + {revisionList} ) } diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-modal-body.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-modal-body.tsx new file mode 100644 index 000000000..d9cf78b24 --- /dev/null +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-modal-body.tsx @@ -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() + 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 ( + + + + + + + + + + + + + + ) +} diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-modal-footer.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-modal-footer.tsx index 20b854f69..d4f69c0be 100644 --- a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-modal-footer.tsx +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-modal-footer.tsx @@ -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 + /** * 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> = ({ +export const RevisionModalFooter: React.FC = ({ selectedRevisionId, - onHide + onHide, + onShowDeleteModal, + disableDeleteRevisions }) => { useTranslation() const noteIdentifier = useApplicationState((state) => state.noteDetails.primaryAddress) @@ -48,15 +57,35 @@ export const RevisionModalFooter: React.FC { + onHide?.() + onShowDeleteModal() + }, [onHide, onShowDeleteModal]) + return ( - + - diff --git a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-modal.tsx b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-modal.tsx index 57307f927..2fd6bb6d8 100644 --- a/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-modal.tsx +++ b/frontend/src/components/editor-page/sidebar/specific-sidebar-entries/revisions-sidebar-entry/revisions-modal/revision-modal.tsx @@ -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 = ({ show, onHide }) => { - useTranslation() - const [selectedRevisionId, setSelectedRevisionId] = useState() - +export const RevisionModal: React.FC = ({ show, onHide, onShowDeleteModal }) => { return ( = ({ show, onHide }) showCloseButton={true} modalSize={'xl'} additionalClasses={styles['revision-modal']}> - - - - - - - - - - - + ) }