From b01bbf9c8900ee2cc182f47d34aaa682bf1aa244 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 13 Jun 2023 15:13:07 +0100 Subject: [PATCH] Page Drafts: Added new "Delete Draft" action to draft menu Provides a way for users to actually delte their user drafts where required. For #3927 Added test to cover new endpoint. Makes update to MD editor #setText so that new selection is within new range, otherwise it errors and fails operation. --- .../Controllers/PageRevisionController.php | 15 ++++++- lang/en/entities.php | 4 +- lang/en/errors.php | 1 + resources/js/components/page-editor.js | 42 +++++++++++++++---- resources/js/markdown/actions.js | 4 +- .../pages/parts/editor-toolbar.blade.php | 13 +++++- resources/views/pages/parts/form.blade.php | 11 ++++- routes/web.php | 1 + tests/Entity/PageDraftTest.php | 24 +++++++++++ 9 files changed, 101 insertions(+), 14 deletions(-) diff --git a/app/Entities/Controllers/PageRevisionController.php b/app/Entities/Controllers/PageRevisionController.php index 9e6a9047798..a3190a0fc43 100644 --- a/app/Entities/Controllers/PageRevisionController.php +++ b/app/Entities/Controllers/PageRevisionController.php @@ -5,6 +5,7 @@ use BookStack\Activity\ActivityType; use BookStack\Entities\Models\PageRevision; use BookStack\Entities\Repos\PageRepo; +use BookStack\Entities\Repos\RevisionRepo; use BookStack\Entities\Tools\PageContent; use BookStack\Exceptions\NotFoundException; use BookStack\Facades\Activity; @@ -16,7 +17,8 @@ class PageRevisionController extends Controller { public function __construct( - protected PageRepo $pageRepo + protected PageRepo $pageRepo, + protected RevisionRepo $revisionRepo, ) { } @@ -154,4 +156,15 @@ public function destroy(string $bookSlug, string $pageSlug, int $revId) return redirect($page->getUrl('/revisions')); } + + /** + * Destroys existing drafts, belonging to the current user, for the given page. + */ + public function destroyUserDraft(string $pageId) + { + $page = $this->pageRepo->getById($pageId); + $this->revisionRepo->deleteDraftsForCurrentUser($page); + + return response('', 200); + } } diff --git a/lang/en/entities.php b/lang/en/entities.php index 92903ed1f3f..5a148e1a259 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -213,6 +213,7 @@ 'pages_editing_page' => 'Editing Page', 'pages_edit_draft_save_at' => 'Draft saved at ', 'pages_edit_delete_draft' => 'Delete Draft', + 'pages_edit_delete_draft_confirm' => 'Are you sure you want to delete your draft page changes? All of your changes, since the last full save, will be lost and the editor will be updated with the latest page non-draft save state.', 'pages_edit_discard_draft' => 'Discard Draft', 'pages_edit_switch_to_markdown' => 'Switch to Markdown Editor', 'pages_edit_switch_to_markdown_clean' => '(Clean Content)', @@ -285,7 +286,8 @@ 'time_b' => 'in the last :minCount minutes', 'message' => ':start :time. Take care not to overwrite each other\'s updates!', ], - 'pages_draft_discarded' => 'Draft discarded, The editor has been updated with the current page content', + 'pages_draft_discarded' => 'Draft discarded! The editor has been updated with the current page content', + 'pages_draft_deleted' => 'Draft deleted! The editor has been updated with the current page content', 'pages_specific' => 'Specific Page', 'pages_is_template' => 'Page Template', diff --git a/lang/en/errors.php b/lang/en/errors.php index b03fb8c355a..23c326f9e01 100644 --- a/lang/en/errors.php +++ b/lang/en/errors.php @@ -58,6 +58,7 @@ // Pages 'page_draft_autosave_fail' => 'Failed to save draft. Ensure you have internet connection before saving this page', + 'page_draft_delete_fail' => 'Failed to delete page draft and fetch current page saved content', 'page_custom_home_deletion' => 'Cannot delete a page while it is set as a homepage', // Entities diff --git a/resources/js/components/page-editor.js b/resources/js/components/page-editor.js index e7f4c0ba959..963c2100896 100644 --- a/resources/js/components/page-editor.js +++ b/resources/js/components/page-editor.js @@ -19,18 +19,23 @@ export class PageEditor extends Component { this.saveDraftButton = this.$refs.saveDraft; this.discardDraftButton = this.$refs.discardDraft; this.discardDraftWrap = this.$refs.discardDraftWrap; + this.deleteDraftButton = this.$refs.deleteDraft; + this.deleteDraftWrap = this.$refs.deleteDraftWrap; this.draftDisplay = this.$refs.draftDisplay; this.draftDisplayIcon = this.$refs.draftDisplayIcon; this.changelogInput = this.$refs.changelogInput; this.changelogDisplay = this.$refs.changelogDisplay; this.changeEditorButtons = this.$manyRefs.changeEditor || []; this.switchDialogContainer = this.$refs.switchDialog; + this.deleteDraftDialogContainer = this.$refs.deleteDraftDialog; // Translations this.draftText = this.$opts.draftText; this.autosaveFailText = this.$opts.autosaveFailText; this.editingPageText = this.$opts.editingPageText; this.draftDiscardedText = this.$opts.draftDiscardedText; + this.draftDeleteText = this.$opts.draftDeleteText; + this.draftDeleteFailText = this.$opts.draftDeleteFailText; this.setChangelogText = this.$opts.setChangelogText; // State data @@ -75,6 +80,7 @@ export class PageEditor extends Component { // Draft Controls onSelect(this.saveDraftButton, this.saveDraft.bind(this)); onSelect(this.discardDraftButton, this.discardDraft.bind(this)); + onSelect(this.deleteDraftButton, this.deleteDraft.bind(this)); // Change editor controls onSelect(this.changeEditorButtons, this.changeEditor.bind(this)); @@ -119,7 +125,8 @@ export class PageEditor extends Component { try { const resp = await window.$http.put(`/ajax/page/${this.pageId}/save-draft`, data); if (!this.isNewDraft) { - this.toggleDiscardDraftVisibility(true); + this.discardDraftWrap.toggleAttribute('hidden', false); + this.deleteDraftWrap.toggleAttribute('hidden', false); } this.draftNotifyChange(`${resp.data.message} ${Dates.utcTimeStampToLocalTime(resp.data.timestamp)}`); @@ -154,7 +161,7 @@ export class PageEditor extends Component { }, 2000); } - async discardDraft() { + async discardDraft(notify = true) { let response; try { response = await window.$http.get(`/ajax/page/${this.pageId}`); @@ -168,7 +175,7 @@ export class PageEditor extends Component { } this.draftDisplay.innerText = this.editingPageText; - this.toggleDiscardDraftVisibility(false); + this.discardDraftWrap.toggleAttribute('hidden', true); window.$events.emit('editor::replace', { html: response.data.html, markdown: response.data.markdown, @@ -178,7 +185,30 @@ export class PageEditor extends Component { window.setTimeout(() => { this.startAutoSave(); }, 1000); - window.$events.emit('success', this.draftDiscardedText); + + if (notify) { + window.$events.success(this.draftDiscardedText); + } + } + + async deleteDraft() { + /** @var {ConfirmDialog} * */ + const dialog = window.$components.firstOnElement(this.deleteDraftDialogContainer, 'confirm-dialog'); + const confirmed = await dialog.show(); + if (!confirmed) { + return; + } + + try { + const discard = this.discardDraft(false); + const draftDelete = window.$http.delete(`/page-revisions/user-drafts/${this.pageId}`); + await Promise.all([discard, draftDelete]); + window.$events.success(this.draftDeleteText); + this.deleteDraftWrap.toggleAttribute('hidden', true); + } catch (err) { + console.error(err); + window.$events.error(this.draftDeleteFailText); + } } updateChangelogDisplay() { @@ -191,10 +221,6 @@ export class PageEditor extends Component { this.changelogDisplay.innerText = summary; } - toggleDiscardDraftVisibility(show) { - this.discardDraftWrap.classList.toggle('hidden', !show); - } - async changeEditor(event) { event.preventDefault(); diff --git a/resources/js/markdown/actions.js b/resources/js/markdown/actions.js index 514bff87d86..f66b7921dea 100644 --- a/resources/js/markdown/actions.js +++ b/resources/js/markdown/actions.js @@ -433,7 +433,9 @@ export class Actions { */ #setText(text, selectionRange = null) { selectionRange = selectionRange || this.#getSelectionRange(); - this.#dispatchChange(0, this.editor.cm.state.doc.length, text, selectionRange.from); + const newDoc = this.editor.cm.state.toText(text); + const newSelectFrom = Math.min(selectionRange.from, newDoc.length); + this.#dispatchChange(0, this.editor.cm.state.doc.length, text, newSelectFrom); this.focus(); } diff --git a/resources/views/pages/parts/editor-toolbar.blade.php b/resources/views/pages/parts/editor-toolbar.blade.php index c29e6de0e3a..3b438de7c35 100644 --- a/resources/views/pages/parts/editor-toolbar.blade.php +++ b/resources/views/pages/parts/editor-toolbar.blade.php @@ -27,13 +27,22 @@ class="dropdown-container draft-display text {{ $draftsEnabled ? '' : 'hidden' } @endif -
  • -
  • +
  • + +
  • @if(userCan('editor-change')) +
  • +
    +
  • @if($editor === 'wysiwyg') diff --git a/resources/views/pages/parts/form.blade.php b/resources/views/pages/parts/form.blade.php index a3a118527eb..4ed55044bc5 100644 --- a/resources/views/pages/parts/form.blade.php +++ b/resources/views/pages/parts/form.blade.php @@ -13,6 +13,8 @@ option:page-editor:autosave-fail-text="{{ trans('errors.page_draft_autosave_fail') }}" option:page-editor:editing-page-text="{{ trans('entities.pages_editing_page') }}" option:page-editor:draft-discarded-text="{{ trans('entities.pages_draft_discarded') }}" + option:page-editor:draft-delete-text="{{ trans('entities.pages_draft_deleted') }}" + option:page-editor:draft-delete-fail-text="{{ trans('errors.page_draft_delete_fail') }}" option:page-editor:set-changelog-text="{{ trans('entities.pages_edit_set_changelog') }}"> {{--Header Toolbar--}} @@ -47,7 +49,7 @@ class="text-link text-button hide-over-m page-save-mobile-button">@icon('save') {{--Editor Change Dialog--}} - @component('common.confirm-dialog', ['title' => trans('entities.pages_editor_switch_title'), 'ref' => 'page-editor@switchDialog']) + @component('common.confirm-dialog', ['title' => trans('entities.pages_editor_switch_title'), 'ref' => 'page-editor@switch-dialog'])

    {{ trans('entities.pages_editor_switch_are_you_sure') }}
    @@ -60,4 +62,11 @@ class="text-link text-button hide-over-m page-save-mobile-button">@icon('save')<

  • {{ trans('entities.pages_editor_switch_consideration_c') }}
  • @endcomponent + + {{--Delete Draft Dialog--}} + @component('common.confirm-dialog', ['title' => trans('entities.pages_edit_delete_draft'), 'ref' => 'page-editor@delete-draft-dialog']) +

    + {{ trans('entities.pages_edit_delete_draft_confirm') }} +

    + @endcomponent \ No newline at end of file diff --git a/routes/web.php b/routes/web.php index 468c300ba19..74ee74a2c77 100644 --- a/routes/web.php +++ b/routes/web.php @@ -106,6 +106,7 @@ Route::get('/books/{bookSlug}/page/{pageSlug}/revisions/{revId}/changes', [EntityControllers\PageRevisionController::class, 'changes']); Route::put('/books/{bookSlug}/page/{pageSlug}/revisions/{revId}/restore', [EntityControllers\PageRevisionController::class, 'restore']); Route::delete('/books/{bookSlug}/page/{pageSlug}/revisions/{revId}/delete', [EntityControllers\PageRevisionController::class, 'destroy']); + Route::delete('/page-revisions/user-drafts/{pageId}', [EntityControllers\PageRevisionController::class, 'destroyUserDraft']); // Chapters Route::get('/books/{bookSlug}/chapter/{chapterSlug}/create-page', [EntityControllers\PageController::class, 'create']); diff --git a/tests/Entity/PageDraftTest.php b/tests/Entity/PageDraftTest.php index 75b1933ea0e..e99ba9b8189 100644 --- a/tests/Entity/PageDraftTest.php +++ b/tests/Entity/PageDraftTest.php @@ -166,6 +166,30 @@ public function test_page_html_in_ajax_fetch_response() ]); } + public function test_user_draft_removed_on_user_drafts_delete_call() + { + $editor = $this->users->editor(); + $page = $this->entities->page(); + + $this->actingAs($editor)->put('/ajax/page/' . $page->id . '/save-draft', [ + 'name' => $page->name, + 'html' => '

    updated draft again

    ', + ]); + + $revisionData = [ + 'type' => 'update_draft', + 'created_by' => $editor->id, + 'page_id' => $page->id, + ]; + + $this->assertDatabaseHas('page_revisions', $revisionData); + + $resp = $this->delete("/page-revisions/user-drafts/{$page->id}"); + + $resp->assertOk(); + $this->assertDatabaseMissing('page_revisions', $revisionData); + } + public function test_updating_page_draft_with_markdown_retains_markdown_content() { $book = $this->entities->book();