diff --git a/app/Entities/Controllers/PageRevisionController.php b/app/Entities/Controllers/PageRevisionController.php index 9e6a90477..a3190a0fc 100644 --- a/app/Entities/Controllers/PageRevisionController.php +++ b/app/Entities/Controllers/PageRevisionController.php @@ -5,6 +5,7 @@ namespace BookStack\Entities\Controllers; 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 @@ use Ssddanbrown\HtmlDiff\Diff; class PageRevisionController extends Controller { public function __construct( - protected PageRepo $pageRepo + protected PageRepo $pageRepo, + protected RevisionRepo $revisionRepo, ) { } @@ -154,4 +156,15 @@ class PageRevisionController extends Controller 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 92903ed1f..5a148e1a2 100644 --- a/lang/en/entities.php +++ b/lang/en/entities.php @@ -213,6 +213,7 @@ return [ '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 @@ return [ '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 b03fb8c35..23c326f9e 100644 --- a/lang/en/errors.php +++ b/lang/en/errors.php @@ -58,6 +58,7 @@ return [ // 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 e7f4c0ba9..963c21008 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 514bff87d..f66b7921d 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 c29e6de0e..3b438de7c 100644 --- a/resources/views/pages/parts/editor-toolbar.blade.php +++ b/resources/views/pages/parts/editor-toolbar.blade.php @@ -27,13 +27,22 @@ @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 a3a118527..4ed55044b 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 @@

  • {{ 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 468c300ba..74ee74a2c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -106,6 +106,7 @@ Route::middleware('auth')->group(function () { 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 75b1933ea..e99ba9b81 100644 --- a/tests/Entity/PageDraftTest.php +++ b/tests/Entity/PageDraftTest.php @@ -166,6 +166,30 @@ class PageDraftTest extends TestCase ]); } + 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();