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',
'verb' => 'POST',
],
[
'name' => 'notes#undo',
'url' => '/notes/undo',
'verb' => 'POST',
],
[
'name' => 'notes#update',
'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
*

View File

@ -14,7 +14,7 @@
:category="filter.category"
:search="filter.search"
@category-selected="onSelectCategory"
@note-deleted="routeFirst"
@note-deleted="onNoteDeleted"
/>
<AppSettings v-if="!loading.notes && error !== true" @reload="reloadNotes" />
@ -40,8 +40,9 @@ import {
AppNavigationNew,
Content,
} 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 AppSettings from './components/AppSettings'
import NavigationList from './components/NavigationList'
@ -70,6 +71,9 @@ export default {
create: false,
},
error: false,
undoNotification: null,
undoTimer: null,
deletedNotes: [],
}
},
@ -82,9 +86,6 @@ export default {
const search = this.filter.search.toLowerCase()
const notes = this.notes.filter(note => {
if (note.deleting === 'deleting') {
return false
}
if (this.filter.category !== null
&& this.filter.category !== note.category
&& !note.category.startsWith(this.filter.category + '/')) {
@ -174,10 +175,12 @@ export default {
},
routeToNote(noteId) {
this.$router.push({
name: 'note',
params: { noteId: noteId.toString() },
})
if (this.$route.name !== 'note' || this.$route.params.noteId !== noteId.toString()) {
this.$router.push({
name: 'note',
params: { noteId: noteId.toString() },
})
}
},
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) {
if (!this.notes.every(note => !note.unsaved)) {
event.preventDefault()

View File

@ -1,6 +1,7 @@
import AppGlobal from './mixins/AppGlobal'
import store from './store'
import axios from '@nextcloud/axios'
import { showError } from '@nextcloud/dialogs'
const t = AppGlobal.methods.t
@ -10,11 +11,11 @@ function url(url) {
}
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() {
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 => {
@ -41,7 +42,7 @@ export const fetchNotes = () => {
store.dispatch('addAll', response.data.notes)
}
if (response.data.errorMessage) {
OC.Notification.showTemporary(response.data.errorMessage)
showError(response.data.errorMessage)
}
return response.data
})
@ -119,12 +120,22 @@ function _updateNote(note) {
})
}
export const prepareDeleteNote = noteId => {
store.commit('setNoteAttribute', { noteId: noteId, attribute: 'deleting', value: 'prepare' })
}
export const undoDeleteNote = noteId => {
store.commit('setNoteAttribute', { noteId: noteId, attribute: 'deleting', value: null })
export const undoDeleteNote = (note) => {
return axios
.post(url('/notes/undo'), note)
.then(response => {
store.commit('add', response.data)
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 => {
@ -137,9 +148,12 @@ export const deleteNote = noteId => {
.catch(err => {
console.error(err)
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
})
.then(() => {
})
}
export const setFavorite = (noteId, favorite) => {

View File

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

View File

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

View File

@ -5,10 +5,8 @@
:menu-open.sync="actionsOpen"
:to="{ name: 'note', params: { noteId: note.id.toString() } }"
:class="{ actionsOpen }"
:undo="isPrepareDeleting"
@undo="onUndoDeleteNote"
>
<template v-if="!note.deleting" slot="actions">
<template #actions>
<ActionButton :icon="actionFavoriteIcon" @click="onToggleFavorite">
{{ actionFavoriteText }}
</ActionButton>
@ -27,8 +25,9 @@ import {
ActionButton,
AppNavigationItem,
} 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 {
name: 'NavigationNoteItem',
@ -52,7 +51,6 @@ export default {
delete: false,
},
actionsOpen: false,
undoTimer: null,
}
},
@ -67,16 +65,8 @@ export default {
return icon
},
isPrepareDeleting() {
return this.note.deleting === 'prepare'
},
title() {
if (this.isPrepareDeleting) {
return this.t('notes', 'Deleted {title}', { title: this.note.title })
} else {
return this.note.title + (this.note.unsaved ? ' *' : '')
}
return this.note.title + (this.note.unsaved ? ' *' : '')
},
actionFavoriteText() {
@ -118,26 +108,30 @@ export default {
},
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
deleteNote(this.note.id)
.then(() => {
fetchNote(this.note.id)
.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(() => {
})
.then(() => {
showError(this.t('notes', 'Error during preparing note for deletion.'))
this.loading.delete = false
this.actionsOpen = false
})
},
},

View File

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