check for update conflicts and provide solutions
This commit is contained in:
parent
892deaf5bb
commit
002dc3ff16
|
@ -14,11 +14,13 @@
|
|||
"@nextcloud/vue": "^3.8.0",
|
||||
"@nextcloud/vue-dashboard": "^2.0.0",
|
||||
"@nextcloud/webpack-vue-config": "^4.0.1",
|
||||
"diff": "^5.0.0",
|
||||
"easymde": "^2.14.0",
|
||||
"markdown-it": "^12.0.4",
|
||||
"stylelint-webpack-plugin": "^2.1.1",
|
||||
"vue": "^2.6.12",
|
||||
"vue-fragment": "1.5.1",
|
||||
"vue-material-design-icons": "^4.11.0",
|
||||
"vue-observe-visibility": "^1.0.0",
|
||||
"vue-router": "^3.5.1",
|
||||
"vuex": "^3.6.2"
|
||||
|
@ -3492,6 +3494,14 @@
|
|||
"minimalistic-assert": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/diff": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz",
|
||||
"integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==",
|
||||
"engines": {
|
||||
"node": ">=0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/diffie-hellman": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz",
|
||||
|
@ -5019,19 +5029,6 @@
|
|||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
|
||||
|
@ -10511,6 +10508,11 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-material-design-icons": {
|
||||
"version": "4.11.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-material-design-icons/-/vue-material-design-icons-4.11.0.tgz",
|
||||
"integrity": "sha512-3Tyeqi9jtONQ/x8WkJqiBs4t5Bd5O1t7RdM/GIPKVYoVdaRy0oy3nbRjnMGyONBlqC/NpPjzhWeoZWUMEI04nA=="
|
||||
},
|
||||
"node_modules/vue-multiselect": {
|
||||
"version": "2.1.6",
|
||||
"resolved": "https://registry.npmjs.org/vue-multiselect/-/vue-multiselect-2.1.6.tgz",
|
||||
|
@ -13668,6 +13670,11 @@
|
|||
"minimalistic-assert": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"diff": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz",
|
||||
"integrity": "sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w=="
|
||||
},
|
||||
"diffie-hellman": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz",
|
||||
|
@ -14809,12 +14816,6 @@
|
|||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
|
||||
},
|
||||
"fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"optional": true
|
||||
},
|
||||
"function-bind": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
|
||||
|
@ -18904,6 +18905,11 @@
|
|||
"vue-style-loader": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"vue-material-design-icons": {
|
||||
"version": "4.11.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-material-design-icons/-/vue-material-design-icons-4.11.0.tgz",
|
||||
"integrity": "sha512-3Tyeqi9jtONQ/x8WkJqiBs4t5Bd5O1t7RdM/GIPKVYoVdaRy0oy3nbRjnMGyONBlqC/NpPjzhWeoZWUMEI04nA=="
|
||||
},
|
||||
"vue-multiselect": {
|
||||
"version": "2.1.6",
|
||||
"resolved": "https://registry.npmjs.org/vue-multiselect/-/vue-multiselect-2.1.6.tgz",
|
||||
|
|
|
@ -21,11 +21,13 @@
|
|||
"@nextcloud/vue": "^3.8.0",
|
||||
"@nextcloud/vue-dashboard": "^2.0.0",
|
||||
"@nextcloud/webpack-vue-config": "^4.0.1",
|
||||
"diff": "^5.0.0",
|
||||
"easymde": "^2.14.0",
|
||||
"markdown-it": "^12.0.4",
|
||||
"stylelint-webpack-plugin": "^2.1.1",
|
||||
"vue": "^2.6.12",
|
||||
"vue-fragment": "1.5.1",
|
||||
"vue-material-design-icons": "^4.11.0",
|
||||
"vue-observe-visibility": "^1.0.0",
|
||||
"vue-router": "^3.5.1",
|
||||
"vuex": "^3.6.2"
|
||||
|
|
|
@ -3,6 +3,7 @@ import { generateUrl } from '@nextcloud/router'
|
|||
import { showError } from '@nextcloud/dialogs'
|
||||
|
||||
import store from './store'
|
||||
import { copyNote } from './Util'
|
||||
|
||||
function url(url) {
|
||||
url = `apps/notes${url}`
|
||||
|
@ -103,7 +104,7 @@ export const fetchNote = noteId => {
|
|||
const localNote = store.getters.getNote(parseInt(noteId))
|
||||
// only overwrite if there are no unsaved changes
|
||||
if (!localNote || !localNote.unsaved) {
|
||||
store.commit('updateNote', response.data)
|
||||
_updateLocalNote(response.data)
|
||||
}
|
||||
return response.data
|
||||
})
|
||||
|
@ -125,17 +126,22 @@ export const refreshNote = (noteId, lastETag) => {
|
|||
if (lastETag) {
|
||||
headers['If-None-Match'] = lastETag
|
||||
}
|
||||
const oldContent = store.getters.getNote(noteId).content
|
||||
const note = store.getters.getNote(noteId)
|
||||
const oldContent = note.content
|
||||
return axios
|
||||
.get(
|
||||
url('/notes/' + noteId),
|
||||
{ headers }
|
||||
)
|
||||
.then(response => {
|
||||
if (note.conflict) {
|
||||
store.commit('setNoteAttribute', { noteId, attribute: 'conflict', value: response.data })
|
||||
return response.headers.etag
|
||||
}
|
||||
const currentContent = store.getters.getNote(noteId).content
|
||||
// only update if local content has not changed
|
||||
if (oldContent === currentContent) {
|
||||
store.commit('updateNote', response.data)
|
||||
_updateLocalNote(response.data)
|
||||
return response.headers.etag
|
||||
}
|
||||
return null
|
||||
|
@ -166,7 +172,7 @@ export const createNote = category => {
|
|||
return axios
|
||||
.post(url('/notes'), { category })
|
||||
.then(response => {
|
||||
store.commit('updateNote', response.data)
|
||||
_updateLocalNote(response.data)
|
||||
return response.data
|
||||
})
|
||||
.catch(err => {
|
||||
|
@ -176,27 +182,87 @@ export const createNote = category => {
|
|||
})
|
||||
}
|
||||
|
||||
function _updateLocalNote(note, reference) {
|
||||
if (reference === undefined) {
|
||||
reference = copyNote(note, {})
|
||||
}
|
||||
store.commit('updateNote', note)
|
||||
store.commit('setNoteAttribute', { noteId: note.id, attribute: 'reference', value: reference })
|
||||
}
|
||||
|
||||
function _updateNote(note) {
|
||||
const requestOptions = { headers: { 'If-Match': '"' + note.etag + '"' } }
|
||||
return axios
|
||||
.put(url('/notes/' + note.id), { content: note.content })
|
||||
.put(url('/notes/' + note.id), { content: note.content }, requestOptions)
|
||||
.then(response => {
|
||||
const updated = response.data
|
||||
note.saveError = false
|
||||
note.title = updated.title
|
||||
note.modified = updated.modified
|
||||
store.commit('setNoteAttribute', { noteId: note.id, attribute: 'conflict', value: undefined })
|
||||
const updated = response.data
|
||||
if (updated.content === note.content) {
|
||||
note.unsaved = false
|
||||
// everything is fine
|
||||
// => update note with remote data
|
||||
_updateLocalNote(
|
||||
{ ...updated, unsaved: false }
|
||||
)
|
||||
} else {
|
||||
// content has changed locally in the meanwhile
|
||||
// => merge note, but exclude content
|
||||
_updateLocalNote(
|
||||
copyNote(updated, note, ['content']),
|
||||
copyNote(updated, {})
|
||||
)
|
||||
}
|
||||
store.commit('updateNote', note)
|
||||
return note
|
||||
})
|
||||
.catch(err => {
|
||||
store.commit('setNoteAttribute', { noteId: note.id, attribute: 'saveError', value: true })
|
||||
console.error(err)
|
||||
handleSyncError(t('notes', 'Saving note {id} has failed.', { id: note.id }), err)
|
||||
if (err.response && err.response.status === 412) {
|
||||
// ETag does not match, try to merge changes
|
||||
note.saveError = false
|
||||
store.commit('setNoteAttribute', { noteId: note.id, attribute: 'conflict', value: undefined })
|
||||
const reference = note.reference
|
||||
const remote = err.response.data
|
||||
if (remote.content === note.content) {
|
||||
// content is already up-to-date
|
||||
// => update note with remote data
|
||||
_updateLocalNote(
|
||||
{ ...remote, unsaved: false }
|
||||
)
|
||||
} else if (remote.content === reference.content) {
|
||||
// remote content has not changed
|
||||
// => use all other attributes and sync again
|
||||
_updateLocalNote(
|
||||
copyNote(remote, note, ['content']),
|
||||
copyNote(remote, {})
|
||||
)
|
||||
saveNote(note.id)
|
||||
} else {
|
||||
console.info('Note update conflict. Manual resolution required.')
|
||||
store.commit('setNoteAttribute', { noteId: note.id, attribute: 'conflict', value: remote })
|
||||
}
|
||||
} else {
|
||||
store.commit('setNoteAttribute', { noteId: note.id, attribute: 'saveError', value: true })
|
||||
console.error(err)
|
||||
handleSyncError(t('notes', 'Saving note {id} has failed.', { id: note.id }), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const conflictSolutionLocal = note => {
|
||||
note.etag = note.conflict.etag
|
||||
_updateLocalNote(
|
||||
copyNote(note.conflict, note, ['content']),
|
||||
copyNote(note.conflict, {})
|
||||
)
|
||||
store.commit('setNoteAttribute', { noteId: note.id, attribute: 'conflict', value: undefined })
|
||||
saveNote(note.id)
|
||||
}
|
||||
|
||||
export const conflictSolutionRemote = note => {
|
||||
_updateLocalNote(
|
||||
{ ...note.conflict, unsaved: false }
|
||||
)
|
||||
store.commit('setNoteAttribute', { noteId: note.id, attribute: 'conflict', value: undefined })
|
||||
}
|
||||
|
||||
export const autotitleNote = noteId => {
|
||||
return axios
|
||||
.put(url('/notes/' + noteId + '/autotitle'))
|
||||
|
@ -213,7 +279,7 @@ export const undoDeleteNote = (note) => {
|
|||
return axios
|
||||
.post(url('/notes/undo'), note)
|
||||
.then(response => {
|
||||
store.commit('updateNote', response.data)
|
||||
_updateLocalNote(response.data)
|
||||
return response.data
|
||||
})
|
||||
.catch(err => {
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
export const noteAttributes = [
|
||||
'id',
|
||||
'etag',
|
||||
'title',
|
||||
'content',
|
||||
'modified',
|
||||
'favorite',
|
||||
'category',
|
||||
]
|
||||
|
||||
export const copyNote = (from, to, exclude) => {
|
||||
if (exclude === undefined) {
|
||||
exclude = []
|
||||
}
|
||||
noteAttributes.forEach(attr => {
|
||||
if (!exclude.includes(attr)) {
|
||||
to[attr] = from[attr]
|
||||
}
|
||||
})
|
||||
return to
|
||||
}
|
|
@ -0,0 +1,94 @@
|
|||
<template>
|
||||
<div class="conflict-solution">
|
||||
<div class="text">
|
||||
<pre v-for="(l, i) in diff" :key="i" :class="l.className">{{ l.line }}</pre>
|
||||
</div>
|
||||
<button @click="$emit('onChooseSolution')">
|
||||
{{ button }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
|
||||
import { diffLines } from 'diff'
|
||||
|
||||
export default {
|
||||
name: 'ConflictSolution',
|
||||
|
||||
props: {
|
||||
content: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
reference: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
button: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
diff() {
|
||||
const diffs = diffLines(this.reference, this.content)
|
||||
const line2class = function(line) {
|
||||
if (line.added) {
|
||||
return 'added'
|
||||
} else if (line.removed) {
|
||||
return 'removed'
|
||||
} else {
|
||||
return 'unchanged'
|
||||
}
|
||||
}
|
||||
const lines = []
|
||||
diffs.forEach(diff => {
|
||||
const className = line2class(diff)
|
||||
diff.value.replace(/\r?\n$/, '').split(/\r?\n/).forEach(line => {
|
||||
lines.push({ line, className })
|
||||
})
|
||||
})
|
||||
return lines
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style scoped>
|
||||
.conflict-solution {
|
||||
height: 100%;
|
||||
padding: 1ex;
|
||||
margin: 1ex;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.conflict-solution .text {
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
background-color: var(--color-background-darker);
|
||||
padding: 0 1ex;
|
||||
}
|
||||
|
||||
.conflict-solution .text pre {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.2;
|
||||
padding: 0.3ex 0.5ex;
|
||||
}
|
||||
|
||||
.conflict-solution .text .removed {
|
||||
background-color: rgba(128, 128, 128, 0.2);
|
||||
color: rgba(128, 128, 128, 1);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.conflict-solution .text .added {
|
||||
background-color: rgba(70, 186, 97, 0.2);
|
||||
color: rgba(70, 186, 97, 1);
|
||||
}
|
||||
|
||||
.conflict-solution button {
|
||||
margin: auto;
|
||||
margin-top: 2ex;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
|
@ -5,6 +5,27 @@
|
|||
class="note-container"
|
||||
:class="{ fullscreen: fullscreen }"
|
||||
>
|
||||
<Modal v-if="note.conflict && showConflict" size="full" @close="showConflict=false">
|
||||
<div class="conflict-modal">
|
||||
<div class="conflict-header">
|
||||
{{ t('notes', 'The note has been changed in another session. Please choose which content should be saved.') }}
|
||||
</div>
|
||||
<div class="conflict-solutions">
|
||||
<ConflictSolution
|
||||
:content="note.conflict.content"
|
||||
:reference="note.reference.content"
|
||||
:button="t('notes', 'Use version from server')"
|
||||
@onChooseSolution="onUseRemoteVersion"
|
||||
/>
|
||||
<ConflictSolution
|
||||
:content="note.content"
|
||||
:reference="note.reference.content"
|
||||
:button="t('notes', 'Use current version')"
|
||||
@onChooseSolution="onUseLocalVersion"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<div class="note-editor">
|
||||
<div v-show="!note.content" class="placeholder">
|
||||
{{ preview ? t('notes', 'Empty note') : t('notes', 'Write …') }}
|
||||
|
@ -35,11 +56,18 @@
|
|||
{{ fullscreen ? t('notes', 'Exit full screen') : t('notes', 'Full screen') }}
|
||||
</ActionButton>
|
||||
</Actions>
|
||||
<button v-show="note.saveError"
|
||||
v-tooltip.right="t('notes', 'Save failed. Click to retry.')"
|
||||
class="action-error icon-error-color"
|
||||
@click="onManualSave"
|
||||
/>
|
||||
<Actions v-if="note.saveError" class="action-error">
|
||||
<ActionButton @click="onManualSave">
|
||||
<SyncAlertIcon slot="icon" :size="18" fill-color="var(--color-text)" />
|
||||
{{ t('notes', 'Save failed. Click to retry.') }}
|
||||
</ActionButton>
|
||||
</Actions>
|
||||
<Actions v-if="note.conflict" class="action-error">
|
||||
<ActionButton @click="showConflict=true">
|
||||
<SyncAlertIcon slot="icon" :size="18" fill-color="var(--color-text)" />
|
||||
{{ t('notes', 'Update conflict. Click for resolving manually.') }}
|
||||
</ActionButton>
|
||||
</Actions>
|
||||
</span>
|
||||
</div>
|
||||
</AppContent>
|
||||
|
@ -50,16 +78,20 @@ import {
|
|||
Actions,
|
||||
ActionButton,
|
||||
AppContent,
|
||||
Modal,
|
||||
Tooltip,
|
||||
isMobile,
|
||||
} from '@nextcloud/vue'
|
||||
import { showError } from '@nextcloud/dialogs'
|
||||
import { emit } from '@nextcloud/event-bus'
|
||||
|
||||
import SyncAlertIcon from 'vue-material-design-icons/SyncAlert'
|
||||
|
||||
import { config } from '../config'
|
||||
import { fetchNote, refreshNote, saveNote, saveNoteManually, autotitleNote, routeIsNewNote } from '../NotesService'
|
||||
import { fetchNote, refreshNote, saveNote, saveNoteManually, autotitleNote, routeIsNewNote, conflictSolutionLocal, conflictSolutionRemote } from '../NotesService'
|
||||
import TheEditor from './EditorEasyMDE'
|
||||
import ThePreview from './EditorMarkdownIt'
|
||||
import ConflictSolution from './ConflictSolution'
|
||||
import store from '../store'
|
||||
|
||||
export default {
|
||||
|
@ -69,6 +101,9 @@ export default {
|
|||
Actions,
|
||||
ActionButton,
|
||||
AppContent,
|
||||
ConflictSolution,
|
||||
Modal,
|
||||
SyncAlertIcon,
|
||||
TheEditor,
|
||||
ThePreview,
|
||||
},
|
||||
|
@ -96,6 +131,7 @@ export default {
|
|||
autotitleTimer: null,
|
||||
refreshTimer: null,
|
||||
etag: null,
|
||||
showConflict: false,
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -124,6 +160,11 @@ export default {
|
|||
}
|
||||
},
|
||||
title: 'onUpdateTitle',
|
||||
'note.conflict'(newConflict, oldConflict) {
|
||||
if (newConflict) {
|
||||
this.showConflict = true
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
|
@ -242,7 +283,7 @@ export default {
|
|||
},
|
||||
|
||||
refreshNote() {
|
||||
if (this.note.unsaved) {
|
||||
if (this.note.unsaved && !this.note.conflict) {
|
||||
this.startRefreshTimer()
|
||||
return
|
||||
}
|
||||
|
@ -317,6 +358,16 @@ export default {
|
|||
store.commit('updateNote', note)
|
||||
saveNoteManually(this.note.id)
|
||||
},
|
||||
|
||||
onUseLocalVersion() {
|
||||
conflictSolutionLocal(this.note)
|
||||
this.showConflict = false
|
||||
},
|
||||
|
||||
onUseRemoteVersion() {
|
||||
conflictSolutionRemote(this.note)
|
||||
this.showConflict = false
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
@ -379,8 +430,8 @@ export default {
|
|||
}
|
||||
|
||||
.action-buttons .action-error {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background-color: var(--color-error);
|
||||
margin-top: 1ex;
|
||||
}
|
||||
|
||||
.note-container.fullscreen .action-buttons {
|
||||
|
@ -390,4 +441,27 @@ export default {
|
|||
.action-buttons button {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
/* Conflict Modal */
|
||||
.conflict-modal {
|
||||
width: 70vw;
|
||||
}
|
||||
|
||||
.conflict-header {
|
||||
padding: 1ex 1em;
|
||||
}
|
||||
|
||||
.conflict-solutions {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
max-height: 75vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 60em) {
|
||||
.conflict-solutions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import Vue from 'vue'
|
||||
import { copyNote } from '../Util'
|
||||
|
||||
const state = {
|
||||
categories: [],
|
||||
|
@ -76,13 +77,11 @@ const mutations = {
|
|||
updateNote(state, updated) {
|
||||
const note = state.notesIds[updated.id]
|
||||
if (note) {
|
||||
note.title = updated.title
|
||||
note.modified = updated.modified
|
||||
note.favorite = updated.favorite
|
||||
note.category = updated.category
|
||||
copyNote(updated, note, ['id', 'etag', 'content'])
|
||||
// don't update meta-data over full data
|
||||
if (updated.content !== undefined || note.content === undefined) {
|
||||
if (updated.content !== undefined && updated.etag !== undefined) {
|
||||
note.content = updated.content
|
||||
note.etag = updated.etag
|
||||
Vue.set(note, 'unsaved', updated.unsaved)
|
||||
Vue.set(note, 'error', updated.error)
|
||||
Vue.set(note, 'errorMessage', updated.errorMessage)
|
||||
|
|
Loading…
Reference in New Issue