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,
]
}