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.
This commit is contained in:
Dan Brown 2023-06-13 15:13:07 +01:00
parent f39938c4e3
commit b01bbf9c89
No known key found for this signature in database
GPG Key ID: 46D9F943C24A2EF9
9 changed files with 101 additions and 14 deletions

View File

@ -5,6 +5,7 @@ namespace BookStack\Entities\Controllers;
use BookStack\Activity\ActivityType; use BookStack\Activity\ActivityType;
use BookStack\Entities\Models\PageRevision; use BookStack\Entities\Models\PageRevision;
use BookStack\Entities\Repos\PageRepo; use BookStack\Entities\Repos\PageRepo;
use BookStack\Entities\Repos\RevisionRepo;
use BookStack\Entities\Tools\PageContent; use BookStack\Entities\Tools\PageContent;
use BookStack\Exceptions\NotFoundException; use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity; use BookStack\Facades\Activity;
@ -16,7 +17,8 @@ use Ssddanbrown\HtmlDiff\Diff;
class PageRevisionController extends Controller class PageRevisionController extends Controller
{ {
public function __construct( 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);
}
} }

View File

@ -213,6 +213,7 @@ return [
'pages_editing_page' => 'Editing Page', 'pages_editing_page' => 'Editing Page',
'pages_edit_draft_save_at' => 'Draft saved at ', 'pages_edit_draft_save_at' => 'Draft saved at ',
'pages_edit_delete_draft' => 'Delete Draft', '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_discard_draft' => 'Discard Draft',
'pages_edit_switch_to_markdown' => 'Switch to Markdown Editor', 'pages_edit_switch_to_markdown' => 'Switch to Markdown Editor',
'pages_edit_switch_to_markdown_clean' => '(Clean Content)', 'pages_edit_switch_to_markdown_clean' => '(Clean Content)',
@ -285,7 +286,8 @@ return [
'time_b' => 'in the last :minCount minutes', 'time_b' => 'in the last :minCount minutes',
'message' => ':start :time. Take care not to overwrite each other\'s updates!', '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_specific' => 'Specific Page',
'pages_is_template' => 'Page Template', 'pages_is_template' => 'Page Template',

View File

@ -58,6 +58,7 @@ return [
// Pages // Pages
'page_draft_autosave_fail' => 'Failed to save draft. Ensure you have internet connection before saving this page', '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', 'page_custom_home_deletion' => 'Cannot delete a page while it is set as a homepage',
// Entities // Entities

View File

@ -19,18 +19,23 @@ export class PageEditor extends Component {
this.saveDraftButton = this.$refs.saveDraft; this.saveDraftButton = this.$refs.saveDraft;
this.discardDraftButton = this.$refs.discardDraft; this.discardDraftButton = this.$refs.discardDraft;
this.discardDraftWrap = this.$refs.discardDraftWrap; this.discardDraftWrap = this.$refs.discardDraftWrap;
this.deleteDraftButton = this.$refs.deleteDraft;
this.deleteDraftWrap = this.$refs.deleteDraftWrap;
this.draftDisplay = this.$refs.draftDisplay; this.draftDisplay = this.$refs.draftDisplay;
this.draftDisplayIcon = this.$refs.draftDisplayIcon; this.draftDisplayIcon = this.$refs.draftDisplayIcon;
this.changelogInput = this.$refs.changelogInput; this.changelogInput = this.$refs.changelogInput;
this.changelogDisplay = this.$refs.changelogDisplay; this.changelogDisplay = this.$refs.changelogDisplay;
this.changeEditorButtons = this.$manyRefs.changeEditor || []; this.changeEditorButtons = this.$manyRefs.changeEditor || [];
this.switchDialogContainer = this.$refs.switchDialog; this.switchDialogContainer = this.$refs.switchDialog;
this.deleteDraftDialogContainer = this.$refs.deleteDraftDialog;
// Translations // Translations
this.draftText = this.$opts.draftText; this.draftText = this.$opts.draftText;
this.autosaveFailText = this.$opts.autosaveFailText; this.autosaveFailText = this.$opts.autosaveFailText;
this.editingPageText = this.$opts.editingPageText; this.editingPageText = this.$opts.editingPageText;
this.draftDiscardedText = this.$opts.draftDiscardedText; this.draftDiscardedText = this.$opts.draftDiscardedText;
this.draftDeleteText = this.$opts.draftDeleteText;
this.draftDeleteFailText = this.$opts.draftDeleteFailText;
this.setChangelogText = this.$opts.setChangelogText; this.setChangelogText = this.$opts.setChangelogText;
// State data // State data
@ -75,6 +80,7 @@ export class PageEditor extends Component {
// Draft Controls // Draft Controls
onSelect(this.saveDraftButton, this.saveDraft.bind(this)); onSelect(this.saveDraftButton, this.saveDraft.bind(this));
onSelect(this.discardDraftButton, this.discardDraft.bind(this)); onSelect(this.discardDraftButton, this.discardDraft.bind(this));
onSelect(this.deleteDraftButton, this.deleteDraft.bind(this));
// Change editor controls // Change editor controls
onSelect(this.changeEditorButtons, this.changeEditor.bind(this)); onSelect(this.changeEditorButtons, this.changeEditor.bind(this));
@ -119,7 +125,8 @@ export class PageEditor extends Component {
try { try {
const resp = await window.$http.put(`/ajax/page/${this.pageId}/save-draft`, data); const resp = await window.$http.put(`/ajax/page/${this.pageId}/save-draft`, data);
if (!this.isNewDraft) { 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; let response;
try { try {
response = await window.$http.get(`/ajax/page/${this.pageId}`); 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', { window.$events.emit('editor::replace', {
html: response.data.html, html: response.data.html,
markdown: response.data.markdown, markdown: response.data.markdown,
@ -178,7 +185,30 @@ export class PageEditor extends Component {
window.setTimeout(() => { window.setTimeout(() => {
this.startAutoSave(); this.startAutoSave();
}, 1000); }, 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) { async changeEditor(event) {
event.preventDefault(); event.preventDefault();

View File

@ -433,7 +433,9 @@ export class Actions {
*/ */
#setText(text, selectionRange = null) { #setText(text, selectionRange = null) {
selectionRange = selectionRange || this.#getSelectionRange(); 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();
} }

View File

@ -27,13 +27,22 @@
</a> </a>
</li> </li>
@endif @endif
<li refs="page-editor@discardDraftWrap" class="{{ $isDraftRevision ? '' : 'hidden' }}"> <li refs="page-editor@discard-draft-wrap" {{ $isDraftRevision ? '' : 'hidden' }}>
<button refs="page-editor@discardDraft" type="button" class="text-neg icon-item"> <button refs="page-editor@discard-draft" type="button" class="text-warn icon-item">
@icon('cancel') @icon('cancel')
<div>{{ trans('entities.pages_edit_discard_draft') }}</div> <div>{{ trans('entities.pages_edit_discard_draft') }}</div>
</button> </button>
</li> </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> <li>
@if($editor === 'wysiwyg') @if($editor === 'wysiwyg')
<a href="{{ $model->getUrl($isDraft ? '' : '/edit') }}?editor=markdown-clean" refs="page-editor@changeEditor" class="icon-item"> <a href="{{ $model->getUrl($isDraft ? '' : '/edit') }}?editor=markdown-clean" refs="page-editor@changeEditor" class="icon-item">

View File

@ -13,6 +13,8 @@
option:page-editor:autosave-fail-text="{{ trans('errors.page_draft_autosave_fail') }}" 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:editing-page-text="{{ trans('entities.pages_editing_page') }}"
option:page-editor:draft-discarded-text="{{ trans('entities.pages_draft_discarded') }}" 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') }}"> option:page-editor:set-changelog-text="{{ trans('entities.pages_edit_set_changelog') }}">
{{--Header Toolbar--}} {{--Header Toolbar--}}
@ -47,7 +49,7 @@
class="text-link text-button hide-over-m page-save-mobile-button">@icon('save')</button> class="text-link text-button hide-over-m page-save-mobile-button">@icon('save')</button>
{{--Editor Change Dialog--}} {{--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> <p>
{{ trans('entities.pages_editor_switch_are_you_sure') }} {{ trans('entities.pages_editor_switch_are_you_sure') }}
<br> <br>
@ -60,4 +62,11 @@
<li>{{ trans('entities.pages_editor_switch_consideration_c') }}</li> <li>{{ trans('entities.pages_editor_switch_consideration_c') }}</li>
</ul> </ul>
@endcomponent @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> </div>

View File

@ -106,6 +106,7 @@ Route::middleware('auth')->group(function () {
Route::get('/books/{bookSlug}/page/{pageSlug}/revisions/{revId}/changes', [EntityControllers\PageRevisionController::class, 'changes']); 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::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('/books/{bookSlug}/page/{pageSlug}/revisions/{revId}/delete', [EntityControllers\PageRevisionController::class, 'destroy']);
Route::delete('/page-revisions/user-drafts/{pageId}', [EntityControllers\PageRevisionController::class, 'destroyUserDraft']);
// Chapters // Chapters
Route::get('/books/{bookSlug}/chapter/{chapterSlug}/create-page', [EntityControllers\PageController::class, 'create']); Route::get('/books/{bookSlug}/chapter/{chapterSlug}/create-page', [EntityControllers\PageController::class, 'create']);

View 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() public function test_updating_page_draft_with_markdown_retains_markdown_content()
{ {
$book = $this->entities->book(); $book = $this->entities->book();