7.9. Руководство по настройке SmartInput

7.9.1. Назначение и общий функционал SmartInput

SmartInput - компонент быстрого ввода данных. Позволяет в текстовом поле создавать описание и заполнять дополнительные атрибуты через специальные теги.

Компонент использует конфигурацию, указанную в соответствующем внешнем скрипте (файл формата .js), который находится в реестре скриптов.

Параметры подключения внешних скриптов передаются в настройках компонента. Подробнее о настройке компонента (виджета) на Домашней страницы см. Руководство по настройке Домашней страницы.

7.9.2. Пример настройки функционала SmartInput на странице Новостной ленты

К компоненту подключается несколько внешних скриптов:

  • MAIN.
  • OPERATORS.
  • TOOLBAR.
  • SUBMIT.
  • VALIDATION.

7.9.2.1. Назначение конфигурационного файла MAIN

Это основной конфигурационный файл, он содержит перечень типов элементов (тегов), например, контактов, местоположений и т.п., с которыми работает компонент SmartInput, и методы для получения и обработки данных этих элементов (добавление в SmartInput, вставка текста и т.п.).

В конце файла «MAIN» находится структура «MAIN_CONFIG», которая считывается компонентом SmartInput.vue. В ней перечислены все типы элементов (теги) и методы, используемые для получения и обработки данных этих элементов.

Примечание

Сами методы описываются в начале файла «MAIN», затем в файле «MAIN» описывается структура «MAIN_CONFIG», в которой этим методы только перечисляются.

Пример структуры «MAIN_CONFIG» для одного из типов элементов (типа элемента «Контакт»):

...
    MAIN_CONFIG = {

      // Перечень типов данных (тегов)
      ITEM_TYPES: {
        // тип - контакт
        contact:{
          // Метод получения данных контакта
          source: getUserList,
          // Метод добавления выбранного результата (из меню или диалогового окна) в связанные данные SmartInput
          callback: addUser,
          // Метод вставки текста выбранного результата в поле редактора (поле для ввода текста)
          pasteToInput: pasteUserName,
          // Метод для изменения какого-либо свойства контакта в связанных данных
             (в текущей реализации Системы используется для смены статуса с клиента на заказчика)
          updateProperty:updateUser,
          // Метод проверки возможности удаления из связанных данных (в текущей реализации Системы
          используется для блокировки удаления единственного заказчика в запросе)
          removalCheck: checkUserRemoval
        },
      }
    }
...

Все другие типы элементов (и используемые ими методы) указываются в блоке «ITEM_TYPES» аналогично.

Пример настроек:

function MAIN_CONFIG($context) {
 // ! rename to main.js

 // this is also included in submitHandler.js
 const getCurrentUser = function () {
   try {
     return $context.$repos.schemaRepository('people').getMetadata().userInfo['ITSM']['ID']
   } catch (e) {
     return undefined
   }
 }

 // users (contacts)
 const getUserList = async function ({ str }) {

   const resp = await $context.$repos.schemaRepository('people').list({
     fields: ['full_name', 'internet_e-mail', 'phone_number_business', 'username'],
     offset: 0,
     maxRows: 10,
     clauses: [
       {
         operator: 'and',
         field: 'full_name',
         operand: 'contains',
         value: str
       },
       {
         operator: 'or',
         field: 'internet_e-mail',
         operand: 'contains',
         value: str
       },
       {
         operator: 'or',
         field: 'phone_number_business',
         operand: 'contains',
         value: str
       }
     ]
   })

   return resp
 }

 const addUser = function ({ $selectedData, $operator, $item }) {

   let newValue = ''
   let newId = ''
   let inSelected = $selectedData.filter(item => item.label === 'contact')

   for (let key in $item) {
     if (key !== 'id') {
       newValue = $item[key]
       newId = $item.id
       break
     }
   }

   if (inSelected.length) {

     // if ( inSelected.length === 1) {
     $selectedData.push({
       label: 'contact',
       value: newValue,
       id: newId,
       // ! weird logic - would work with max 2 persons only
       // ? which should be distinct contact or customer ???
       personStatus: inSelected[0].personStatus === 'contact' ? 'customer' : 'contact'
     })
     // }

   } else {

     $selectedData.push({
       label: 'contact',
       value: newValue,
       id: newId,
       personStatus: 'contact' === 'contact' ? 'customer' : ''
     })

   }

 }

 const pasteUserName = function (userData) {
   return userData["full_name"]


 }

 const updateUser = function ({ $selectedData, $itemUpdate }) {
   if ($itemUpdate.property === 'personStatus') {
     // change user personStatus (from contact to customer and vise-versa)

     // disallow unselecting customer
     if ($itemUpdate.item.personStatus === 'customer') return 'NewsFeed_SmartInput_ErrUnselectCustomer'

     // change status to customer and drop previous customer status
     let persons = $selectedData.filter(item => item.label === 'contact')
     persons.forEach(p => {
       if (p.personStatus === 'customer') {
         p.personStatus = 'contact'
       }
       if (p.id === $itemUpdate.item.id) {
         p.personStatus = 'customer'
       }
     })

     return true

   }
 }

 const checkUserRemoval = function ({ $selectedData, $item, $index }) {

   let customer = $selectedData.find(item => item.label === 'contact' && item.personStatus === 'customer')

   return (customer && customer.id && customer.id === $item.id) ? 'NewsFeed_SmartInput_ErrDelCustomer' : true

 }

 // hashtags (kb and others)
 const getHashTags = async function ({ str }) {
   const resp = await $context.$repos.schemaRepository('kb').list({
     fields: ['articletitle'],
     offset: 0,
     maxRows: 10,
     clauses: [
       {
         operator: 'and',
         field: 'articletitle',
         operand: 'contains',
         value: str
       },
       {
         operator: 'or',
         field: 'article_keywords',
         operand: 'contains',
         value: str
       }
     ]
   })

   return resp
 }

 const addTag = function ({ $selectedData, $operator, $item }) {

   // * keep all logic for this item type here
   // e.g. max amount of same type, item overwrite and other conditions
   $selectedData.push({
     label: $operator,
     value: $item
   })

 }

 const pasteTag = function (tagData) {
   return tagData["articletitle"]
 }

 // templates (catalog)

 const getTemplates = async function ({ str }) {
   try {
     let cat = []
     if ($context.$getters.currentCatalog.length) {
       cat = $context.$getters.currentCatalog
     } else {
       cat = await $context.$repos.userApps.catalog.get()
       $context.$repos.userApps.catalog.set(cat)
       // this.$store.dispatch('app/setCatalog', cat)
     }

     let result = { items: [] }
     if (cat.length) {
       cat.forEach(item => {
         if (item.items && item.items.length) {
           item.items.forEach(innerItem => {
             let comp1 = innerItem.name && (innerItem.name.toLowerCase().indexOf(str.toLowerCase()) !== -1)
             let comp2 = innerItem.keywords && (innerItem.keywords.toLowerCase().indexOf(str.toLowerCase()) !== -1)

             if (comp1 || comp2) {
               result.items.push({
                 id: innerItem.id,
                 name: innerItem.name,
                 form_id: innerItem.form_id
               })
             }
           })
         }
       })
     }

     return result.items.slice(0, 10)
   }
   catch (err) {
     throw err
   }
 }

 const addTemplate = function ({ $selectedData, $operator, $item }) {

   let newValue = ''
   let newId = ''
   let inSelected = $selectedData.filter(item => item.label === 'template')

   for (let key in $item) {
     if (key !== 'id') {
       newValue = $item[key]
       newId = $item.id
       break
     }
   }

   if (inSelected.length) {

     inSelected[0].value = newValue
     inSelected[0].id = newId


   } else {

     $selectedData.push({
       label: 'template',
       value: newValue,
       id: newId,
       form_id: $item["form_id"],
     })

   }

 }

 const pasteTemplate = function (templateData) {
   return templateData["name"]
 }

 // locations
 const getLocations = async function ({ str, $linkedData }) {
   try {
     if (!str) {
       const persons = $linkedData.filter(item => item.label === 'person')

       if (!persons.length) {
         persons.push({
           id: getCurrentUser,
           label: 'person',
           personStatus: 'customer',
           value: ''
         })
       }

       const resp = await $context.$repos.schemaRepository('people').list({
         field: 'id',
         distinct: true,
         value: persons.map(item => item.id),
         fields: ['site_country', 'site_city', 'site_street', 'site_zip_postal_code', 'site_id']
       })

       return resp.map(item => {
         return {
           country: item.site_country,
           city: item.site_city,
           street: item.site_street,
           zip_postal_code: item.zip_postal_code,
           id: item.site_id,
           selected: false
         }
       })

     } else {

       const resp = await $context.$repos.schemaRepository('site').list({
         fields: ['country', 'city', 'street', 'zip_postal_code', 'id'],
         offset: 0,
         clauses: [
           {
             operator: 'and',
             field: 'country',
             operand: 'contains',
             value: str
           },
           {
             operator: 'or',
             field: 'city',
             operand: 'contains',
             value: str
           },
           {
             operator: 'or',
             field: 'street',
             operand: 'contains',
             value: str
           }
         ]
       })

       return resp.map(item => {
         item.selected = false
         return item
       })

     }

   } catch (err) {
     throw err
   }
 }

 const addLocation = function ({ $selectedData, $operator, $item }) {

   let inSelected = $selectedData.filter(item => item.label === 'location')
   let addressString = `${$item.country ? $item.country : ''} ${$item.city ? $item.city : ''} ${$item.street ? $item.street : ''}`

   if (inSelected.length) {
     inSelected[0].value = addressString
     inSelected[0].id = $item.id
   } else {
     $selectedData.push({
       label: 'location',
       value: addressString,
       id: $item.id
     })
   }

 }

 // used in `getCi()`
 const setUniqueMatches = function (arr) {
   let matchedCis = []
   let obj = {}

   arr.forEach(item => {
     if (item.reconciliationidentity in obj) return

     obj[item.reconciliationidentity] = 1
     matchedCis.push({
       type: item.type,
       name: item.name,
       model: item.model,
       manufacturer: item.manufacturername,
       people_id: item.peoplegroup_form_entry_id,
       ci_id: item.reconciliationidentity,
       selected: false
     })
   })
   return matchedCis
 }

 const getCi = async function ({ str, $linkedData }) {
   try {
     if (!str) {
       const persons = $linkedData.filter(item => item.label === 'person')

       if (!persons.length) {
         persons.push({
           id: getCurrentUser,
           label: 'person',
           personStatus: 'customer',
           value: ''
         })
       }

       const resp = await $context.$repos.schemaRepository('ci_people_assoc').list({
         fields: [
           'peoplegroup_form_entry_id',
           'manufacturername',
           'type',
           'model',
           'name',
           'reconciliationidentity'
         ],
         field: 'id',
         distinct: true,
         value: persons.map(item => item.id)
       })

       return setUniqueMatches(resp)

       // * displays contact names above search field
       // this.personNames = ''
       // persons.forEach(item => {
       //     this.personNames += item.value + ' '
       // })

     } else {

       const resp = await $context.$repos.schemaRepository('ci_people_assoc').list({
         fields: [
           'peoplegroup_form_entry_id',
           'manufacturername',
           'type',
           'name',
           'model',
           'reconciliationidentity'
         ],
         offset: 0,
         clauses: [
           {
             operator: 'and',
             field: 'type',
             operand: 'contains',
             value: str
           },
           {
             operator: 'or',
             field: 'model',
             operand: 'contains',
             value: str
           },
           {
             operator: 'or',
             field: 'name',
             operand: 'contains',
             value: str
           }
         ]
       })

       return setUniqueMatches(resp)
     }
   } catch (err) {
     throw err
   }
 }

 const addCi = function ({ $selectedData, $operator, $item }) {

   let inSelected = $selectedData.filter(item => item.label === 'ci')
   let descString = `${$item.type ? $item.type : ''} ${$item.name ? $item.name : ''} ${$item.model ? $item.model : ''} ${$item.manufacturer ? $item.manufacturer : ''}`

   if (inSelected.length) {
     inSelected[0].value = descString
     inSelected[0].id = $item.id
   } else {
     $selectedData.push({
       label: 'ci',
       value: descString,
       id: $item.ci_id
     })
   }
 }

 function getResultItems(operators = {}) {

     let result_operators = {}

     Object.keys(operators).forEach(operator_key=>{
       Object.assign(result_operators, {[operator_key]:{}})
       Object.keys(operators[operator_key]).forEach(method_key=>{
         if(method_key!='key') {
           if(operators[operator_key][method_key].length>0) result_operators[operator_key][method_key] =  eval(operators[operator_key][method_key])
         }
       })
     })

     return {
       ITEM_TYPES: {
           contact: {
             source: getUserList,
             callback: addUser,
             pasteToInput: pasteUserName,
             updateProperty: updateUser,
             removalCheck: checkUserRemoval
           },
           hashtag: {
             source: getHashTags,
             callback: addTag,
             pasteToInput: pasteTag,
           },
           template: {
             source: getTemplates,
             callback: addTemplate,
             pasteToInput: pasteTemplate,
           },
         location: {
           source: getLocations,
           callback: addLocation,
         },
         ci: {
           source: getCi,
           callback: addCi,
         },
         attachment: {},
           ...result_operators
         },
         // use this form ID if templates isn't provided
       DEFAULT_FORM_ID: 'schm001_form_0001',

       // * simple creation mode
       // attempt to create entry on submit and DO NOT redirect to that entry result page
       simpleMode: true

     }
 }

 return getResultItems($context.customOperators)

}

7.9.2.2. Назначение конфигурационного файла OPERATORS

В файле «OPERATORS» указываются специальные символы для ввода тегов. Например на странице Новостной ленты компонент SmartInput.vue использует следующие тэги:

  • @ -контакт (contact).
  • # - запись базы знаний (hashtag).
  • ! - шаблон (template).

Пример настроек:

function OPERATORS_CONFIG({customOperators}) {
 return {
  OPERATORS: {
    contact: {
      key: '@',
        },
    hashtag: {
      key: '#',
    },
    template: {
      key: '!',
    },
    ...customOperators
  }
}
}

7.9.2.3. Назначение конфигурационного файла TOOLBAR

Файл «TOOLBAR» содержит конфигурацию, которая описывает тулбар (панель инструментов). Здесь перечисляются настройки каждого элемента тулбара (definitions) и их порядок (toolbar). Свойства элементов тулбара аналогичны одноименным свойствам компонента «QEditor» в «Quasar Framework».

В файле «TOOLBAR» для каждого элемента тулбара (кнопки) нужно указать метод обработки клика в формате (в текущей реализации Системы этот метод одинаков для всех элементов тулбара):

...
  const handleTagClick = function (){
    $vm.operatorCallback({type:'hashtag'})
  }
...

Здесь в параметре «type» указывается название оператора (оно должно совпадать с его названием в конфигурационном файле «OPERATORS»).

При клике по элементу тулбара выполняется запрос данных по этому типу (значению, указанному в параметре «type») и отображается список/диалоговое окно с результатами для выбора. Привязка типа к компоненту (списку/диалоговому окну) в текущей реализации Системы жестко прописала в компоненте SmartInput.vue (без возможности настройки).

В текущей реализации Системы по клику на элемент тулбара реализовано отображение только простых списков. В дальнейшем при клике на элемент тулбара можно будет настроить выбор метроположения на карте, фильтрацию, быструю вставку связанных данных и т.п.

Пример настроек:

function toolbar_config({ icons, layout, customDefinitions }) {
return function ({ $vm }) {

  const toolbarItemHandler = function (mouseEvent, Proxy, Caret) {
    // mouseEvent - normal mouse event
    // Proxy - handler, target ???
    // Caret - cursor in editor (HTML, vue component and range)
  }

  const handleContactClick = function () {
    $vm.operatorCallback({ type: 'contact' })
  }

  const handleTagClick = function () {
    $vm.operatorCallback({ type: 'hashtag' })
  }

  const handleTemplateClick = function () {
    $vm.operatorCallback({ type: 'template' })
  }


  const handleLocationClick = function () {
    $vm.operatorCallback({ type: 'location' })
  }
  const handleCiClick = function () {
    $vm.operatorCallback({ type: 'ci' })
  }
  const handleAttachmentClick = function () {
    // ? use q-file
  }

  const submitData = function () {
    $vm.submitData()
  }

  const DEFAULT_ICONS = {
    hashtag: 'book2',
    location: 'book'
  }

  function setDefinitions({ icons = DEFAULT_ICONS }) {

    if(customDefinitions)
    {
      Object.keys(customDefinitions).forEach(def_key=>{
        if(customDefinitions[def_key]!==undefined) customDefinitions[def_key].handler = eval(customDefinitions[def_key].handler)
      })
    }

    return {
       location: {
        icon: 'location_on',
        handler: handleLocationClick,
        type: 'no-state'
      },
      ci: {
        icon: 'storage',
        handler: handleCiClick,
        type: 'no-state'
      },
      submit: {
        handler: submitData,
        type: 'no-state'
      },
      attachment: {
        icon: icons.attachment || DEFAULT_ICONS.attachment,
        handler: handleAttachmentClick,
        type: 'no-state'
      },
      hashtag: {
        icon: icons.hashtag || DEFAULT_ICONS.hashtag,
        handler: handleTagClick,
        type: 'no-state'
      },
      contact: {
        icon: icons.contact || DEFAULT_ICONS.contact,
        handler: handleContactClick,
        type: 'no-state'
      },
      template: {
        icon: icons.template || DEFAULT_ICONS.template,
        handler: handleTemplateClick,
        type: 'no-state'
      },
      ...customDefinitions
    }
  }

  const DEFAULT_LAYOUT = [['location', 'ci'], ['attachment', 'hashtag', 'contact', 'template'], ['submit'], ['new_button']]
  function setToolbarLayout({ layout }) {
    return layout || DEFAULT_LAYOUT
  }

  return {
    definitions: setDefinitions({ icons }),
    // toolbar btn layout
    toolbar: setToolbarLayout({ layout })
  }

}
  }

7.9.2.4. Назначение конфигурационного файла SUBMIT

В файле «SUBMIT» описывается метод для кнопки «Отправить» на панели инструментов. Метод описывает формирование данных создаваемой формы из связаных данных и текста, введенного в поле ввода.

Связанные данные и текст, введенный в поле ввода, перед открытием создаваемой формы должны быть отформатированы. В файле «SUBMIT» отписываются правила форматирования этих данных.

В конце файла «SUBMIT» должен быть указан метод обработки данных формы в формате «submit:methodName». Например:

...
smartInputSubmitter = {
  submit: createNewEvent,
}
...

Здесь: «createNewElement» - название метода формирования данных формы из этого же скрипта.

Данный метод (в примере это метод «createNewElement») должен вернуть структуру формата:

...
{
  // данные формы в формате "имя_поля": "значение"
  data:{...},
  // опционально: идентификатор формы
  form_id: "FORM_ID"
}
...

При отсутствии идентификатора формы выбирается значение, указанное в свойстве «DEFAULT_FORM_ID» в конфигугационном файле «MAIN».

Пример настроек:

function smartInputSubmitter($context) {
 const getCurrentUser = function () {
   try {
     return $context.$repos.schemaRepository('people').getMetadata().userInfo['ITSM']['ID']
   } catch (e) {
     return undefined
   }
 }
 const createNewEvent = async function (description, links) {
   try {
     let persons = links.filter(item => item.label === 'contact')
     let templates = links.filter(item => item.label === 'template')
     let locations = links.filter(item => item.label === 'location')
     let cis = links.filter(item => item.label === 'ci')

     if (!persons.length) {
       persons.push({
         personStatus: 'customer',
         id: getCurrentUser
       })
     }
     if (persons.length === 1) {
       if (persons[0].personStatus === 'customer') {
         persons.push({
           personStatus: 'contact',
           id: persons[0].id
         })
       } else {
         persons.push({
           personStatus: 'customer',
           id: getCurrentUser
         })
       }
     }

     if (templates.length) {

       let query = {}

       persons.forEach((item, i) => {
         query[item.personStatus] = item.id
       })
       query.description = description
       if (locations.length) query.location = locations[0].id
       if (cis.length) query.ci = cis[0].id

       return {
         form_id: templates[0].form_id,
         data: query
       }

     } else {

       return await createNewImcident({ persons, locations, cis, description, links })
     }
   } catch (e) {
     throw e
     return e
   }

 }

 const createNewIncident = async function ({ persons, locations, cis, description, links }) {
   try {
     let requestObj = {
       // Для корректного заполнения на backend
       "incident_number": "$NULL$",
       //        "contact_company": "$NULL$",
       //        "priority": "$NULL$",
       // могут быть изменены данными из шаблона
       "impact": "4000",
       "urgency": "4000",
       "priority": "3",
       "service_type": "0",
       // Точно известные на текущий момент данные
       "description": description,
     }
     // ? personData is not used
     let personData = links.filter(item => item.label === "person")
     let customer = persons.filter(item => item.personStatus === 'customer')[0]

     let resp = await $context.$repos.schemaRepository('people').list({
       field: 'id',
       value: customer.id,
       fields: ['company', 'phone_number_business']
     })
     requestObj.person_id = customer.id
     requestObj.customer = customer.value
     requestObj.company = resp[0].company
     requestObj.contact_company = resp[0].company
     requestObj.phone_number = resp[0].phone_number_business
     requestObj.description = description

     if (locations.length) requestObj.site_id = locations[0].id
     if (cis.length) requestObj.hpd_ci_reconid = cis[0].id

     // resp = await $context.$repos.schemaRepository('incidents').create(requestObj)

     return {
       // ! exclude `form_id` - component will read default value from config
       data: requestObj
     }

   } catch (e) {
     throw e
   }

 }
 return {
   submit: createNewEvent,
 }
}

7.9.2.5. Назначение конфигурационного файла VALIDATION

В файле «VALIDATION» описываются правила валидации связаных данных. Для компонента SmartInput.vue на странице Новостной ленты используется, например, следующиая валидация: проверяется количество контактов (максимум два), количество шаблонов и местоположений.

Формат валидации (правила валидации) такой же, как у валидации полей в «Quasar Framework».

Пример настроек:

function VALIDATION_RULES($context) {
 const maxTemplatesAllowed = function (links) {
   return links.filter(item => item.label === 'template').length <= 1 || 'NewsFeed_SmartInput_TemplatesMax'
 }

 const maxContactsAllowed = function (links) {
   return links.filter(item => item.label === 'contact').length <= 2 || 'NewsFeed_SmartInput_ContactsMax'
 }

 const maxPlacesAllowed = function (links) {
   return links.filter(item => item.label === 'location').length <= 1 || 'NewsFeed_SmartInput_LocationsMax'
 }

 const mustBeOnlyOneClient = function (links) {
   return links.filter(item => item.label === 'contact').filter(item => item.personStatus === 'customer').length === 1 || 'NewsFeed_SmartInput_MustBeOnlyOneClient'
 }
 return [
   maxTemplatesAllowed,
   maxContactsAllowed,
   maxPlacesAllowed,
   mustBeOnlyOneClient,
 ]
}