new undo design using Toasts
This commit is contained in:
parent
4a73fa4548
commit
afb42da260
|
@ -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}',
|
||||||
|
|
|
@ -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
|
||||||
*
|
*
|
||||||
|
|
74
src/App.vue
74
src/App.vue
|
@ -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()
|
||||||
|
|
|
@ -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) => {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
@ -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(() => {
|
||||||
|
|
Loading…
Reference in New Issue