mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-21 17:26:29 -05:00
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:
parent
ebf8e3a759
commit
1c73e99b0a
18 changed files with 163 additions and 158 deletions
|
@ -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"
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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])
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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'))
|
||||||
},
|
},
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -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'
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue