enhancement(note-deletion): allow to keep uploads

This adds support for keeping the uploads attached to a note when
deleting the same note. This is done by a simple checkbox that can be
clicked in the DeletionModal.

To do this, some parts of the note deletion had to be refactored,
especially in the case of the history page. Both the note deletion and
history removal methods used the same modal, which isn't applicable now
anymore. Additionally, there was a bug that the modal checked for
ownership in the frontend before allowing the note deletion. However, in
the context of the history page, the ownership couldn't be evaluated
since the backend API didn't include that information. This is now fixed
as well.

Signed-off-by: Erik Michelson <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson 2024-09-15 20:39:00 +02:00
parent 3e17edf95d
commit 4878ecba40
No known key found for this signature in database
GPG key ID: DB99ADDDC5C0AF82
18 changed files with 163 additions and 158 deletions

View file

@ -5,7 +5,13 @@
*/ */
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsArray, IsBoolean, IsDate, IsString } from 'class-validator'; import {
IsArray,
IsBoolean,
IsDate,
IsOptional,
IsString,
} from 'class-validator';
import { BaseDto } from '../utils/base.dto.'; import { BaseDto } from '../utils/base.dto.';
@ -26,6 +32,16 @@ export class HistoryEntryDto extends BaseDto {
@ApiProperty() @ApiProperty()
title: string; title: string;
/**
* The username of the owner of the note
* Might be null for anonymous notes
* @example "alice"
*/
@IsOptional()
@IsString()
@ApiProperty()
owner: string | null;
/** /**
* Datestring of the last time this note was updated * Datestring of the last time this note was updated
* @example "2020-12-01 12:23:34" * @example "2020-12-01 12:23:34"

View file

@ -180,6 +180,7 @@ export class HistoryService {
*/ */
async toHistoryEntryDto(entry: HistoryEntry): Promise<HistoryEntryDto> { async toHistoryEntryDto(entry: HistoryEntry): Promise<HistoryEntryDto> {
const note = await entry.note; const note = await entry.note;
const owner = await note.owner;
const revision = await this.revisionsService.getLatestRevision(note); const revision = await this.revisionsService.getLatestRevision(note);
return { return {
identifier: await getIdentifier(entry), identifier: await getIdentifier(entry),
@ -187,6 +188,7 @@ export class HistoryService {
tags: (await revision.tags).map((tag) => tag.name), tags: (await revision.tags).map((tag) => tag.name),
title: revision.title ?? '', title: revision.title ?? '',
pinStatus: entry.pinStatus, pinStatus: entry.pinStatus,
owner: owner ? owner.username : null,
}; };
} }
} }

View file

@ -445,7 +445,9 @@
"title": "Delete note", "title": "Delete note",
"question": "Do you really want to delete this note?", "question": "Do you really want to delete this note?",
"warning": "All users will lose their connection. This process is irreversible.", "warning": "All users will lose their connection. This process is irreversible.",
"button": "Delete note" "deleteButton": "Delete note and uploads",
"deleteButtonKeepMedia": "Delete note but keep uploads",
"keepMedia": "Keep uploads?"
}, },
"permissions": { "permissions": {
"title": "Permissions", "title": "Permissions",

View file

@ -17,6 +17,7 @@ export interface HistoryEntryPutDto {
export interface HistoryEntry { export interface HistoryEntry {
identifier: string identifier: string
title: string title: string
owner: string | null
lastVisitedAt: string lastVisitedAt: string
tags: string[] tags: string[]
pinStatus: boolean pinStatus: boolean

View file

@ -79,14 +79,13 @@ export const createNoteWithPrimaryAlias = async (markdown: string, primaryAlias:
* Deletes the specified note. * Deletes the specified note.
* *
* @param noteIdOrAlias The id or alias of the note to delete. * @param noteIdOrAlias The id or alias of the note to delete.
* @param keepMedia Whether to keep the uploaded media associated with the note.
* @throws {Error} when the api request wasn't successful. * @throws {Error} when the api request wasn't successful.
*/ */
export const deleteNote = async (noteIdOrAlias: string): Promise<void> => { export const deleteNote = async (noteIdOrAlias: string, keepMedia: boolean): Promise<void> => {
await new DeleteApiRequestBuilder<void, NoteDeletionOptions>('notes/' + noteIdOrAlias) await new DeleteApiRequestBuilder<void, NoteDeletionOptions>('notes/' + noteIdOrAlias)
.withJsonBody({ .withJsonBody({
keepMedia: false keepMedia
// TODO Ask whether the user wants to keep the media uploaded to the note.
// https://github.com/hedgedoc/hedgedoc/issues/2928
}) })
.sendRequest() .sendRequest()
} }

View file

@ -15,6 +15,7 @@ export interface DeletionModalProps extends CommonModalProps {
onConfirm: () => void onConfirm: () => void
deletionButtonI18nKey: string deletionButtonI18nKey: string
disabled?: boolean disabled?: boolean
footerContent?: React.ReactNode
} }
/** /**
@ -39,6 +40,7 @@ export const DeletionModal: React.FC<PropsWithChildren<DeletionModalProps>> = ({
titleIcon, titleIcon,
children, children,
disabled = false, disabled = false,
footerContent,
...props ...props
}) => { }) => {
useTranslation() useTranslation()
@ -52,6 +54,7 @@ export const DeletionModal: React.FC<PropsWithChildren<DeletionModalProps>> = ({
{...props}> {...props}>
<Modal.Body>{children}</Modal.Body> <Modal.Body>{children}</Modal.Body>
<Modal.Footer> <Modal.Footer>
{footerContent}
<Button {...cypressId('deletionModal.confirmButton')} variant='danger' onClick={onConfirm} disabled={disabled}> <Button {...cypressId('deletionModal.confirmButton')} variant='danger' onClick={onConfirm} disabled={disabled}>
<Trans i18nKey={deletionButtonI18nKey} /> <Trans i18nKey={deletionButtonI18nKey} />
</Button> </Button>

View file

@ -20,7 +20,7 @@ export const useUpdateLocalHistoryEntry = (): void => {
const userExists = useApplicationState((state) => !!state.user) const userExists = useApplicationState((state) => !!state.user)
const currentNoteTitle = useApplicationState((state) => state.noteDetails?.title ?? '') const currentNoteTitle = useApplicationState((state) => state.noteDetails?.title ?? '')
const currentNoteTags = useApplicationState((state) => state.noteDetails?.frontmatter.tags ?? []) const currentNoteTags = useApplicationState((state) => state.noteDetails?.frontmatter.tags ?? [])
const currentNoteOwner = useApplicationState((state) => state.noteDetails?.permissions.owner)
const lastNoteTitle = useRef('') const lastNoteTitle = useRef('')
const lastNoteTags = useRef<string[]>([]) const lastNoteTags = useRef<string[]>([])
@ -38,7 +38,8 @@ export const useUpdateLocalHistoryEntry = (): void => {
pinStatus: false, pinStatus: false,
lastVisitedAt: '', lastVisitedAt: '',
tags: [], tags: [],
origin: HistoryEntryOrigin.LOCAL origin: HistoryEntryOrigin.LOCAL,
owner: null
} }
if (entry.origin === HistoryEntryOrigin.REMOTE) { if (entry.origin === HistoryEntryOrigin.REMOTE) {
return return
@ -46,9 +47,10 @@ export const useUpdateLocalHistoryEntry = (): void => {
const updatedEntry = { ...entry } const updatedEntry = { ...entry }
updatedEntry.title = currentNoteTitle updatedEntry.title = currentNoteTitle
updatedEntry.tags = currentNoteTags updatedEntry.tags = currentNoteTags
updatedEntry.owner = currentNoteOwner
updatedEntry.lastVisitedAt = new Date().toISOString() updatedEntry.lastVisitedAt = new Date().toISOString()
updateLocalHistoryEntry(id, updatedEntry) updateLocalHistoryEntry(id, updatedEntry)
lastNoteTitle.current = currentNoteTitle lastNoteTitle.current = currentNoteTitle
lastNoteTags.current = currentNoteTags lastNoteTags.current = currentNoteTags
}, [id, userExists, currentNoteTitle, currentNoteTags]) }, [id, userExists, currentNoteTitle, currentNoteTags, currentNoteOwner])
} }

View file

@ -7,20 +7,14 @@ import { useNoteTitle } from '../../../../../hooks/common/use-note-title'
import { cypressId } from '../../../../../utils/cypress-attribute' import { cypressId } from '../../../../../utils/cypress-attribute'
import type { ModalVisibilityProps } from '../../../../common/modals/common-modal' import type { ModalVisibilityProps } from '../../../../common/modals/common-modal'
import { DeletionModal } from '../../../../common/modals/deletion-modal' import { DeletionModal } from '../../../../common/modals/deletion-modal'
import React from 'react' import React, { useCallback, useMemo, useState } from 'react'
import { Trans } from 'react-i18next' import { Trans } from 'react-i18next'
import { useIsOwner } from '../../../../../hooks/common/use-is-owner' import { useIsOwner } from '../../../../../hooks/common/use-is-owner'
export interface DeleteHistoryNoteModalProps {
modalTitleI18nKey?: string
modalQuestionI18nKey?: string
modalWarningI18nKey?: string
modalButtonI18nKey?: string
}
export interface DeleteNoteModalProps extends ModalVisibilityProps { export interface DeleteNoteModalProps extends ModalVisibilityProps {
optionalNoteTitle?: string optionalNoteTitle?: string
onConfirm: () => void onConfirm: (keepMedia: boolean) => void
overrideIsOwner?: boolean
} }
/** /**
@ -31,40 +25,53 @@ export interface DeleteNoteModalProps extends ModalVisibilityProps {
* @param onHide A callback that fires if the modal should be hidden without confirmation * @param onHide A callback that fires if the modal should be hidden without confirmation
* @param onConfirm A callback that fires if the user confirmed the request * @param onConfirm A callback that fires if the user confirmed the request
* @param modalTitleI18nKey optional i18nKey for the title * @param modalTitleI18nKey optional i18nKey for the title
* @param modalQuestionI18nKey optional i18nKey for the question
* @param modalWarningI18nKey optional i18nKey for the warning
* @param modalButtonI18nKey optional i18nKey for the button
*/ */
export const DeleteNoteModal: React.FC<DeleteNoteModalProps & DeleteHistoryNoteModalProps> = ({ export const DeleteNoteModal: React.FC<DeleteNoteModalProps> = ({
optionalNoteTitle, optionalNoteTitle,
show, show,
onHide, onHide,
onConfirm, onConfirm,
modalTitleI18nKey, overrideIsOwner
modalQuestionI18nKey,
modalWarningI18nKey,
modalButtonI18nKey
}) => { }) => {
const [keepMedia, setKeepMedia] = useState(false)
const noteTitle = useNoteTitle() const noteTitle = useNoteTitle()
const isOwner = useIsOwner() const isOwnerOfCurrentEditedNote = useIsOwner()
const deletionButtonI18nKey = useMemo(() => {
return keepMedia ? 'editor.modal.deleteNote.deleteButtonKeepMedia' : 'editor.modal.deleteNote.deleteButton'
}, [keepMedia])
const handleConfirm = useCallback(() => {
onConfirm(keepMedia)
}, [onConfirm, keepMedia])
const isOwner = overrideIsOwner ?? isOwnerOfCurrentEditedNote
return ( return (
<DeletionModal <DeletionModal
{...cypressId('sidebar.deleteNote.modal')} {...cypressId('sidebar.deleteNote.modal')}
onConfirm={onConfirm} onConfirm={handleConfirm}
deletionButtonI18nKey={modalButtonI18nKey ?? 'editor.modal.deleteNote.button'} deletionButtonI18nKey={deletionButtonI18nKey}
show={show} show={show}
onHide={onHide} onHide={onHide}
disabled={!isOwner} disabled={!isOwner}
titleI18nKey={modalTitleI18nKey ?? 'editor.modal.deleteNote.title'}> titleI18nKey={'editor.modal.deleteNote.title'}
footerContent={
<label className={'me-auto'}>
<input type='checkbox' checked={keepMedia} onChange={() => setKeepMedia(!keepMedia)} />
<span className={'ms-1'}>
<Trans i18nKey={'editor.modal.deleteNote.keepMedia'} />
</span>
</label>
}>
<h5> <h5>
<Trans i18nKey={modalQuestionI18nKey ?? 'editor.modal.deleteNote.question'} /> <Trans i18nKey={'editor.modal.deleteNote.question'} />
</h5> </h5>
<ul> <ul>
<li {...cypressId('sidebar.deleteNote.modal.noteTitle')}>{optionalNoteTitle ?? noteTitle}</li> <li {...cypressId('sidebar.deleteNote.modal.noteTitle')}>{optionalNoteTitle ?? noteTitle}</li>
</ul> </ul>
<h6> <h6>
<Trans i18nKey={modalWarningI18nKey ?? 'editor.modal.deleteNote.warning'} /> <Trans i18nKey={'editor.modal.deleteNote.warning'} />
</h6> </h6>
</DeletionModal> </DeletionModal>
) )

View file

@ -32,15 +32,18 @@ export const DeleteNoteSidebarEntry: React.FC<PropsWithChildren<SpecificSidebarE
const [modalVisibility, showModal, closeModal] = useBooleanState() const [modalVisibility, showModal, closeModal] = useBooleanState()
const { showErrorNotification } = useUiNotifications() const { showErrorNotification } = useUiNotifications()
const deleteNoteAndCloseDialog = useCallback(() => { const deleteNoteAndCloseDialog = useCallback(
if (noteId === undefined) { (keepMedia: boolean) => {
return if (noteId === undefined) {
} return
deleteNote(noteId) }
.then(() => router.push('/history')) deleteNote(noteId, keepMedia)
.catch(showErrorNotification('landing.history.error.deleteNote.text')) .then(() => router.push('/history'))
.finally(closeModal) .catch(showErrorNotification('landing.history.error.deleteNote.text'))
}, [closeModal, noteId, router, showErrorNotification]) .finally(closeModal)
},
[closeModal, noteId, router, showErrorNotification]
)
if (!userIsOwner) { if (!userIsOwner) {
return null return null

View file

@ -3,13 +3,18 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { DropdownItemWithDeletionModal } from './dropdown-item-with-deletion-modal' import React, { Fragment } from 'react'
import React from 'react'
import { Trash as IconTrash } from 'react-bootstrap-icons' import { Trash as IconTrash } from 'react-bootstrap-icons'
import { Dropdown } from 'react-bootstrap'
import { UiIcon } from '../../common/icons/ui-icon'
import { Trans } from 'react-i18next'
import { DeleteNoteModal } from '../../editor-page/sidebar/specific-sidebar-entries/delete-note-sidebar-entry/delete-note-modal'
import { useBooleanState } from '../../../hooks/common/use-boolean-state'
export interface DeleteNoteItemProps { export interface DeleteNoteItemProps {
onConfirm: () => void onConfirm: (keepMedia: boolean) => void
noteTitle: string noteTitle: string
isOwner: boolean
} }
/** /**
@ -18,13 +23,21 @@ export interface DeleteNoteItemProps {
* @param noteTitle The title of the note to delete to show it in the deletion confirmation modal * @param noteTitle The title of the note to delete to show it in the deletion confirmation modal
* @param onConfirm The callback that is fired when the deletion is confirmed * @param onConfirm The callback that is fired when the deletion is confirmed
*/ */
export const DeleteNoteItem: React.FC<DeleteNoteItemProps> = ({ noteTitle, onConfirm }) => { export const DeleteNoteItem: React.FC<DeleteNoteItemProps> = ({ noteTitle, onConfirm, isOwner }) => {
const [isModalVisible, showModal, hideModal] = useBooleanState()
return ( return (
<DropdownItemWithDeletionModal <Fragment>
onConfirm={onConfirm} <Dropdown.Item onClick={showModal}>
itemI18nKey={'landing.history.menu.deleteNote'} <UiIcon icon={IconTrash} className='mx-2' />
modalIcon={IconTrash} <Trans i18nKey={'landing.history.menu.deleteNote'} />
noteTitle={noteTitle} </Dropdown.Item>
/> <DeleteNoteModal
optionalNoteTitle={noteTitle}
onConfirm={onConfirm}
show={isModalVisible}
onHide={hideModal}
overrideIsOwner={isOwner}
/>
</Fragment>
) )
} }

View file

@ -1,75 +0,0 @@
/*
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useBooleanState } from '../../../hooks/common/use-boolean-state'
import { UiIcon } from '../../common/icons/ui-icon'
import type { DeleteHistoryNoteModalProps } from '../../editor-page/sidebar/specific-sidebar-entries/delete-note-sidebar-entry/delete-note-modal'
import { DeleteNoteModal } from '../../editor-page/sidebar/specific-sidebar-entries/delete-note-sidebar-entry/delete-note-modal'
import React, { Fragment, useCallback } from 'react'
import { Dropdown } from 'react-bootstrap'
import type { Icon } from 'react-bootstrap-icons'
import { Trans, useTranslation } from 'react-i18next'
export interface DropdownItemWithDeletionModalProps {
onConfirm: () => void
itemI18nKey: string
modalIcon: Icon
noteTitle: string
className?: string
}
/**
* Renders a dropdown item and the corresponding deletion modal.
*
* @param onConfirm A callback that fires if the user confirmed the request
* @param noteTitle The note title to be displayed
* @param modalTitleI18nKey The i18nKey for title to be shown in the modal
* @param modalButtonI18nKey The i18nKey for button to be shown in the modal
* @param itemI18nKey The i18nKey for the dropdown item
* @param modalIcon The icon for the dropdown item
* @param modalQuestionI18nKey The i18nKey for question to be shown in the modal
* @param modalWarningI18nKey The i18nKey for warning to be shown in the modal
* @param className Additional classes given to the dropdown item
*/
export const DropdownItemWithDeletionModal: React.FC<
DropdownItemWithDeletionModalProps & DeleteHistoryNoteModalProps
> = ({
onConfirm,
noteTitle,
modalTitleI18nKey,
modalButtonI18nKey,
itemI18nKey,
modalIcon,
modalQuestionI18nKey,
modalWarningI18nKey,
className
}) => {
useTranslation()
const [modalVisibility, showModal, closeModal] = useBooleanState()
const handleConfirm = useCallback(() => {
closeModal()
onConfirm()
}, [closeModal, onConfirm])
return (
<Fragment>
<Dropdown.Item onClick={showModal} className={className}>
<UiIcon icon={modalIcon} className='mx-2' />
<Trans i18nKey={itemI18nKey} />
</Dropdown.Item>
<DeleteNoteModal
optionalNoteTitle={noteTitle}
onConfirm={handleConfirm}
show={modalVisibility}
onHide={closeModal}
modalTitleI18nKey={modalTitleI18nKey}
modalButtonI18nKey={modalButtonI18nKey}
modalQuestionI18nKey={modalQuestionI18nKey}
modalWarningI18nKey={modalWarningI18nKey}
/>
</Fragment>
)
}

View file

@ -14,13 +14,15 @@ import { Dropdown } from 'react-bootstrap'
import { Cloud as IconCloud, Laptop as IconLaptop, ThreeDots as IconThreeDots } from 'react-bootstrap-icons' import { Cloud as IconCloud, Laptop as IconLaptop, ThreeDots as IconThreeDots } from 'react-bootstrap-icons'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { useIsLoggedIn } from '../../../hooks/common/use-is-logged-in' import { useIsLoggedIn } from '../../../hooks/common/use-is-logged-in'
import { useApplicationState } from '../../../hooks/common/use-application-state'
export interface EntryMenuProps { export interface EntryMenuProps {
id: string id: string
title: string title: string
origin: HistoryEntryOrigin origin: HistoryEntryOrigin
noteOwner: string | null
onRemoveFromHistory: () => void onRemoveFromHistory: () => void
onDeleteNote: () => void onDeleteNote: (keepMedia: boolean) => void
className?: string className?: string
} }
@ -30,6 +32,7 @@ export interface EntryMenuProps {
* @param id The unique identifier of the history entry. * @param id The unique identifier of the history entry.
* @param title The title of the note of the history entry. * @param title The title of the note of the history entry.
* @param origin The origin of the entry. Must be either {@link HistoryEntryOrigin.LOCAL} or {@link HistoryEntryOrigin.REMOTE}. * @param origin The origin of the entry. Must be either {@link HistoryEntryOrigin.LOCAL} or {@link HistoryEntryOrigin.REMOTE}.
* @param noteOwner The username of the note owner.
* @param onRemoveFromHistory Callback that is fired when the entry should be removed from the history. * @param onRemoveFromHistory Callback that is fired when the entry should be removed from the history.
* @param onDeleteNote Callback that is fired when the note should be deleted. * @param onDeleteNote Callback that is fired when the note should be deleted.
* @param className Additional CSS classes to add to the dropdown. * @param className Additional CSS classes to add to the dropdown.
@ -38,12 +41,14 @@ export const EntryMenu: React.FC<EntryMenuProps> = ({
id, id,
title, title,
origin, origin,
noteOwner,
onRemoveFromHistory, onRemoveFromHistory,
onDeleteNote, onDeleteNote,
className className
}) => { }) => {
useTranslation() useTranslation()
const userExists = useIsLoggedIn() const userExists = useIsLoggedIn()
const currentUsername = useApplicationState((state) => state.user?.username)
return ( return (
<Dropdown className={`d-inline-flex ${className || ''}`} {...cypressId('history-entry-menu')}> <Dropdown className={`d-inline-flex ${className || ''}`} {...cypressId('history-entry-menu')}>
@ -75,11 +80,10 @@ export const EntryMenu: React.FC<EntryMenuProps> = ({
<RemoveNoteEntryItem onConfirm={onRemoveFromHistory} noteTitle={title} /> <RemoveNoteEntryItem onConfirm={onRemoveFromHistory} noteTitle={title} />
{/* TODO Check permissions (ownership) before showing option for delete (https://github.com/hedgedoc/hedgedoc/issues/5036)*/} {userExists && currentUsername === noteOwner && (
{userExists && (
<> <>
<Dropdown.Divider /> <Dropdown.Divider />
<DeleteNoteItem onConfirm={onDeleteNote} noteTitle={title} /> <DeleteNoteItem onConfirm={onDeleteNote} noteTitle={title} isOwner={true} />
</> </>
)} )}
</Dropdown.Menu> </Dropdown.Menu>

View file

@ -3,10 +3,13 @@
* *
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { cypressId } from '../../../utils/cypress-attribute' import React, { Fragment } from 'react'
import { DropdownItemWithDeletionModal } from './dropdown-item-with-deletion-modal'
import React from 'react'
import { Archive as IconArchive } from 'react-bootstrap-icons' import { Archive as IconArchive } from 'react-bootstrap-icons'
import { useBooleanState } from '../../../hooks/common/use-boolean-state'
import { Dropdown } from 'react-bootstrap'
import { UiIcon } from '../../common/icons/ui-icon'
import { Trans } from 'react-i18next'
import { DeletionModal } from '../../common/modals/deletion-modal'
export interface RemoveNoteEntryItemProps { export interface RemoveNoteEntryItemProps {
onConfirm: () => void onConfirm: () => void
@ -20,17 +23,29 @@ export interface RemoveNoteEntryItemProps {
* @param onConfirm The callback to delete the note * @param onConfirm The callback to delete the note
*/ */
export const RemoveNoteEntryItem: React.FC<RemoveNoteEntryItemProps> = ({ noteTitle, onConfirm }) => { export const RemoveNoteEntryItem: React.FC<RemoveNoteEntryItemProps> = ({ noteTitle, onConfirm }) => {
const [isModalVisible, showModal, hideModal] = useBooleanState()
return ( return (
<DropdownItemWithDeletionModal <Fragment>
onConfirm={onConfirm} <Dropdown.Item onClick={showModal}>
itemI18nKey={'landing.history.menu.removeEntry'} <UiIcon icon={IconArchive} className='mx-2' />
modalButtonI18nKey={'landing.history.modal.removeNote.button'} <Trans i18nKey={'landing.history.menu.removeEntry'} />
modalIcon={IconArchive} </Dropdown.Item>
modalTitleI18nKey={'landing.history.modal.removeNote.title'} <DeletionModal
modalQuestionI18nKey={'landing.history.modal.removeNote.question'} deletionButtonI18nKey={'landing.history.modal.removeNote.button'}
modalWarningI18nKey={'landing.history.modal.removeNote.warning'} onConfirm={onConfirm}
noteTitle={noteTitle} show={isModalVisible}
{...cypressId('history-entry-menu-remove-button')} onHide={hideModal}
/> titleI18nKey={'landing.history.modal.removeNote.title'}>
<h5>
<Trans i18nKey={'landing.history.modal.removeNote.question'} />
</h5>
<ul>
<li>{noteTitle}</li>
</ul>
<h6>
<Trans i18nKey={'landing.history.modal.removeNote.warning'} />
</h6>
</DeletionModal>
</Fragment>
) )
} }

View file

@ -36,9 +36,12 @@ export const HistoryCard: React.FC<HistoryEntryProps & HistoryEventHandlers> = (
onRemoveEntryClick(entry.identifier) onRemoveEntryClick(entry.identifier)
}, [onRemoveEntryClick, entry.identifier]) }, [onRemoveEntryClick, entry.identifier])
const onDeleteNote = useCallback(() => { const onDeleteNote = useCallback(
onDeleteNoteClick(entry.identifier) (keepMedia: boolean) => {
}, [onDeleteNoteClick, entry.identifier]) onDeleteNoteClick(entry.identifier, keepMedia)
},
[onDeleteNoteClick, entry.identifier]
)
const onPinEntry = useCallback(() => { const onPinEntry = useCallback(() => {
onPinClick(entry.identifier) onPinClick(entry.identifier)
@ -93,6 +96,7 @@ export const HistoryCard: React.FC<HistoryEntryProps & HistoryEventHandlers> = (
origin={entry.origin} origin={entry.origin}
onRemoveFromHistory={onRemoveEntry} onRemoveFromHistory={onRemoveEntry}
onDeleteNote={onDeleteNote} onDeleteNote={onDeleteNote}
noteOwner={entry.owner}
/> />
</div> </div>
</Card.Body> </Card.Body>

View file

@ -23,7 +23,7 @@ type OnEntryClick = (entryId: string) => void
export interface HistoryEventHandlers { export interface HistoryEventHandlers {
onPinClick: OnEntryClick onPinClick: OnEntryClick
onRemoveEntryClick: OnEntryClick onRemoveEntryClick: OnEntryClick
onDeleteNoteClick: OnEntryClick onDeleteNoteClick: (entryId: string, keepMedia: boolean) => void
} }
export interface HistoryEntryProps { export interface HistoryEntryProps {
@ -61,8 +61,8 @@ export const HistoryContent: React.FC = () => {
) )
const onDeleteClick = useCallback( const onDeleteClick = useCallback(
(noteId: string) => { (noteId: string, keepMedia: boolean) => {
deleteNote(noteId) deleteNote(noteId, keepMedia)
.then(() => removeHistoryEntry(noteId)) .then(() => removeHistoryEntry(noteId))
.catch(showErrorNotification('landing.history.error.deleteNote.text')) .catch(showErrorNotification('landing.history.error.deleteNote.text'))
}, },

View file

@ -37,9 +37,12 @@ export const HistoryTableRow: React.FC<HistoryEntryProps & HistoryEventHandlers>
onRemoveEntryClick(entry.identifier) onRemoveEntryClick(entry.identifier)
}, [onRemoveEntryClick, entry.identifier]) }, [onRemoveEntryClick, entry.identifier])
const onDeleteNote = useCallback(() => { const onDeleteNote = useCallback(
onDeleteNoteClick(entry.identifier) (keepMedia: boolean) => {
}, [onDeleteNoteClick, entry.identifier]) onDeleteNoteClick(entry.identifier, keepMedia)
},
[onDeleteNoteClick, entry.identifier]
)
return ( return (
<tr {...cypressAttribute('entry-title', entryTitle)}> <tr {...cypressAttribute('entry-title', entryTitle)}>
@ -63,6 +66,7 @@ export const HistoryTableRow: React.FC<HistoryEntryProps & HistoryEventHandlers>
id={entry.identifier} id={entry.identifier}
title={entryTitle} title={entryTitle}
origin={entry.origin} origin={entry.origin}
noteOwner={entry.owner}
onRemoveFromHistory={onEntryRemove} onRemoveFromHistory={onEntryRemove}
onDeleteNote={onDeleteNote} onDeleteNote={onDeleteNote}
/> />

View file

@ -14,28 +14,32 @@ const handler = (req: NextApiRequest, res: NextApiResponse) => {
title: 'Slide example', title: 'Slide example',
lastVisitedAt: '2020-05-30T15:20:36.088Z', lastVisitedAt: '2020-05-30T15:20:36.088Z',
pinStatus: true, pinStatus: true,
tags: ['features', 'cool', 'updated'] tags: ['features', 'cool', 'updated'],
owner: null
}, },
{ {
identifier: 'features', identifier: 'features',
title: 'Features', title: 'Features',
lastVisitedAt: '2020-05-31T15:20:36.088Z', lastVisitedAt: '2020-05-31T15:20:36.088Z',
pinStatus: true, pinStatus: true,
tags: ['features', 'cool', 'updated'] tags: ['features', 'cool', 'updated'],
owner: null
}, },
{ {
identifier: 'ODakLc2MQkyyFc_Xmb53sg', identifier: 'ODakLc2MQkyyFc_Xmb53sg',
title: 'Non existent', title: 'Non existent',
lastVisitedAt: '2020-05-25T19:48:14.025Z', lastVisitedAt: '2020-05-25T19:48:14.025Z',
pinStatus: false, pinStatus: false,
tags: [] tags: [],
owner: null
}, },
{ {
identifier: 'l8JuWxApTR6Fqa0LCrpnLg', identifier: 'l8JuWxApTR6Fqa0LCrpnLg',
title: 'Non existent', title: 'Non existent',
lastVisitedAt: '2020-05-24T16:04:36.433Z', lastVisitedAt: '2020-05-24T16:04:36.433Z',
pinStatus: false, pinStatus: false,
tags: ['agenda', 'HedgeDoc community', 'community call'] tags: ['agenda', 'HedgeDoc community', 'community call'],
owner: 'test'
} }
]) ])
} }

View file

@ -151,7 +151,8 @@ export const convertV1History = (oldHistory: V1HistoryEntry[]): HistoryEntryWith
tags: entry.tags, tags: entry.tags,
lastVisitedAt: DateTime.fromMillis(entry.time).toISO(), lastVisitedAt: DateTime.fromMillis(entry.time).toISO(),
pinStatus: entry.pinned, pinStatus: entry.pinned,
origin: HistoryEntryOrigin.LOCAL origin: HistoryEntryOrigin.LOCAL,
owner: null
})) }))
} }