new undo design using Toasts

This commit is contained in:
korelstar 2019-12-23 17:40:19 +01:00
parent 4a73fa4548
commit afb42da260
8 changed files with 151 additions and 53 deletions

View File

@ -47,6 +47,11 @@ return ['routes' => [
'url' => '/notes', 'url' => '/notes',
'verb' => 'POST', 'verb' => 'POST',
], ],
[
'name' => 'notes#undo',
'url' => '/notes/undo',
'verb' => 'POST',
],
[ [
'name' => 'notes#update', 'name' => 'notes#update',
'url' => '/notes/{id}', 'url' => '/notes/{id}',

View File

@ -140,6 +140,34 @@ class NotesController extends Controller {
} }
/**
* @NoAdminRequired
*
* @param string $content
*/
public function undo($id, $content, $category, $modified, $favorite) {
try {
// check if note still exists
$note = $this->notesService->get($id, $this->userId);
if ($note->getError()) {
throw new \Exception();
}
} catch (\Throwable $e) {
// re-create if note doesn't exit anymore
$note = $this->notesService->create($this->userId);
$note = $this->notesService->update(
$note->getId(),
$content,
$this->userId,
$category,
$modified
);
$note->favorite = $this->notesService->favorite($note->getId(), $favorite, $this->userId);
}
return new DataResponse($note);
}
/** /**
* @NoAdminRequired * @NoAdminRequired
* *

View File

@ -14,7 +14,7 @@
:category="filter.category" :category="filter.category"
:search="filter.search" :search="filter.search"
@category-selected="onSelectCategory" @category-selected="onSelectCategory"
@note-deleted="routeFirst" @note-deleted="onNoteDeleted"
/> />
<AppSettings v-if="!loading.notes && error !== true" @reload="reloadNotes" /> <AppSettings v-if="!loading.notes && error !== true" @reload="reloadNotes" />
@ -40,8 +40,9 @@ import {
AppNavigationNew, AppNavigationNew,
Content, Content,
} from '@nextcloud/vue' } from '@nextcloud/vue'
import { showSuccess } from '@nextcloud/dialogs'
import { fetchNotes, noteExists, createNote } from './NotesService' import { fetchNotes, noteExists, createNote, undoDeleteNote } from './NotesService'
import { openNavbar } from './nextcloud' import { openNavbar } from './nextcloud'
import AppSettings from './components/AppSettings' import AppSettings from './components/AppSettings'
import NavigationList from './components/NavigationList' import NavigationList from './components/NavigationList'
@ -70,6 +71,9 @@ export default {
create: false, create: false,
}, },
error: false, error: false,
undoNotification: null,
undoTimer: null,
deletedNotes: [],
} }
}, },
@ -82,9 +86,6 @@ export default {
const search = this.filter.search.toLowerCase() const search = this.filter.search.toLowerCase()
const notes = this.notes.filter(note => { const notes = this.notes.filter(note => {
if (note.deleting === 'deleting') {
return false
}
if (this.filter.category !== null if (this.filter.category !== null
&& this.filter.category !== note.category && this.filter.category !== note.category
&& !note.category.startsWith(this.filter.category + '/')) { && !note.category.startsWith(this.filter.category + '/')) {
@ -174,10 +175,12 @@ export default {
}, },
routeToNote(noteId) { routeToNote(noteId) {
this.$router.push({ if (this.$route.name !== 'note' || this.$route.params.noteId !== noteId.toString()) {
name: 'note', this.$router.push({
params: { noteId: noteId.toString() }, name: 'note',
}) params: { noteId: noteId.toString() },
})
}
}, },
onSearch(query) { onSearch(query) {
@ -215,6 +218,59 @@ export default {
} }
}, },
onNoteDeleted(note) {
this.deletedNotes.push(note)
this.clearUndoTimer()
let label
if (this.deletedNotes.length === 1) {
label = this.t('notes', 'Deleted {title}', { title: note.title })
} else {
label = this.t('notes', 'Deleted {number} notes', { number: this.deletedNotes.length })
}
if (this.undoNotification === null) {
const action = '<button class="undo">' + this.t('notes', 'Undo Delete') + '</button>'
this.undoNotification = showSuccess(
'<span class="deletedLabel">' + label + '</span> ' + action,
{ isHTML: true, timeout: -1, onShow: this.onUndoNotificationClosed }
)
this.undoNotification.toastElement.getElementsByClassName('undo')
.forEach(element => { element.onclick = this.onUndoDelete })
} else {
this.undoNotification.toastElement.getElementsByClassName('deletedLabel')
.forEach(element => { element.textContent = label })
}
this.undoTimer = setTimeout(this.onRemoveUndoNotification, 12000)
this.routeFirst()
},
clearUndoTimer() {
if (this.undoTimer) {
clearTimeout(this.undoTimer)
this.undoTimer = null
}
},
onUndoDelete() {
this.deletedNotes.forEach(note => undoDeleteNote(note))
this.onRemoveUndoNotification()
},
onUndoNotificationClosed() {
if (this.undoNotification) {
this.undoNotification = null
this.onRemoveUndoNotification()
}
},
onRemoveUndoNotification() {
this.deletedNotes = []
if (this.undoNotification) {
this.undoNotification.hideToast()
this.undoNotification = null
}
this.clearUndoTimer()
},
onClose(event) { onClose(event) {
if (!this.notes.every(note => !note.unsaved)) { if (!this.notes.every(note => !note.unsaved)) {
event.preventDefault() event.preventDefault()

View File

@ -1,6 +1,7 @@
import AppGlobal from './mixins/AppGlobal' import AppGlobal from './mixins/AppGlobal'
import store from './store' import store from './store'
import axios from '@nextcloud/axios' import axios from '@nextcloud/axios'
import { showError } from '@nextcloud/dialogs'
const t = AppGlobal.methods.t const t = AppGlobal.methods.t
@ -10,11 +11,11 @@ function url(url) {
} }
function handleSyncError(message) { function handleSyncError(message) {
OC.Notification.showTemporary(message + ' ' + t('notes', 'See JavaScript console for details.')) showError(message + ' ' + t('notes', 'See JavaScript console and server log for details.'))
} }
function handleInsufficientStorage() { function handleInsufficientStorage() {
OC.Notification.showTemporary(t('notes', 'Saving the note has failed due to insufficient storage.')) showError(t('notes', 'Saving the note has failed due to insufficient storage.'))
} }
export const setSettings = settings => { export const setSettings = settings => {
@ -41,7 +42,7 @@ export const fetchNotes = () => {
store.dispatch('addAll', response.data.notes) store.dispatch('addAll', response.data.notes)
} }
if (response.data.errorMessage) { if (response.data.errorMessage) {
OC.Notification.showTemporary(response.data.errorMessage) showError(response.data.errorMessage)
} }
return response.data return response.data
}) })
@ -119,12 +120,22 @@ function _updateNote(note) {
}) })
} }
export const prepareDeleteNote = noteId => { export const undoDeleteNote = (note) => {
store.commit('setNoteAttribute', { noteId: noteId, attribute: 'deleting', value: 'prepare' }) return axios
} .post(url('/notes/undo'), note)
.then(response => {
export const undoDeleteNote = noteId => { store.commit('add', response.data)
store.commit('setNoteAttribute', { noteId: noteId, attribute: 'deleting', value: null }) return response.data
})
.catch(err => {
console.error(err)
if (err.response.status === 507) {
handleInsufficientStorage()
} else {
handleSyncError(t('notes', 'Undo delete has failed for note {title}.', { title: note.title }))
}
throw err
})
} }
export const deleteNote = noteId => { export const deleteNote = noteId => {
@ -137,9 +148,12 @@ export const deleteNote = noteId => {
.catch(err => { .catch(err => {
console.error(err) console.error(err)
handleSyncError(t('notes', 'Deleting note {id} has failed.', { id: noteId })) handleSyncError(t('notes', 'Deleting note {id} has failed.', { id: noteId }))
undoDeleteNote(noteId) // remove note always since we don't know when the error happened
store.commit('remove', noteId)
throw err throw err
}) })
.then(() => {
})
} }
export const setFavorite = (noteId, favorite) => { export const setFavorite = (noteId, favorite) => {

View File

@ -14,7 +14,7 @@
icon="icon-recent" icon="icon-recent"
@click.prevent.stop="onSelectCategory(null)" @click.prevent.stop="onSelectCategory(null)"
> >
<AppNavigationCounter slot="counter"> <AppNavigationCounter #counter>
{{ numNotes }} {{ numNotes }}
</AppNavigationCounter> </AppNavigationCounter>
</AppNavigationItem> </AppNavigationItem>
@ -25,7 +25,7 @@
:icon="category.name === '' ? 'icon-emptyfolder' : 'icon-files'" :icon="category.name === '' ? 'icon-emptyfolder' : 'icon-files'"
@click.prevent.stop="onSelectCategory(category.name)" @click.prevent.stop="onSelectCategory(category.name)"
> >
<AppNavigationCounter slot="counter"> <AppNavigationCounter #counter>
{{ category.count }} {{ category.count }}
</AppNavigationCounter> </AppNavigationCounter>
</AppNavigationItem> </AppNavigationItem>

View File

@ -42,7 +42,7 @@
:key="note.id" :key="note.id"
:note="note" :note="note"
@category-selected="$emit('category-selected', $event)" @category-selected="$emit('category-selected', $event)"
@note-deleted="$emit('note-deleted')" @note-deleted="$emit('note-deleted', $event)"
/> />
</template> </template>
<AppNavigationItem <AppNavigationItem

View File

@ -5,10 +5,8 @@
:menu-open.sync="actionsOpen" :menu-open.sync="actionsOpen"
:to="{ name: 'note', params: { noteId: note.id.toString() } }" :to="{ name: 'note', params: { noteId: note.id.toString() } }"
:class="{ actionsOpen }" :class="{ actionsOpen }"
:undo="isPrepareDeleting"
@undo="onUndoDeleteNote"
> >
<template v-if="!note.deleting" slot="actions"> <template #actions>
<ActionButton :icon="actionFavoriteIcon" @click="onToggleFavorite"> <ActionButton :icon="actionFavoriteIcon" @click="onToggleFavorite">
{{ actionFavoriteText }} {{ actionFavoriteText }}
</ActionButton> </ActionButton>
@ -27,8 +25,9 @@ import {
ActionButton, ActionButton,
AppNavigationItem, AppNavigationItem,
} from '@nextcloud/vue' } from '@nextcloud/vue'
import { showError } from '@nextcloud/dialogs'
import { categoryLabel, setFavorite, prepareDeleteNote, undoDeleteNote, deleteNote } from '../NotesService' import { categoryLabel, setFavorite, fetchNote, deleteNote } from '../NotesService'
export default { export default {
name: 'NavigationNoteItem', name: 'NavigationNoteItem',
@ -52,7 +51,6 @@ export default {
delete: false, delete: false,
}, },
actionsOpen: false, actionsOpen: false,
undoTimer: null,
} }
}, },
@ -67,16 +65,8 @@ export default {
return icon return icon
}, },
isPrepareDeleting() {
return this.note.deleting === 'prepare'
},
title() { title() {
if (this.isPrepareDeleting) { return this.note.title + (this.note.unsaved ? ' *' : '')
return this.t('notes', 'Deleted {title}', { title: this.note.title })
} else {
return this.note.title + (this.note.unsaved ? ' *' : '')
}
}, },
actionFavoriteText() { actionFavoriteText() {
@ -118,26 +108,30 @@ export default {
}, },
onDeleteNote() { onDeleteNote() {
this.actionsOpen = false
prepareDeleteNote(this.note.id)
this.undoTimer = setTimeout(this.onDeleteNoteFinally, 7000)
this.$emit('note-deleted')
},
onUndoDeleteNote() {
clearTimeout(this.undoTimer)
undoDeleteNote(this.note.id)
},
onDeleteNoteFinally() {
this.loading.delete = true this.loading.delete = true
deleteNote(this.note.id) fetchNote(this.note.id)
.then(() => { .then((note) => {
if (note.errorMessage) {
throw new Error('Note has errors')
}
deleteNote(this.note.id)
.then(() => {
// nothing to do, confirmation is done after event
})
.catch(() => {
// nothing to do, error is already shown by NotesService
})
.then(() => {
// always show undo, since error can relate to response only
this.$emit('note-deleted', note)
this.loading.delete = false
this.actionsOpen = false
})
}) })
.catch(() => { .catch(() => {
}) showError(this.t('notes', 'Error during preparing note for deletion.'))
.then(() => {
this.loading.delete = false this.loading.delete = false
this.actionsOpen = false
}) })
}, },
}, },

View File

@ -57,6 +57,7 @@ import {
AppContent, AppContent,
Tooltip, Tooltip,
} from '@nextcloud/vue' } from '@nextcloud/vue'
import { showError } from '@nextcloud/dialogs'
import { fetchNote, saveNote, saveNoteManually } from '../NotesService' import { fetchNote, saveNote, saveNoteManually } from '../NotesService'
import { closeNavbar } from '../nextcloud' import { closeNavbar } from '../nextcloud'
@ -141,7 +142,7 @@ export default {
fetchNote(this.noteId) fetchNote(this.noteId)
.then((note) => { .then((note) => {
if (note.errorMessage) { if (note.errorMessage) {
OC.Notification.showTemporary(note.errorMessage) showError(note.errorMessage)
} }
}) })
.catch(() => { .catch(() => {