feat: save conflict resolution
This commit is contained in:
@@ -105,7 +105,7 @@
|
||||
v-icon(left) mdi-code-tags
|
||||
span Source
|
||||
v-divider.mx-2(vertical)
|
||||
v-btn(color='primary', text, :href='`/h/` + page.locale + `/` + page.path', disabled)
|
||||
v-btn(color='primary', text, :href='`/h/` + page.locale + `/` + page.path')
|
||||
v-icon(left) mdi-history
|
||||
span History
|
||||
v-spacer
|
||||
|
@@ -147,7 +147,7 @@
|
||||
v-tooltip(bottom)
|
||||
template(v-slot:activator='{ on }')
|
||||
v-btn(icon, tile, height='64', v-on='on', @click='pageNew')
|
||||
v-icon(color='grey') mdi-file-document-box-plus-outline
|
||||
v-icon(color='grey') mdi-text-box-plus-outline
|
||||
span {{$t('common:header.newPage')}}
|
||||
v-divider(vertical)
|
||||
|
||||
@@ -172,18 +172,18 @@
|
||||
v-list-item-content
|
||||
v-list-item-title {{name}}
|
||||
v-list-item-subtitle {{email}}
|
||||
v-list-item(href='/w', disabled)
|
||||
v-list-item-action: v-icon(color='blue') mdi-view-compact-outline
|
||||
v-list-item-content
|
||||
v-list-item-title {{$t('common:header.myWiki')}}
|
||||
v-list-item-subtitle.overline Coming soon
|
||||
v-list-item(href='/p', disabled)
|
||||
v-list-item-action: v-icon(color='blue') mdi-face-profile
|
||||
v-list-item-content
|
||||
v-list-item-title {{$t('common:header.profile')}}
|
||||
v-list-item-subtitle.overline Coming soon
|
||||
//- v-list-item(href='/w', disabled)
|
||||
//- v-list-item-action: v-icon(color='blue') mdi-view-compact-outline
|
||||
//- v-list-item-content
|
||||
//- v-list-item-title {{$t('common:header.myWiki')}}
|
||||
//- v-list-item-subtitle.overline Coming soon
|
||||
//- v-list-item(href='/p', disabled)
|
||||
//- v-list-item-action: v-icon(color='blue') mdi-face-profile
|
||||
//- v-list-item-content
|
||||
//- v-list-item-title {{$t('common:header.profile')}}
|
||||
//- v-list-item-subtitle.overline Coming soon
|
||||
v-list-item(href='/a', v-if='isAuthenticated && isAdmin')
|
||||
v-list-item-action.btn-animate-rotate: v-icon(:color='$vuetify.theme.dark ? `blue-grey lighten-3` : `blue-grey`') mdi-settings
|
||||
v-list-item-action.btn-animate-rotate: v-icon(:color='$vuetify.theme.dark ? `blue-grey lighten-3` : `blue-grey`') mdi-cog
|
||||
v-list-item-title(:class='$vuetify.theme.dark ? `blue-grey--text text--lighten-3` : `blue-grey--text`') {{$t('common:header.admin')}}
|
||||
v-list-item(@click='logout')
|
||||
v-list-item-action: v-icon(color='red') mdi-logout
|
||||
|
@@ -13,7 +13,7 @@
|
||||
full-width
|
||||
)
|
||||
template(slot='actions')
|
||||
v-btn.mr-3.animated.fadeIn(color='amber', outlined, small, v-if='isConflict')
|
||||
v-btn.mr-3.animated.fadeIn(color='amber', outlined, small, v-if='isConflict', @click='openConflict')
|
||||
.overline.amber--text.mr-3 Conflict
|
||||
status-indicator(intermediary, pulse)
|
||||
v-btn.animated.fadeInDown(
|
||||
@@ -22,10 +22,9 @@
|
||||
@click='save'
|
||||
@click.ctrl.exact='saveAndClose'
|
||||
:class='{ "is-icon": $vuetify.breakpoint.mdAndDown }'
|
||||
:disabled='!isDirty'
|
||||
)
|
||||
v-icon(color='green', :left='$vuetify.breakpoint.lgAndUp') mdi-check
|
||||
span(v-if='$vuetify.breakpoint.lgAndUp && mode !== `create` && !isDirty') {{ $t('editor:save.saved') }}
|
||||
span.grey--text(v-if='$vuetify.breakpoint.lgAndUp && mode !== `create` && !isDirty') {{ $t('editor:save.saved') }}
|
||||
span.white--text(v-else-if='$vuetify.breakpoint.lgAndUp') {{ mode === 'create' ? $t('common:actions.create') : $t('common:actions.save') }}
|
||||
v-btn.animated.fadeInDown.wait-p1s(
|
||||
text
|
||||
@@ -86,7 +85,8 @@ export default {
|
||||
editorModalProperties: () => import(/* webpackChunkName: "editor", webpackMode: "eager" */ './editor/editor-modal-properties.vue'),
|
||||
editorModalUnsaved: () => import(/* webpackChunkName: "editor", webpackMode: "eager" */ './editor/editor-modal-unsaved.vue'),
|
||||
editorModalMedia: () => import(/* webpackChunkName: "editor", webpackMode: "eager" */ './editor/editor-modal-media.vue'),
|
||||
editorModalBlocks: () => import(/* webpackChunkName: "editor", webpackMode: "eager" */ './editor/editor-modal-blocks.vue')
|
||||
editorModalBlocks: () => import(/* webpackChunkName: "editor", webpackMode: "eager" */ './editor/editor-modal-blocks.vue'),
|
||||
editorModalConflict: () => import(/* webpackChunkName: "editor-conflict", webpackMode: "lazy" */ './editor/editor-modal-conflict.vue')
|
||||
},
|
||||
props: {
|
||||
locale: {
|
||||
@@ -136,6 +136,7 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isSaving: false,
|
||||
isConflict: false,
|
||||
dialogProps: false,
|
||||
dialogProgress: false,
|
||||
@@ -152,6 +153,7 @@ export default {
|
||||
mode: get('editor/mode'),
|
||||
welcomeMode() { return this.mode === `create` && this.path === `home` },
|
||||
currentPageTitle: sync('page/title'),
|
||||
checkoutDateActive: sync('editor/checkoutDateActive'),
|
||||
isDirty () {
|
||||
return _.some([
|
||||
this.initContentParsed !== this.$store.get('editor/content'),
|
||||
@@ -183,6 +185,8 @@ export default {
|
||||
this.$store.commit('page/SET_TITLE', this.title)
|
||||
|
||||
this.$store.commit('page/SET_MODE', 'edit')
|
||||
|
||||
this.checkoutDateActive = this.checkoutDate
|
||||
},
|
||||
mounted() {
|
||||
this.$store.set('editor/mode', this.initMode || 'create')
|
||||
@@ -205,6 +209,10 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
this.$root.$on('resetEditorConflict', () => {
|
||||
this.isConflict = false
|
||||
})
|
||||
|
||||
// this.$store.set('editor/mode', 'edit')
|
||||
// this.currentEditor = `editorApi`
|
||||
},
|
||||
@@ -218,8 +226,12 @@ export default {
|
||||
hideProgressDialog() {
|
||||
this.dialogProgress = false
|
||||
},
|
||||
async save() {
|
||||
openConflict() {
|
||||
this.$root.$emit('saveConflict')
|
||||
},
|
||||
async save({ rethrow = false, overwrite = false } = {}) {
|
||||
this.showProgressDialog('saving')
|
||||
this.isSaving = true
|
||||
try {
|
||||
if (this.$store.get('editor/mode') === 'create') {
|
||||
// --------------------------------------------
|
||||
@@ -244,6 +256,8 @@ export default {
|
||||
})
|
||||
resp = _.get(resp, 'data.pages.create', {})
|
||||
if (_.get(resp, 'responseResult.succeeded')) {
|
||||
this.checkoutDateActive = _.get(resp, 'page.updatedAt', this.checkoutDateActive)
|
||||
this.isConflict = false
|
||||
this.$store.commit('showNotification', {
|
||||
message: this.$t('editor:save.createSuccess'),
|
||||
style: 'success',
|
||||
@@ -261,6 +275,25 @@ export default {
|
||||
// -> UPDATE EXISTING PAGE
|
||||
// --------------------------------------------
|
||||
|
||||
const conflictResp = await this.$apollo.query({
|
||||
query: gql`
|
||||
query ($id: Int!, $checkoutDate: Date!) {
|
||||
pages {
|
||||
checkConflicts(id: $id, checkoutDate: $checkoutDate)
|
||||
}
|
||||
}
|
||||
`,
|
||||
fetchPolicy: 'network-only',
|
||||
variables: {
|
||||
id: this.pageId,
|
||||
checkoutDate: this.checkoutDateActive
|
||||
}
|
||||
})
|
||||
if (_.get(conflictResp, 'data.pages.checkConflicts', false)) {
|
||||
this.$root.$emit('saveConflict')
|
||||
throw new Error('Save conflict! Another user has already modified this page.')
|
||||
}
|
||||
|
||||
let resp = await this.$apollo.mutate({
|
||||
mutation: updatePageMutation,
|
||||
variables: {
|
||||
@@ -280,6 +313,8 @@ export default {
|
||||
})
|
||||
resp = _.get(resp, 'data.pages.update', {})
|
||||
if (_.get(resp, 'responseResult.succeeded')) {
|
||||
this.checkoutDateActive = _.get(resp, 'page.updatedAt', this.checkoutDateActive)
|
||||
this.isConflict = false
|
||||
this.$store.commit('showNotification', {
|
||||
message: this.$t('editor:save.updateSuccess'),
|
||||
style: 'success',
|
||||
@@ -302,13 +337,16 @@ export default {
|
||||
style: 'error',
|
||||
icon: 'warning'
|
||||
})
|
||||
throw err
|
||||
if (rethrow === true) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
this.isSaving = false
|
||||
this.hideProgressDialog()
|
||||
},
|
||||
async saveAndClose() {
|
||||
try {
|
||||
await this.save()
|
||||
await this.save({ rethrow: true })
|
||||
await this.exit()
|
||||
} catch (err) {
|
||||
// Error is already handled
|
||||
@@ -348,12 +386,12 @@ export default {
|
||||
variables () {
|
||||
return {
|
||||
id: this.pageId,
|
||||
checkoutDate: this.checkoutDate
|
||||
checkoutDate: this.checkoutDateActive
|
||||
}
|
||||
},
|
||||
update: (data) => _.cloneDeep(data.pages.checkConflicts),
|
||||
skip () {
|
||||
return this.mode === 'create' || !this.isDirty
|
||||
return this.mode === 'create' || this.isSaving || !this.isDirty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -633,6 +633,14 @@ export default {
|
||||
break
|
||||
}
|
||||
})
|
||||
|
||||
// Handle save conflict
|
||||
this.$root.$on('saveConflict', () => {
|
||||
this.toggleModal(`editorModalConflict`)
|
||||
})
|
||||
this.$root.$on('overwriteEditorContent', () => {
|
||||
this.cm.setValue(this.$store.get('editor/content'))
|
||||
})
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$root.$off('editorInsert')
|
||||
|
216
client/components/editor/editor-modal-conflict.vue
Normal file
216
client/components/editor/editor-modal-conflict.vue
Normal file
@@ -0,0 +1,216 @@
|
||||
<template lang='pug'>
|
||||
v-card.editor-modal-conflict.animated.fadeIn(flat, tile)
|
||||
.pa-4
|
||||
v-toolbar.radius-7(flat, color='indigo', style='border-bottom-left-radius: 0; border-bottom-right-radius: 0;', dark)
|
||||
v-icon.mr-3 mdi-merge
|
||||
.subtitle-1 Resolve Save Conflict
|
||||
v-spacer
|
||||
v-btn(outlined, color='white', @click='useLocal', title='Use content in the left panel')
|
||||
v-icon(left) mdi-alpha-l-box
|
||||
span Use Local
|
||||
v-dialog(
|
||||
v-model='isRemoteConfirmDiagShown'
|
||||
width='500'
|
||||
)
|
||||
template(v-slot:activator='{ on }')
|
||||
v-btn.ml-3(outlined, color='white', v-on='on', title='Discard local changes and use latest version')
|
||||
v-icon(left) mdi-alpha-r-box
|
||||
span Use Remote
|
||||
v-card
|
||||
.dialog-header.is-short.is-indigo
|
||||
v-icon.mr-3(color='white') mdi-alpha-r-box
|
||||
span Overwrite with Remote Version?
|
||||
v-card-text.pa-4
|
||||
.body-2 Are you sure you want to replace your current version with the latest remote content? #[strong Your current edits will be lost.]
|
||||
v-card-chin
|
||||
v-spacer
|
||||
v-btn(outlined, color='indigo', @click='isRemoteConfirmDiagShown = false')
|
||||
v-icon(left) mdi-close
|
||||
span Cancel
|
||||
v-btn(@click='useRemote', color='indigo', dark)
|
||||
v-icon(left) mdi-check
|
||||
span Confirm
|
||||
v-divider.mx-3(vertical)
|
||||
v-btn(outlined, color='indigo lighten-4', @click='close')
|
||||
v-icon(left) mdi-close
|
||||
span Cancel
|
||||
v-row.indigo.darken-1.body-2(no-gutters)
|
||||
v-col.pa-4
|
||||
v-icon.mr-3(color='white') mdi-alpha-l-box
|
||||
span.white--text Local Version #[em.indigo--text.text--lighten-4 (editable)]
|
||||
v-divider(vertical)
|
||||
v-col.pa-4
|
||||
v-icon.mr-3(color='white') mdi-alpha-r-box
|
||||
span.white--text Remote Version #[em.indigo--text.text--lighten-4 (read-only)]
|
||||
v-row.grey.lighten-2.body-2(no-gutters)
|
||||
v-col.px-4.py-2
|
||||
em.grey--text.text--darken-2 Your current edit, based on page version from #[span(:title='$options.filters.moment(checkoutDateActive, `LLL`)') {{ checkoutDateActive | moment('from') }}]
|
||||
v-divider(vertical)
|
||||
v-col.px-4.py-2
|
||||
em.grey--text.text--darken-2 Last edited by #[strong {{latest.authorName}}], #[span(:title='$options.filters.moment(latest.updatedAt, `LLL`)') {{ latest.updatedAt | moment('from') }}]
|
||||
v-row.grey.lighten-3.grey--text.text--darken-3(no-gutters)
|
||||
v-col.pa-4
|
||||
.body-2
|
||||
strong.indigo--text Title:
|
||||
strong.pl-2 {{title}}
|
||||
.caption
|
||||
strong.indigo--text Description:
|
||||
span.pl-2 {{description}}
|
||||
v-divider(vertical, light)
|
||||
v-col.pa-4
|
||||
.body-2
|
||||
strong.indigo--text Title:
|
||||
strong.pl-2 {{latest.title}}
|
||||
.caption
|
||||
strong.indigo--text Description:
|
||||
span.pl-2 {{latest.description}}
|
||||
v-card.radius-7(:light='!$vuetify.theme.dark', :dark='$vuetify.theme.dark')
|
||||
div(ref='cm')
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import _ from 'lodash'
|
||||
import gql from 'graphql-tag'
|
||||
import { sync, get } from 'vuex-pathify'
|
||||
|
||||
/* global siteConfig */
|
||||
|
||||
// ========================================
|
||||
// IMPORTS
|
||||
// ========================================
|
||||
|
||||
import '../../libs/codemirror-merge/diff-match-patch.js'
|
||||
|
||||
// Code Mirror
|
||||
import CodeMirror from 'codemirror'
|
||||
import 'codemirror/lib/codemirror.css'
|
||||
|
||||
// Language
|
||||
import 'codemirror/mode/markdown/markdown.js'
|
||||
import 'codemirror/mode/htmlmixed/htmlmixed.js'
|
||||
|
||||
// Addons
|
||||
import 'codemirror/addon/selection/active-line.js'
|
||||
import 'codemirror/addon/merge/merge.js'
|
||||
import 'codemirror/addon/merge/merge.css'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
cm: null,
|
||||
latest: {
|
||||
title: '',
|
||||
description: '',
|
||||
updatedAt: '',
|
||||
authorName: ''
|
||||
},
|
||||
isRemoteConfirmDiagShown: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
editorKey: get('editor/editorKey'),
|
||||
activeModal: sync('editor/activeModal'),
|
||||
pageId: get('page/id'),
|
||||
title: get('page/title'),
|
||||
description: get('page/description'),
|
||||
updatedAt: get('page/updatedAt'),
|
||||
checkoutDateActive: sync('editor/checkoutDateActive')
|
||||
},
|
||||
methods: {
|
||||
close () {
|
||||
this.isRemoteConfirmDiagShown = false
|
||||
this.activeModal = ''
|
||||
},
|
||||
overwriteAndClose() {
|
||||
this.checkoutDateActive = this.latest.updatedAt
|
||||
this.$root.$emit('overwriteEditorContent')
|
||||
this.$root.$emit('resetEditorConflict')
|
||||
this.close()
|
||||
},
|
||||
useLocal () {
|
||||
this.$store.set('editor/content', this.cm.edit.getValue())
|
||||
this.overwriteAndClose()
|
||||
},
|
||||
useRemote () {
|
||||
this.$store.set('editor/content', this.latest.content)
|
||||
this.overwriteAndClose()
|
||||
}
|
||||
},
|
||||
async mounted () {
|
||||
let textMode = 'text/html'
|
||||
|
||||
switch (this.editorKey) {
|
||||
case 'markdown':
|
||||
textMode = 'text/markdown'
|
||||
break
|
||||
}
|
||||
|
||||
let resp = await this.$apollo.query({
|
||||
query: gql`
|
||||
query ($id: Int!) {
|
||||
pages {
|
||||
conflictLatest(id: $id) {
|
||||
id
|
||||
authorId
|
||||
authorName
|
||||
content
|
||||
createdAt
|
||||
description
|
||||
isPublished
|
||||
locale
|
||||
path
|
||||
tags
|
||||
title
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
fetchPolicy: 'network-only',
|
||||
variables: {
|
||||
id: this.$store.get('page/id')
|
||||
}
|
||||
})
|
||||
resp = _.get(resp, 'data.pages.conflictLatest', false)
|
||||
|
||||
if (!resp) {
|
||||
return this.$store.commit('showNotification', {
|
||||
message: 'Failed to fetch latest version.',
|
||||
style: 'warning',
|
||||
icon: 'warning'
|
||||
})
|
||||
}
|
||||
this.latest = resp
|
||||
|
||||
this.cm = CodeMirror.MergeView(this.$refs.cm, {
|
||||
value: this.$store.get('editor/content'),
|
||||
orig: resp.content,
|
||||
tabSize: 2,
|
||||
mode: textMode,
|
||||
lineNumbers: true,
|
||||
lineWrapping: true,
|
||||
connect: null,
|
||||
highlightDifferences: true,
|
||||
styleActiveLine: true,
|
||||
collapseIdentical: true,
|
||||
direction: siteConfig.rtl ? 'rtl' : 'ltr'
|
||||
})
|
||||
this.cm.rightOriginal().setSize(null, 'calc(100vh - 265px)')
|
||||
this.cm.editor().setSize(null, 'calc(100vh - 265px)')
|
||||
this.cm.wrap.style.height = 'calc(100vh - 265px)'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang='scss'>
|
||||
.editor-modal-conflict {
|
||||
position: fixed !important;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background-color: rgba(0, 0, 0, .9) !important;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
Reference in New Issue
Block a user