Browse Source

Feature: Mobile - Add more configurable options to the editor

Vladimir Sheremet 2 years ago
parent
commit
563a38feee

+ 40 - 1
app/frontend/apps/mobile/pages/playground/views/PlaygroundOverview.vue

@@ -9,7 +9,7 @@ import { useDialog } from '@shared/composables/useDialog'
 import CommonButtonGroup from '@mobile/components/CommonButtonGroup/CommonButtonGroup.vue'
 import { useUserCreate } from '@mobile/entities/user/composables/useUserCreate'
 import CommonStepper from '@mobile/components/CommonStepper/CommonStepper.vue'
-import { ref } from 'vue'
+import { computed, reactive, ref } from 'vue'
 
 const linkSchemaRaw = [
   {
@@ -26,6 +26,7 @@ const linkSchemaRaw = [
     name: 'editor',
     label: 'Editor',
     required: true,
+    // props: editorProps,
   },
   {
     type: 'textarea',
@@ -359,6 +360,36 @@ const steps = {
     disabled: true,
   },
 }
+
+const editorProps = reactive({
+  contentType: 'text/plain',
+  meta: {
+    footer: {
+      text: '/AB',
+      maxlength: 276,
+      warningLength: 30,
+    },
+  },
+})
+
+const updateEditorProps = () => {
+  editorProps.contentType =
+    editorProps.contentType === 'text/plain' ? 'text/html' : 'text/plain'
+}
+
+const editorSchema = defineFormSchema([
+  {
+    type: 'editor',
+    name: 'editor',
+    label: 'Editor',
+    required: true,
+    props: Object.keys(editorProps).reduce((acc, key) => {
+      acc[key] = computed(() => editorProps[key as keyof typeof editorProps])
+      return acc
+    }, {} as Record<string, unknown>),
+  },
+])
+const logSubmit = console.log
 </script>
 
 <template>
@@ -367,11 +398,19 @@ const steps = {
       Dialog
     </button>
 
+    <button type="button" @click="updateEditorProps">
+      CHANGE EDITOR PROPS
+    </button>
+
+    <button form="form">Submit</button>
+
     <CommonStepper v-model="currentStep" class="mx-20" :steps="steps" />
 
     <!-- TODO where to put this? -->
     <button @click="openCreateUserDialog()">Create user</button>
 
+    <Form id="form" :schema="editorSchema" @submit="logSubmit" />
+
     <CommonButtonGroup
       class="py-4"
       mode="full"

+ 5 - 2
app/frontend/apps/mobile/pages/ticket/composable/useTicketEdit.ts

@@ -11,6 +11,7 @@ import { EnumObjectManagerObjects } from '@shared/graphql/types'
 import { MutationHandler } from '@shared/server/apollo/handler'
 import type { TicketById } from '@shared/entities/ticket/types'
 import type { FileUploaded } from '@shared/components/Form/fields/FieldFile/types'
+import type { SecurityValue } from '@shared/components/Form/fields/FieldSecurity/types'
 import { useTicketUpdateMutation } from '../graphql/mutations/update.api'
 
 interface ArticleFormValues {
@@ -21,6 +22,8 @@ interface ArticleFormValues {
   to?: string[]
   subject?: string
   attachments?: FileUploaded[]
+  contentType?: string
+  security?: SecurityValue
 }
 
 export const useTicketEdit = (
@@ -74,9 +77,9 @@ export const useTicketEdit = (
       cc: article.cc,
       to: article.to,
       subject: article.subject,
-      contentType: 'text/html', // TODO can be plain text
+      contentType: article.contentType || 'text/html',
       attachments: attachments.length ? { files, formId } : null,
-      // TODO security
+      security: article.security,
     }
   }
 

+ 3 - 1
app/frontend/apps/mobile/pages/ticket/composable/useTicketEditForm.ts

@@ -21,6 +21,8 @@ export const useTicketEditForm = (ticket: Ref<TicketById | undefined>) => {
 
   const currentArticleType = shallowRef<AppSpecificTicketArticleType>()
 
+  const editorType = computed(() => currentArticleType.value?.contentType)
+
   const editorMeta = computed(() => {
     return {
       mentionUser: {
@@ -120,7 +122,6 @@ export const useTicketEditForm = (ticket: Ref<TicketById | undefined>) => {
         triggerFormUpdater: false,
       },
       {
-        // includes security and is possible to enable it
         if: '$fns.includes($currentArticleType.attributes, "security")',
         name: 'security',
         label: __('Security'),
@@ -135,6 +136,7 @@ export const useTicketEditForm = (ticket: Ref<TicketById | undefined>) => {
         screen: 'edit',
         object: EnumObjectManagerObjects.TicketArticle,
         props: {
+          contentType: editorType,
           meta: editorMeta,
         },
         triggerFormUpdater: false,

+ 9 - 3
app/frontend/apps/mobile/pages/ticket/views/TicketDetailView.vue

@@ -84,9 +84,18 @@ const {
   openArticleReplyDialog,
 } = useTicketArticleReply(ticket, form)
 
+const { currentArticleType, ticketEditSchema, articleTypeHandler } =
+  useTicketEditForm(ticket)
+
 const { notify } = useNotifications()
 
 const submitForm = async (formData: FormData) => {
+  const updateForm = currentArticleType.value?.updateForm
+
+  if (updateForm) {
+    formData = updateForm(formData)
+  }
+
   try {
     const result = await editTicket(formData)
 
@@ -155,9 +164,6 @@ onBeforeRouteLeave(async () => {
   return confirmed
 })
 
-const { currentArticleType, ticketEditSchema, articleTypeHandler } =
-  useTicketEditForm(ticket)
-
 const ticketEditSchemaData = reactive({
   formLocation,
   newTicketArticleRequested,

+ 40 - 0
app/frontend/cypress/shared/components/Form/fields/FieldEditor/editor-content-type.cy.ts

@@ -0,0 +1,40 @@
+// Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
+
+import { getNode } from '@formkit/core'
+import { mountEditor } from './utils'
+
+describe('changes private value depending on content type', () => {
+  it('has html content type by default', () => {
+    mountEditor()
+
+    cy.findByRole('textbox')
+      .type('some kind of text')
+      .then(() => {
+        expect(getNode('editor')?._value).to.equal('<p>some kind of text</p>')
+      })
+  })
+
+  it('has html content type, if prop is provided', () => {
+    mountEditor({
+      contentType: 'text/html',
+    })
+
+    cy.findByRole('textbox')
+      .type('some kind of text')
+      .then(() => {
+        expect(getNode('editor')?._value).to.equal('<p>some kind of text</p>')
+      })
+  })
+
+  it('has text content type, if prop is provided', () => {
+    mountEditor({
+      contentType: 'text/plain',
+    })
+
+    cy.findByRole('textbox')
+      .type('some kind of text')
+      .then(() => {
+        expect(getNode('editor')?._value).to.equal('some kind of text')
+      })
+  })
+})

+ 89 - 0
app/frontend/cypress/shared/components/Form/fields/FieldEditor/editor-footer.cy.ts

@@ -0,0 +1,89 @@
+// Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
+
+import { mountEditor } from './utils'
+
+describe('displays footer information', () => {
+  it("doesn't display footer by default", () => {
+    mountEditor()
+
+    cy.findByTestId('editor-footer').should('not.exist')
+  })
+
+  it("doesn't display footer, if footer if disabled", () => {
+    mountEditor({
+      meta: {
+        footer: {
+          disabled: true,
+          text: '/AB',
+        },
+      },
+    })
+
+    // doesn't exist before async editor initialization
+    cy.findByTestId('editor-footer').should('not.exist')
+    cy.findByText('/AB').should('not.exist')
+
+    // doesn't exist after async editor initialization
+    cy.findByRole('textbox').then(() => {
+      cy.findByTestId('editor-footer').should('not.exist')
+      cy.findByText('/AB').should('not.exist')
+    })
+  })
+
+  it('displays footer, if footer text is provided', () => {
+    mountEditor({
+      meta: {
+        footer: {
+          text: '/AB',
+        },
+      },
+    })
+
+    // exists before async editor initialization
+    cy.findByTestId('editor-footer').should('exist')
+    cy.findByText('/AB').should('exist')
+
+    // exists after async editor initialization
+    cy.findByRole('textbox').then(() => {
+      cy.findByTestId('editor-footer').should('exist')
+      cy.findByText('/AB').should('exist')
+    })
+  })
+
+  it('renders counter that decrements', () => {
+    mountEditor({
+      meta: {
+        footer: {
+          text: '/AB',
+          maxlength: 10,
+          warningLength: 5,
+        },
+      },
+    })
+
+    cy.findByTestId('editor-footer').should('exist')
+    cy.findByText('/AB').should('exist')
+    cy.findByTitle('Available characters').should('have.text', '10')
+
+    cy.findByRole('textbox').then(() => {
+      cy.findByTestId('editor-footer').should('exist')
+      cy.findByText('/AB').should('exist')
+      cy.findByTitle('Available characters').should('have.text', '10')
+    })
+
+    cy.findByRole('textbox')
+      .type('123456789')
+      .then(() => {
+        cy.findByTitle('Available characters')
+          .should('have.text', '1')
+          .and('have.class', 'text-orange')
+      })
+    cy.findByRole('textbox')
+      .type('1234567')
+      .then(() => {
+        cy.findByTitle('Available characters')
+          .should('have.text', '-6')
+          .and('have.class', 'text-red')
+      })
+  })
+})

+ 0 - 2
app/frontend/cypress/shared/components/Form/fields/FieldEditor/utils.ts

@@ -13,5 +13,3 @@ export const mountEditor = (props: Record<string, unknown> = {}) => {
     },
   })
 }
-
-export default {}

+ 7 - 2
app/frontend/shared/components/Form/fields/FieldEditor/FieldEditorActionBar.vue

@@ -8,10 +8,11 @@ import { onKeyDown, useEventListener, whenever } from '@vueuse/core'
 import { nextTick, ref, toRef } from 'vue'
 import type { Ref } from 'vue'
 import useEditorActions from './useEditorActions'
-import type { EditorCustomPlugins } from './types'
+import type { EditorContentType, EditorCustomPlugins } from './types'
 
 const props = defineProps<{
   editor?: Editor
+  contentType: EditorContentType
   visible: boolean
   disabledPlugins: EditorCustomPlugins[]
 }>()
@@ -24,7 +25,11 @@ const emit = defineEmits<{
 const actionBar = ref<HTMLElement>()
 const editor = toRef(props, 'editor')
 
-const { actions, isActive } = useEditorActions(editor, props.disabledPlugins)
+const { actions, isActive } = useEditorActions(
+  editor,
+  props.contentType,
+  props.disabledPlugins,
+)
 
 const opacityGradientEnd = ref('0')
 const opacityGradientStart = ref('0')

+ 53 - 0
app/frontend/shared/components/Form/fields/FieldEditor/FieldEditorFooter.vue

@@ -0,0 +1,53 @@
+<!-- Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import type { ConfidentTake } from '@shared/types/utils'
+import { computed } from 'vue'
+import type { FieldEditorProps } from './types'
+
+interface Props {
+  footer: ConfidentTake<FieldEditorProps, 'meta.footer'>
+  characters: number
+}
+
+const props = defineProps<Props>()
+
+const availableCharactersCount = computed(() => {
+  const { maxlength } = props.footer
+  if (!maxlength) return 0
+  return maxlength - props.characters
+})
+</script>
+
+<template>
+  <div class="flex" data-test-id="editor-footer">
+    <span class="flex-1 ltr:pr-2 rtl:pl-2">{{ footer.text }}</span>
+    <span
+      v-if="footer.maxlength != null"
+      title="Available characters"
+      class="text-right"
+      :class="{
+        'text-red': availableCharactersCount < 0,
+        'text-orange':
+          footer.warningLength &&
+          availableCharactersCount >= 0 &&
+          availableCharactersCount < footer.warningLength,
+      }"
+    >
+      {{ availableCharactersCount }}
+    </span>
+    <span
+      v-if="footer.maxlength != null && availableCharactersCount < 0"
+      class="sr-only"
+      aria-atomic="true"
+      aria-live="polite"
+    >
+      {{
+        $t(
+          'You have exceeded the character limit by %s',
+          0 - availableCharactersCount,
+        )
+      }}
+    </span>
+  </div>
+</template>

+ 63 - 19
app/frontend/shared/components/Form/fields/FieldEditor/FieldEditorInput.vue

@@ -5,15 +5,22 @@ import type { FormFieldContext } from '@shared/components/Form/types/field'
 import { convertFileList } from '@shared/utils/files'
 import { useEditor, EditorContent } from '@tiptap/vue-3'
 import { useEventListener } from '@vueuse/core'
-import { ref, toRef, watch } from 'vue'
+import { computed, onUnmounted, ref, toRef, watch } from 'vue'
 import useValue from '../../composables/useValue'
-import getExtensions from './extensions/list'
+import {
+  getCustomExtensions,
+  getHtmlExtensions,
+  getPlainExtensions,
+} from './extensions/list'
 import type {
+  EditorContentType,
   EditorCustomPlugins,
   FieldEditorProps,
   PossibleSignature,
 } from './types'
 import FieldEditorActionBar from './FieldEditorActionBar.vue'
+import FieldEditorFooter from './FieldEditorFooter.vue'
+import { PLUGIN_NAME as userMentionPluginName } from './suggestions/UserMention'
 
 interface Props {
   context: FormFieldContext<FieldEditorProps>
@@ -30,11 +37,26 @@ const disabledPlugins = Object.entries(props.context.meta || {})
   .filter(([, value]) => value.disabled)
   .map(([key]) => key as EditorCustomPlugins)
 
-const editorExtensions = getExtensions(reactiveContext).filter(
-  (extension) =>
-    !disabledPlugins.includes(extension.name as EditorCustomPlugins),
+const contentType = computed<EditorContentType>(
+  () => props.context.contentType || 'text/html',
 )
 
+// remove user mention in plain text mode and inline images
+if (contentType.value === 'text/plain') {
+  disabledPlugins.push(userMentionPluginName, 'image')
+}
+
+const editorExtensions =
+  contentType.value === 'text/plain'
+    ? getPlainExtensions()
+    : getHtmlExtensions()
+
+getCustomExtensions(reactiveContext).forEach((extension) => {
+  if (!disabledPlugins.includes(extension.name as EditorCustomPlugins)) {
+    editorExtensions.push(extension)
+  }
+})
+
 const showActionBar = ref(false)
 const editor = useEditor({
   extensions: editorExtensions,
@@ -75,12 +97,10 @@ const editor = useEditor({
   editable: props.context.disabled !== true,
   content: currentValue.value,
   onUpdate({ editor }) {
-    const html = editor.getHTML()
-    if (html === '<p></p>') {
-      props.context.node.input('')
-    } else {
-      props.context.node.input(html)
-    }
+    const content =
+      contentType.value === 'text/plain' ? editor.getText() : editor.getHTML()
+    const value = content === '<p></p>' ? '' : content
+    props.context.node.input(value)
   },
   onFocus() {
     showActionBar.value = true
@@ -119,12 +139,20 @@ watch(
 // )
 
 // Set the new editor value, when it was changed from outside (e.G. form schema update).
-props.context.node.on('input', ({ payload: value }) => {
-  if (editor.value && value !== editor.value.getHTML()) {
+const updateValueKey = props.context.node.on('input', ({ payload: value }) => {
+  const currentValue =
+    contentType.value === 'text/plain'
+      ? editor.value?.getText()
+      : editor.value?.getHTML()
+  if (editor.value && value !== currentValue) {
     editor.value.commands.setContent(value, false)
   }
 })
 
+onUnmounted(() => {
+  props.context.node.off(updateValueKey)
+})
+
 const focusEditor = () => {
   const view = editor.value?.view
   view?.focus()
@@ -157,6 +185,13 @@ const removeSignature = () => {
   editor.value.chain().removeSignature().focus(currentPosition).run()
 }
 
+const characters = computed(() => {
+  if (!editor.value) return 0
+  return editor.value.storage.characterCount.characters({
+    node: editor.value.state.doc,
+  })
+})
+
 Object.assign(props.context, {
   addSignature,
   removeSignature,
@@ -164,14 +199,23 @@ Object.assign(props.context, {
 </script>
 
 <template>
-  <EditorContent
-    ref="editorVueInstance"
-    data-test-id="field-editor"
-    class="px-2 py-2"
-    :editor="editor"
-  />
+  <div class="p-2">
+    <EditorContent
+      ref="editorVueInstance"
+      data-test-id="field-editor"
+      :editor="editor"
+    />
+    <FieldEditorFooter
+      v-if="context.meta?.footer && !context.meta.footer.disabled && editor"
+      :footer="context.meta.footer"
+      :characters="characters"
+    />
+  </div>
+
+  <!-- TODO: questionable usability - it moves, when new line is added -->
   <FieldEditorActionBar
     :editor="editor"
+    :content-type="contentType"
     :visible="showActionBar"
     :disabled-plugins="disabledPlugins"
     @hide="showActionBar = false"

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