Browse Source

feat: editor reason for change + nav fixes

NGPixel 2 years ago
parent
commit
a6041b4ba5

+ 4 - 3
server/graph/resolvers/user.js

@@ -283,11 +283,12 @@ module.exports = {
     async uploadUserAvatar (obj, args) {
       try {
         const { filename, mimetype, createReadStream } = await args.image
-        WIKI.logger.debug(`Processing user ${args.id} avatar ${filename} of type ${mimetype}...`)
+        const lowercaseFilename = filename.toLowerCase()
+        WIKI.logger.debug(`Processing user ${args.id} avatar ${lowercaseFilename} of type ${mimetype}...`)
         if (!WIKI.extensions.ext.sharp.isInstalled) {
-          throw new Error('This feature requires the Sharp extension but it is not installed.')
+          throw new Error('This feature requires the Sharp extension but it is not installed. Contact your wiki administrator.')
         }
-        if (!['.png', '.jpg', '.webp', '.gif'].some(s => filename.endsWith(s))) {
+        if (!['.png', '.jpg', '.webp', '.gif'].some(s => lowercaseFilename.endsWith(s))) {
           throw new Error('Invalid File Extension. Must be png, jpg, webp or gif.')
         }
         const destFolder = path.resolve(

File diff suppressed because it is too large
+ 0 - 0
ux/public/_assets/icons/fluent-query.svg


+ 4 - 0
ux/src/components/EditorMarkdown.vue

@@ -237,6 +237,7 @@ import { reactive, ref, shallowRef, nextTick, onBeforeMount, onMounted, watch }
 import { useMeta, useQuasar, setCssVar } from 'quasar'
 import { useI18n } from 'vue-i18n'
 import { get, flatten, last, times, startsWith, debounce } from 'lodash-es'
+import { DateTime } from 'luxon'
 
 import { useEditorStore } from 'src/stores/editor'
 import { usePageStore } from 'src/stores/page'
@@ -438,6 +439,9 @@ onMounted(async () => {
 
   cm.value.setValue(pageStore.content)
   cm.value.on('change', c => {
+    editorStore.$patch({
+      lastChangeTimestamp: DateTime.utc()
+    })
     pageStore.$patch({
       content: c.getValue()
     })

+ 30 - 7
ux/src/components/PageHeader.vue

@@ -120,8 +120,8 @@
         flat
         icon='las la-times'
         color='negative'
-        label='Discard'
-        aria-label='Discard'
+        :label='editorStore.hasPendingChanges ? t(`common.actions.discard`) : t(`common.actions.close`)'
+        :aria-label='editorStore.hasPendingChanges ? t(`common.actions.discard`) : t(`common.actions.close`)'
         no-caps
         @click='discardChanges'
       )
@@ -142,6 +142,7 @@
         color='positive'
         label='Save Changes'
         aria-label='Save Changes'
+        :disabled='!editorStore.hasPendingChanges'
         no-caps
         @click='saveChanges'
       )
@@ -220,17 +221,21 @@ async function discardChanges () {
     return
   }
 
+  const hadPendingChanges = editorStore.hasPendingChanges
+
   $q.loading.show()
   try {
     editorStore.$patch({
       isActive: false,
       editor: ''
     })
-    await pageStore.pageLoad({ id: pageStore.id })
-    $q.notify({
-      type: 'positive',
-      message: 'Page has been reverted to the last saved state.'
-    })
+    await pageStore.cancelPageEdit()
+    if (hadPendingChanges) {
+      $q.notify({
+        type: 'positive',
+        message: 'Page has been reverted to the last saved state.'
+      })
+    }
   } catch (err) {
     $q.notify({
       type: 'negative',
@@ -241,6 +246,24 @@ async function discardChanges () {
 }
 
 async function saveChanges () {
+  if (siteStore.features.reasonForChange !== 'off') {
+    $q.dialog({
+      component: defineAsyncComponent(() => import('../components/PageReasonForChangeDialog.vue')),
+      componentProps: {
+        required: siteStore.features.reasonForChange === 'required'
+      }
+    }).onOk(async ({ reason }) => {
+      editorStore.$patch({
+        reasonForChange: reason
+      })
+      saveChangesCommit()
+    })
+  } else {
+    saveChangesCommit()
+  }
+}
+
+async function saveChangesCommit () {
   $q.loading.show()
   try {
     await pageStore.pageSave()

+ 105 - 0
ux/src/components/PageReasonForChangeDialog.vue

@@ -0,0 +1,105 @@
+<template lang="pug">
+q-dialog(ref='dialogRef', @hide='onDialogHide')
+  q-card(style='min-width: 450px;')
+    q-card-section.card-header
+      q-icon(name='img:/_assets/icons/fluent-query.svg', left, size='sm')
+      span {{t(`editor.reasonForChange.title`)}}
+    q-card-section
+      .text-body2(v-if='props.required') {{t(`editor.reasonForChange.required`)}}
+      .text-body2(v-else) {{t(`editor.reasonForChange.optional`)}}
+    q-form.q-pb-sm(ref='reasonForm', @submit.prevent='commit')
+      q-item
+        q-item-section
+          q-input(
+            outlined
+            v-model='state.reason'
+            dense
+            :rules='reasonValidation'
+            hide-bottom-space
+            :label='t(`editor.reasonForChange.field`)'
+            :aria-label='t(`editor.reasonForChange.field`)'
+            lazy-rules='ondemand'
+            autofocus
+            )
+    q-card-actions.card-actions
+      q-space
+      q-btn.acrylic-btn(
+        flat
+        :label='t(`common.actions.cancel`)'
+        color='grey'
+        padding='xs md'
+        @click='onDialogCancel'
+        )
+      q-btn(
+        unelevated
+        :label='t(`common.actions.save`)'
+        color='primary'
+        padding='xs md'
+        @click='commit'
+        :loading='state.isLoading'
+        )
+</template>
+
+<script setup>
+import { useI18n } from 'vue-i18n'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { reactive, ref } from 'vue'
+
+// PROPS
+
+const props = defineProps({
+  required: {
+    type: Boolean,
+    required: false,
+    default: false
+  }
+})
+
+// EMITS
+
+defineEmits([
+  ...useDialogPluginComponent.emits
+])
+
+// QUASAR
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const $q = useQuasar()
+
+// I18N
+
+const { t } = useI18n()
+
+// DATA
+
+const state = reactive({
+  reason: '',
+  isLoading: false
+})
+
+// REFS
+
+const reasonForm = ref(null)
+
+// VALIDATION RULES
+
+const reasonValidation = [
+  val => val.length > 0 || t('editor.reasonForChange.reasonMissing')
+]
+
+// METHODS
+
+async function commit () {
+  state.isLoading = true
+  try {
+    if (props.required) {
+      const isFormValid = await reasonForm.value.validate(true)
+      if (!isFormValid) {
+        throw new Error('Form Invalid')
+      }
+    }
+    onDialogOK({ reason: state.reason })
+  } catch (err) { }
+  state.isLoading = false
+}
+</script>

+ 5 - 0
ux/src/i18n/locales/en.json

@@ -1531,6 +1531,11 @@
   "editor.props.title": "Title",
   "editor.props.tocMinMaxDepth": "Min/Max Depth",
   "editor.props.visibility": "Visibility",
+  "editor.reasonForChange.field": "Reason",
+  "editor.reasonForChange.optional": "Enter a short description of the reason for this change. This is optional but recommended.",
+  "editor.reasonForChange.reasonMissing": "A reason is missing.",
+  "editor.reasonForChange.required": "You must provide a reason for this change. Enter a small description of what changed.",
+  "editor.reasonForChange.title": "Reason For Change",
   "editor.renderPreview": "Render Preview",
   "editor.save.createSuccess": "Page created successfully.",
   "editor.save.error": "An error occurred while creating the page",

+ 5 - 0
ux/src/pages/Index.vue

@@ -261,6 +261,11 @@ watch(() => route.path, async (newValue) => {
   if (newValue.startsWith('/_')) { return }
   try {
     await pageStore.pageLoad({ path: newValue })
+    if (editorStore.isActive) {
+      editorStore.$patch({
+        isActive: false
+      })
+    }
   } catch (err) {
     if (err.message === 'ERR_PAGE_NOT_FOUND') {
       if (newValue === '/') {

+ 4 - 3
ux/src/stores/editor.js

@@ -4,8 +4,8 @@ export const useEditorStore = defineStore('editor', {
   state: () => ({
     isActive: false,
     editor: '',
-    content: '',
-    mode: 'create',
+    originPageId: '',
+    mode: 'edit',
     activeModal: '',
     activeModalData: null,
     hideSideNav: false,
@@ -17,7 +17,8 @@ export const useEditorStore = defineStore('editor', {
     checkoutDateActive: '',
     lastSaveTimestamp: null,
     lastChangeTimestamp: null,
-    editors: {}
+    editors: {},
+    reasonForChange: ''
   }),
   getters: {
     hasPendingChanges: (state) => {

+ 112 - 97
ux/src/stores/page.js

@@ -109,6 +109,73 @@ const gqlQueries = {
   `
 }
 
+const gqlMutations = {
+  createPage: gql`
+    mutation createPage (
+      $allowComments: Boolean
+      $allowContributions: Boolean
+      $allowRatings: Boolean
+      $content: String!
+      $description: String!
+      $editor: String!
+      $icon: String
+      $isBrowsable: Boolean
+      $locale: String!
+      $path: String!
+      $publishState: PagePublishState!
+      $publishEndDate: Date
+      $publishStartDate: Date
+      $relations: [PageRelationInput!]
+      $scriptCss: String
+      $scriptJsLoad: String
+      $scriptJsUnload: String
+      $showSidebar: Boolean
+      $showTags: Boolean
+      $showToc: Boolean
+      $siteId: UUID!
+      $tags: [String!]
+      $title: String!
+      $tocDepth: PageTocDepthInput
+    ) {
+      createPage (
+        allowComments: $allowComments
+        allowContributions: $allowContributions
+        allowRatings: $allowRatings
+        content: $content
+        description: $description
+        editor: $editor
+        icon: $icon
+        isBrowsable: $isBrowsable
+        locale: $locale
+        path: $path
+        publishState: $publishState
+        publishEndDate: $publishEndDate
+        publishStartDate: $publishStartDate
+        relations: $relations
+        scriptCss: $scriptCss
+        scriptJsLoad: $scriptJsLoad
+        scriptJsUnload: $scriptJsUnload
+        showSidebar: $showSidebar
+        showTags: $showTags
+        showToc: $showToc
+        siteId: $siteId
+        tags: $tags
+        title: $title
+        tocDepth: $tocDepth
+      ) {
+        operation {
+          succeeded
+          message
+        }
+        page {
+          ...PageRead
+        }
+      }
+    }
+    ${pagePropsFragment}
+  `
+}
+
 export const usePageStore = defineStore('page', {
   state: () => ({
     allowComments: false,
@@ -211,45 +278,37 @@ export const usePageStore = defineStore('page', {
     pageCreate ({ editor, locale, path, title = '', description = '', content = '' }) {
       const editorStore = useEditorStore()
 
-      // if (['markdown', 'api'].includes(editor)) {
-      //   commit('site/SET_SHOW_SIDE_NAV', false, { root: true })
-      // } else {
-      //   commit('site/SET_SHOW_SIDE_NAV', true, { root: true })
-      // }
-
-      // if (['markdown', 'channel', 'api'].includes(editor)) {
-      //   commit('site/SET_SHOW_SIDEBAR', false, { root: true })
-      // } else {
-      //   commit('site/SET_SHOW_SIDEBAR', true, { root: true })
-      // }
+      // -> Init editor
+      editorStore.$patch({
+        originPageId: editorStore.isActive ? editorStore.originPageId : this.id, // Don't replace if already in edit mode
+        isActive: true,
+        mode: 'create',
+        editor
+      })
 
-      // -> Page Data
-      this.id = 0
-      this.locale = locale || this.locale
-      if (path || path === '') {
-        this.path = path
-      } else {
-        this.path = this.path.length < 2 ? 'new-page' : `${this.path}/new-page`
+      // -> Default Page Path
+      let newPath = path
+      if (!path && path !== '') {
+        newPath = this.path.length < 2 ? 'new-page' : `${this.path}/new-page`
       }
-      this.title = title ?? ''
-      this.description = description ?? ''
-      this.icon = 'las la-file-alt'
-      this.publishState = 'published'
-      this.relations = []
-      this.tags = []
-
-      this.content = content ?? ''
-      this.render = ''
 
-      // -> View Mode
-      this.mode = 'edit'
-
-      // -> Editor Mode
-      editorStore.$patch({
-        isActive: true,
-        editor,
-        mode: 'create'
+      // -> Set Default Page Data
+      this.$patch({
+        id: 0,
+        locale: locale || this.locale,
+        path: newPath,
+        title: title ?? '',
+        description: description ?? '',
+        icon: 'las la-file-alt',
+        publishState: 'published',
+        relations: [],
+        tags: [],
+        content: content ?? '',
+        render: '',
+        mode: 'edit'
       })
+
+      this.router.push('/_create')
     },
     /**
      * PAGE SAVE
@@ -260,66 +319,7 @@ export const usePageStore = defineStore('page', {
       try {
         if (editorStore.mode === 'create') {
           const resp = await APOLLO_CLIENT.mutate({
-            mutation: gql`
-              mutation createPage (
-                $allowComments: Boolean
-                $allowContributions: Boolean
-                $allowRatings: Boolean
-                $content: String!
-                $description: String!
-                $editor: String!
-                $icon: String
-                $isBrowsable: Boolean
-                $locale: String!
-                $path: String!
-                $publishState: PagePublishState!
-                $publishEndDate: Date
-                $publishStartDate: Date
-                $relations: [PageRelationInput!]
-                $scriptCss: String
-                $scriptJsLoad: String
-                $scriptJsUnload: String
-                $showSidebar: Boolean
-                $showTags: Boolean
-                $showToc: Boolean
-                $siteId: UUID!
-                $tags: [String!]
-                $title: String!
-                $tocDepth: PageTocDepthInput
-              ) {
-                createPage (
-                  allowComments: $allowComments
-                  allowContributions: $allowContributions
-                  allowRatings: $allowRatings
-                  content: $content
-                  description: $description
-                  editor: $editor
-                  icon: $icon
-                  isBrowsable: $isBrowsable
-                  locale: $locale
-                  path: $path
-                  publishState: $publishState
-                  publishEndDate: $publishEndDate
-                  publishStartDate: $publishStartDate
-                  relations: $relations
-                  scriptCss: $scriptCss
-                  scriptJsLoad: $scriptJsLoad
-                  scriptJsUnload: $scriptJsUnload
-                  showSidebar: $showSidebar
-                  showTags: $showTags
-                  showToc: $showToc
-                  siteId: $siteId
-                  tags: $tags
-                  title: $title
-                  tocDepth: $tocDepth
-                ) {
-                  operation {
-                    succeeded
-                    message
-                  }
-                }
-              }
-              `,
+            mutation: gqlMutations.createPage,
             variables: {
               ...pick(this, [
                 'allowComments',
@@ -354,8 +354,18 @@ export const usePageStore = defineStore('page', {
           if (!result.succeeded) {
             throw new Error(result.message)
           }
-          this.id = resp.data.createPage.page.id
-          this.editor = editorStore.editor
+          const pageData = cloneDeep(resp.data.createPage.page ?? {})
+          if (!pageData?.id) {
+            throw new Error('ERR_CREATED_PAGE_NOT_FOUND')
+          }
+          // Update page store
+          this.$patch({
+            ...pageData,
+            relations: pageData.relations.map(r => pick(r, ['id', 'position', 'label', 'caption', 'icon', 'target'])),
+            tocDepth: pick(pageData.tocDepth, ['min', 'max'])
+          })
+
+          this.router.replace(`/${this.path}`)
         } else {
           const resp = await APOLLO_CLIENT.mutate({
             mutation: gql`
@@ -419,6 +429,11 @@ export const usePageStore = defineStore('page', {
         throw err
       }
     },
+    async cancelPageEdit () {
+      const editorStore = useEditorStore()
+      await this.pageLoad({ id: editorStore.originPageId ? editorStore.originPageId : this.id })
+      this.router.replace(`/${this.path}`)
+    },
     generateToc () {
 
     }

Some files were not shown because too many files changed in this diff