Commit 4b0e3d1c authored by NGPixel's avatar NGPixel

feat: save conflict resolution

parent bacbe4f5
...@@ -105,7 +105,7 @@ ...@@ -105,7 +105,7 @@
v-icon(left) mdi-code-tags v-icon(left) mdi-code-tags
span Source span Source
v-divider.mx-2(vertical) 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 v-icon(left) mdi-history
span History span History
v-spacer v-spacer
......
...@@ -147,7 +147,7 @@ ...@@ -147,7 +147,7 @@
v-tooltip(bottom) v-tooltip(bottom)
template(v-slot:activator='{ on }') template(v-slot:activator='{ on }')
v-btn(icon, tile, height='64', v-on='on', @click='pageNew') 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')}} span {{$t('common:header.newPage')}}
v-divider(vertical) v-divider(vertical)
...@@ -172,18 +172,18 @@ ...@@ -172,18 +172,18 @@
v-list-item-content v-list-item-content
v-list-item-title {{name}} v-list-item-title {{name}}
v-list-item-subtitle {{email}} v-list-item-subtitle {{email}}
v-list-item(href='/w', disabled) //- v-list-item(href='/w', disabled)
v-list-item-action: v-icon(color='blue') mdi-view-compact-outline //- v-list-item-action: v-icon(color='blue') mdi-view-compact-outline
v-list-item-content //- v-list-item-content
v-list-item-title {{$t('common:header.myWiki')}} //- v-list-item-title {{$t('common:header.myWiki')}}
v-list-item-subtitle.overline Coming soon //- v-list-item-subtitle.overline Coming soon
v-list-item(href='/p', disabled) //- v-list-item(href='/p', disabled)
v-list-item-action: v-icon(color='blue') mdi-face-profile //- v-list-item-action: v-icon(color='blue') mdi-face-profile
v-list-item-content //- v-list-item-content
v-list-item-title {{$t('common:header.profile')}} //- v-list-item-title {{$t('common:header.profile')}}
v-list-item-subtitle.overline Coming soon //- v-list-item-subtitle.overline Coming soon
v-list-item(href='/a', v-if='isAuthenticated && isAdmin') 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-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(@click='logout')
v-list-item-action: v-icon(color='red') mdi-logout v-list-item-action: v-icon(color='red') mdi-logout
......
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
full-width full-width
) )
template(slot='actions') 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 .overline.amber--text.mr-3 Conflict
status-indicator(intermediary, pulse) status-indicator(intermediary, pulse)
v-btn.animated.fadeInDown( v-btn.animated.fadeInDown(
...@@ -22,10 +22,9 @@ ...@@ -22,10 +22,9 @@
@click='save' @click='save'
@click.ctrl.exact='saveAndClose' @click.ctrl.exact='saveAndClose'
:class='{ "is-icon": $vuetify.breakpoint.mdAndDown }' :class='{ "is-icon": $vuetify.breakpoint.mdAndDown }'
:disabled='!isDirty'
) )
v-icon(color='green', :left='$vuetify.breakpoint.lgAndUp') mdi-check 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') }} 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( v-btn.animated.fadeInDown.wait-p1s(
text text
...@@ -86,7 +85,8 @@ export default { ...@@ -86,7 +85,8 @@ export default {
editorModalProperties: () => import(/* webpackChunkName: "editor", webpackMode: "eager" */ './editor/editor-modal-properties.vue'), editorModalProperties: () => import(/* webpackChunkName: "editor", webpackMode: "eager" */ './editor/editor-modal-properties.vue'),
editorModalUnsaved: () => import(/* webpackChunkName: "editor", webpackMode: "eager" */ './editor/editor-modal-unsaved.vue'), editorModalUnsaved: () => import(/* webpackChunkName: "editor", webpackMode: "eager" */ './editor/editor-modal-unsaved.vue'),
editorModalMedia: () => import(/* webpackChunkName: "editor", webpackMode: "eager" */ './editor/editor-modal-media.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: { props: {
locale: { locale: {
...@@ -136,6 +136,7 @@ export default { ...@@ -136,6 +136,7 @@ export default {
}, },
data() { data() {
return { return {
isSaving: false,
isConflict: false, isConflict: false,
dialogProps: false, dialogProps: false,
dialogProgress: false, dialogProgress: false,
...@@ -152,6 +153,7 @@ export default { ...@@ -152,6 +153,7 @@ export default {
mode: get('editor/mode'), mode: get('editor/mode'),
welcomeMode() { return this.mode === `create` && this.path === `home` }, welcomeMode() { return this.mode === `create` && this.path === `home` },
currentPageTitle: sync('page/title'), currentPageTitle: sync('page/title'),
checkoutDateActive: sync('editor/checkoutDateActive'),
isDirty () { isDirty () {
return _.some([ return _.some([
this.initContentParsed !== this.$store.get('editor/content'), this.initContentParsed !== this.$store.get('editor/content'),
...@@ -183,6 +185,8 @@ export default { ...@@ -183,6 +185,8 @@ export default {
this.$store.commit('page/SET_TITLE', this.title) this.$store.commit('page/SET_TITLE', this.title)
this.$store.commit('page/SET_MODE', 'edit') this.$store.commit('page/SET_MODE', 'edit')
this.checkoutDateActive = this.checkoutDate
}, },
mounted() { mounted() {
this.$store.set('editor/mode', this.initMode || 'create') this.$store.set('editor/mode', this.initMode || 'create')
...@@ -205,6 +209,10 @@ export default { ...@@ -205,6 +209,10 @@ export default {
} }
} }
this.$root.$on('resetEditorConflict', () => {
this.isConflict = false
})
// this.$store.set('editor/mode', 'edit') // this.$store.set('editor/mode', 'edit')
// this.currentEditor = `editorApi` // this.currentEditor = `editorApi`
}, },
...@@ -218,8 +226,12 @@ export default { ...@@ -218,8 +226,12 @@ export default {
hideProgressDialog() { hideProgressDialog() {
this.dialogProgress = false this.dialogProgress = false
}, },
async save() { openConflict() {
this.$root.$emit('saveConflict')
},
async save({ rethrow = false, overwrite = false } = {}) {
this.showProgressDialog('saving') this.showProgressDialog('saving')
this.isSaving = true
try { try {
if (this.$store.get('editor/mode') === 'create') { if (this.$store.get('editor/mode') === 'create') {
// -------------------------------------------- // --------------------------------------------
...@@ -244,6 +256,8 @@ export default { ...@@ -244,6 +256,8 @@ export default {
}) })
resp = _.get(resp, 'data.pages.create', {}) resp = _.get(resp, 'data.pages.create', {})
if (_.get(resp, 'responseResult.succeeded')) { if (_.get(resp, 'responseResult.succeeded')) {
this.checkoutDateActive = _.get(resp, 'page.updatedAt', this.checkoutDateActive)
this.isConflict = false
this.$store.commit('showNotification', { this.$store.commit('showNotification', {
message: this.$t('editor:save.createSuccess'), message: this.$t('editor:save.createSuccess'),
style: 'success', style: 'success',
...@@ -261,6 +275,25 @@ export default { ...@@ -261,6 +275,25 @@ export default {
// -> UPDATE EXISTING PAGE // -> 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({ let resp = await this.$apollo.mutate({
mutation: updatePageMutation, mutation: updatePageMutation,
variables: { variables: {
...@@ -280,6 +313,8 @@ export default { ...@@ -280,6 +313,8 @@ export default {
}) })
resp = _.get(resp, 'data.pages.update', {}) resp = _.get(resp, 'data.pages.update', {})
if (_.get(resp, 'responseResult.succeeded')) { if (_.get(resp, 'responseResult.succeeded')) {
this.checkoutDateActive = _.get(resp, 'page.updatedAt', this.checkoutDateActive)
this.isConflict = false
this.$store.commit('showNotification', { this.$store.commit('showNotification', {
message: this.$t('editor:save.updateSuccess'), message: this.$t('editor:save.updateSuccess'),
style: 'success', style: 'success',
...@@ -302,13 +337,16 @@ export default { ...@@ -302,13 +337,16 @@ export default {
style: 'error', style: 'error',
icon: 'warning' icon: 'warning'
}) })
throw err if (rethrow === true) {
throw err
}
} }
this.isSaving = false
this.hideProgressDialog() this.hideProgressDialog()
}, },
async saveAndClose() { async saveAndClose() {
try { try {
await this.save() await this.save({ rethrow: true })
await this.exit() await this.exit()
} catch (err) { } catch (err) {
// Error is already handled // Error is already handled
...@@ -348,12 +386,12 @@ export default { ...@@ -348,12 +386,12 @@ export default {
variables () { variables () {
return { return {
id: this.pageId, id: this.pageId,
checkoutDate: this.checkoutDate checkoutDate: this.checkoutDateActive
} }
}, },
update: (data) => _.cloneDeep(data.pages.checkConflicts), update: (data) => _.cloneDeep(data.pages.checkConflicts),
skip () { skip () {
return this.mode === 'create' || !this.isDirty return this.mode === 'create' || this.isSaving || !this.isDirty
} }
} }
} }
......
...@@ -633,6 +633,14 @@ export default { ...@@ -633,6 +633,14 @@ export default {
break break
} }
}) })
// Handle save conflict
this.$root.$on('saveConflict', () => {
this.toggleModal(`editorModalConflict`)
})
this.$root.$on('overwriteEditorContent', () => {
this.cm.setValue(this.$store.get('editor/content'))
})
}, },
beforeDestroy() { beforeDestroy() {
this.$root.$off('editorInsert') this.$root.$off('editorInsert')
......
<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>
...@@ -9,6 +9,7 @@ mutation ($content: String!, $description: String!, $editor: String!, $isPrivate ...@@ -9,6 +9,7 @@ mutation ($content: String!, $description: String!, $editor: String!, $isPrivate
} }
page { page {
id id
updatedAt
} }
} }
} }
......
...@@ -7,6 +7,9 @@ mutation ($id: Int!, $content: String, $description: String, $editor: String, $i ...@@ -7,6 +7,9 @@ mutation ($id: Int!, $content: String, $description: String, $editor: String, $i
slug slug
message message
} }
page {
updatedAt
}
} }
} }
} }
...@@ -10,7 +10,8 @@ const state = { ...@@ -10,7 +10,8 @@ const state = {
folderTree: [], folderTree: [],
currentFolderId: 0, currentFolderId: 0,
currentFileId: null currentFileId: null
} },
checkoutDateActive: ''
} }
export default { export default {
......
...@@ -139,7 +139,7 @@ module.exports = { ...@@ -139,7 +139,7 @@ module.exports = {
async single (obj, args, context, info) { async single (obj, args, context, info) {
let page = await WIKI.models.pages.getPageFromDb(args.id) let page = await WIKI.models.pages.getPageFromDb(args.id)
if (page) { if (page) {
if (WIKI.auth.checkAccess(context.req.user, ['read:history'], { if (WIKI.auth.checkAccess(context.req.user, ['manage:pages', 'delete:pages'], {
path: page.path, path: page.path,
locale: page.localeCode locale: page.localeCode
})) { })) {
...@@ -263,13 +263,35 @@ module.exports = { ...@@ -263,13 +263,35 @@ module.exports = {
path: page.path, path: page.path,
locale: page.localeCode locale: page.localeCode
})) { })) {
return page.updatedAt !== args.checkoutDate return page.updatedAt > args.checkoutDate
} else { } else {
throw new WIKI.Error.PageUpdateForbidden() throw new WIKI.Error.PageUpdateForbidden()
} }
} else { } else {
throw new WIKI.Error.PageNotFound() throw new WIKI.Error.PageNotFound()
} }
},
/**
* FETCH LATEST VERSION FOR CONFLICT COMPARISON
*/
async conflictLatest (obj, args, context, info) {
let page = await WIKI.models.pages.getPageFromDb(args.id)
if (page) {
if (WIKI.auth.checkAccess(context.req.user, ['write:pages', 'manage:pages'], {
path: page.path,
locale: page.localeCode
})) {
return {
...page,
tags: page.tags.map(t => t.tag),
locale: page.localeCode
}
} else {
throw new WIKI.Error.PageViewForbidden()
}
} else {
throw new WIKI.Error.PageNotFound()
}
} }
}, },
PageMutation: { PageMutation: {
......
...@@ -66,6 +66,10 @@ type PageQuery { ...@@ -66,6 +66,10 @@ type PageQuery {
id: Int! id: Int!
checkoutDate: Date! checkoutDate: Date!
): Boolean! @auth(requires: ["write:pages", "manage:pages", "manage:system"]) ): Boolean! @auth(requires: ["write:pages", "manage:pages", "manage:system"])
conflictLatest(
id: Int!
): PageConflictLatest! @auth(requires: ["write:pages", "manage:pages", "manage:system"])
} }
# ----------------------------------------------- # -----------------------------------------------
...@@ -277,6 +281,21 @@ type PageLinkItem { ...@@ -277,6 +281,21 @@ type PageLinkItem {
links: [String]! links: [String]!
} }
type PageConflictLatest {
id: Int!
authorId: String!
authorName: String!
content: String!
createdAt: Date!
description: String!
isPublished: Boolean!
locale: String!
path: String!
tags: [String]
title: String!
updatedAt: Date!
}
enum PageOrderBy { enum PageOrderBy {
CREATED CREATED
ID ID
......
...@@ -293,6 +293,9 @@ module.exports = class Page extends Model { ...@@ -293,6 +293,9 @@ module.exports = class Page extends Model {
mode: 'create' mode: 'create'
}) })
// -> Get latest updatedAt
page.updatedAt = await WIKI.models.pages.query().findById(page.id).select('updatedAt').then(r => r.updatedAt)
return page return page
} }
...@@ -340,12 +343,7 @@ module.exports = class Page extends Model { ...@@ -340,12 +343,7 @@ module.exports = class Page extends Model {
publishStartDate: opts.publishStartDate || '', publishStartDate: opts.publishStartDate || '',
title: opts.title title: opts.title
}).where('id', ogPage.id) }).where('id', ogPage.id)
let page = await WIKI.models.pages.getPageFromDb({ let page = await WIKI.models.pages.getPageFromDb(ogPage.id)
path: ogPage.path,
locale: ogPage.localeCode,
userId: ogPage.authorId,
isPrivate: ogPage.isPrivate
})
// -> Save Tags // -> Save Tags
await WIKI.models.tags.associateTags({ tags: opts.tags, page }) await WIKI.models.tags.associateTags({ tags: opts.tags, page })
...@@ -381,6 +379,9 @@ module.exports = class Page extends Model { ...@@ -381,6 +379,9 @@ module.exports = class Page extends Model {
}).update('title', page.title) }).update('title', page.title)
} }
// -> Get latest updatedAt
page.updatedAt = await WIKI.models.pages.query().findById(page.id).select('updatedAt').then(r => r.updatedAt)
return page return page
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment