new undo design using Toasts
This commit is contained in:
parent
4a73fa4548
commit
afb42da260
|
@ -47,6 +47,11 @@ return ['routes' => [
|
|||
'url' => '/notes',
|
||||
'verb' => 'POST',
|
||||
],
|
||||
[
|
||||
'name' => 'notes#undo',
|
||||
'url' => '/notes/undo',
|
||||
'verb' => 'POST',
|
||||
],
|
||||
[
|
||||
'name' => 'notes#update',
|
||||
'url' => '/notes/{id}',
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
74
src/App.vue
74
src/App.vue
|
@ -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()
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
})
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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(() => {
|
||||
|
|
Loading…
Reference in New Issue