]> BookStack Code Mirror - bookstack/commitdiff
Page Drafts: Added new "Delete Draft" action to draft menu
authorDan Brown <redacted>
Tue, 13 Jun 2023 14:13:07 +0000 (15:13 +0100)
committerDan Brown <redacted>
Tue, 13 Jun 2023 14:13:07 +0000 (15:13 +0100)
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.

app/Entities/Controllers/PageRevisionController.php
lang/en/entities.php
lang/en/errors.php
resources/js/components/page-editor.js
resources/js/markdown/actions.js
resources/views/pages/parts/editor-toolbar.blade.php
resources/views/pages/parts/form.blade.php
routes/web.php
tests/Entity/PageDraftTest.php

index 9e6a90477984cf01b1db0398b67512f944f32d2a..a3190a0fc43ee6627ef763afd6ecc1b665e681a1 100644 (file)
@@ -5,6 +5,7 @@ namespace BookStack\Entities\Controllers;
 use BookStack\Activity\ActivityType;
 use BookStack\Entities\Models\PageRevision;
 use BookStack\Entities\Repos\PageRepo;
 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;
 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(
 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'));
     }
 
         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);
+    }
 }
 }
index 92903ed1f3fe1a1dfdf8bc5e5c6f730988a5823a..5a148e1a259640349a07edde0269eda91bcd9049 100644 (file)
@@ -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_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)',
     '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!',
     ],
         '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',
 
     'pages_specific' => 'Specific Page',
     'pages_is_template' => 'Page Template',
 
index b03fb8c355aa7bc9e1b18b95035dbc1b42ea085d..23c326f9e016f3222e81cbb9aab399a5780f1bea 100644 (file)
@@ -58,6 +58,7 @@ return [
 
     // Pages
     'page_draft_autosave_fail' => 'Failed to save draft. Ensure you have internet connection before saving this page',
 
     // 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
     'page_custom_home_deletion' => 'Cannot delete a page while it is set as a homepage',
 
     // Entities
index e7f4c0ba95907345e7ad25b6b0fc2fa3284c8853..963c21008968642314050fbf179be06dd8d48625 100644 (file)
@@ -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.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.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;
 
         // 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
         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));
         // 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));
 
         // 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) {
         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)}`);
             }
 
             this.draftNotifyChange(`${resp.data.message} ${Dates.utcTimeStampToLocalTime(resp.data.timestamp)}`);
@@ -154,7 +161,7 @@ export class PageEditor extends Component {
         }, 2000);
     }
 
         }, 2000);
     }
 
-    async discardDraft() {
+    async discardDraft(notify = true) {
         let response;
         try {
             response = await window.$http.get(`/ajax/page/${this.pageId}`);
         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.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,
         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.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() {
     }
 
     updateChangelogDisplay() {
@@ -191,10 +221,6 @@ export class PageEditor extends Component {
         this.changelogDisplay.innerText = summary;
     }
 
         this.changelogDisplay.innerText = summary;
     }
 
-    toggleDiscardDraftVisibility(show) {
-        this.discardDraftWrap.classList.toggle('hidden', !show);
-    }
-
     async changeEditor(event) {
         event.preventDefault();
 
     async changeEditor(event) {
         event.preventDefault();
 
index 514bff87d86b87379a49a75035958f637394bd39..f66b7921dea70b6951857d5c259592d0a51220ef 100644 (file)
@@ -433,7 +433,9 @@ export class Actions {
      */
     #setText(text, selectionRange = null) {
         selectionRange = selectionRange || this.#getSelectionRange();
      */
     #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();
     }
 
         this.focus();
     }
 
index c29e6de0e3ab22eb5f178cea08a9e8bc660c731c..3b438de7c35e6516d82fda08a14c37a89bca0e6c 100644 (file)
                             </a>
                         </li>
                     @endif
                             </a>
                         </li>
                     @endif
-                    <li refs="page-editor@discardDraftWrap" class="{{ $isDraftRevision ? '' : 'hidden' }}">
-                        <button refs="page-editor@discardDraft" type="button" class="text-neg icon-item">
+                    <li refs="page-editor@discard-draft-wrap" {{ $isDraftRevision ? '' : 'hidden' }}>
+                        <button refs="page-editor@discard-draft" type="button" class="text-warn icon-item">
                             @icon('cancel')
                             <div>{{ trans('entities.pages_edit_discard_draft') }}</div>
                         </button>
                     </li>
                             @icon('cancel')
                             <div>{{ trans('entities.pages_edit_discard_draft') }}</div>
                         </button>
                     </li>
+                    <li refs="page-editor@delete-draft-wrap" {{ $isDraftRevision ? '' : 'hidden' }}>
+                        <button refs="page-editor@delete-draft" type="button" class="text-neg icon-item">
+                            @icon('delete')
+                            <div>{{ trans('entities.pages_edit_delete_draft') }}</div>
+                        </button>
+                    </li>
                     @if(userCan('editor-change'))
                     @if(userCan('editor-change'))
+                        <li>
+                            <hr>
+                        </li>
                         <li>
                             @if($editor === 'wysiwyg')
                                 <a href="{{ $model->getUrl($isDraft ? '' : '/edit') }}?editor=markdown-clean" refs="page-editor@changeEditor" class="icon-item">
                         <li>
                             @if($editor === 'wysiwyg')
                                 <a href="{{ $model->getUrl($isDraft ? '' : '/edit') }}?editor=markdown-clean" refs="page-editor@changeEditor" class="icon-item">
index a3a118527ebbd0352465f31b7ce1e3e919250d5a..4ed55044bc55a866a82dfd0013bd2a05e3c947fe 100644 (file)
@@ -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: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--}}
      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')</button>
 
     {{--Editor Change Dialog--}}
             class="text-link text-button hide-over-m page-save-mobile-button">@icon('save')</button>
 
     {{--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'])
         <p>
             {{ trans('entities.pages_editor_switch_are_you_sure') }}
             <br>
         <p>
             {{ trans('entities.pages_editor_switch_are_you_sure') }}
             <br>
             <li>{{ trans('entities.pages_editor_switch_consideration_c') }}</li>
         </ul>
     @endcomponent
             <li>{{ trans('entities.pages_editor_switch_consideration_c') }}</li>
         </ul>
     @endcomponent
+
+    {{--Delete Draft Dialog--}}
+    @component('common.confirm-dialog', ['title' => trans('entities.pages_edit_delete_draft'), 'ref' => 'page-editor@delete-draft-dialog'])
+        <p>
+            {{ trans('entities.pages_edit_delete_draft_confirm') }}
+        </p>
+    @endcomponent
 </div>
\ No newline at end of file
 </div>
\ No newline at end of file
index 468c300ba190eeafdfef7fdd36a06ea3ca6f9876..74ee74a2c77c39c009cf311b8a5bcd77f5b0a0fe 100644 (file)
@@ -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::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']);
 
     // Chapters
     Route::get('/books/{bookSlug}/chapter/{chapterSlug}/create-page', [EntityControllers\PageController::class, 'create']);
index 75b1933ea0e10d46fcd2e648081183d13b8abebf..e99ba9b8189423f85922e0fe066550f25e467ee5 100644 (file)
@@ -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' => '<p>updated draft again</p>',
+        ]);
+
+        $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();
     public function test_updating_page_draft_with_markdown_retains_markdown_content()
     {
         $book = $this->entities->book();