Initial knowledge base support.

This commit is contained in:
Martin Edenhofer 2019-06-04 05:40:48 +02:00
parent 5d2398a3aa
commit 97d14a93b3
308 changed files with 37506 additions and 505 deletions

View file

@ -57,7 +57,7 @@ rules:
no-case-declarations: 2 no-case-declarations: 2
no-div-regex: 2 no-div-regex: 2
no-else-return: 0 no-else-return: 0
no-empty-label: 2 no-labels: 2
no-empty-pattern: 2 no-empty-pattern: 2
no-eq-null: 2 no-eq-null: 2
no-eval: 2 no-eval: 2
@ -69,7 +69,6 @@ rules:
no-implied-eval: 2 no-implied-eval: 2
no-invalid-this: 0 no-invalid-this: 0
no-iterator: 2 no-iterator: 2
no-labels: 0
no-lone-blocks: 2 no-lone-blocks: 2
no-loop-func: 2 no-loop-func: 2
no-magic-number: 0 no-magic-number: 0

View file

@ -31,6 +31,9 @@ gem 'eventmachine'
# core - password security # core - password security
gem 'argon2', '1.1.5' gem 'argon2', '1.1.5'
# core - state machine
gem 'aasm'
# performance - Memcached # performance - Memcached
gem 'dalli' gem 'dalli'
@ -105,6 +108,9 @@ gem 'telephone_number'
# feature - SMS # feature - SMS
gem 'twilio-ruby' gem 'twilio-ruby'
# feature - ordering
gem 'acts_as_list'
# integrations # integrations
gem 'clearbit' gem 'clearbit'
gem 'net-ldap' gem 'net-ldap'
@ -136,7 +142,9 @@ group :development, :test do
gem 'pry-stack_explorer' gem 'pry-stack_explorer'
# test frameworks # test frameworks
gem 'rails-controller-testing'
gem 'rspec-rails' gem 'rspec-rails'
gem 'shoulda-matchers'
gem 'test-unit' gem 'test-unit'
# test DB # test DB

View file

@ -47,6 +47,8 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
aasm (5.0.0)
concurrent-ruby (~> 1.0)
actioncable (5.1.7) actioncable (5.1.7)
actionpack (= 5.1.7) actionpack (= 5.1.7)
nio4r (~> 2.0) nio4r (~> 2.0)
@ -94,6 +96,8 @@ GEM
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
minitest (~> 5.1) minitest (~> 5.1)
tzinfo (~> 1.1) tzinfo (~> 1.1)
acts_as_list (0.9.16)
activerecord (>= 3.0)
addressable (2.5.2) addressable (2.5.2)
public_suffix (>= 2.0.2, < 4.0) public_suffix (>= 2.0.2, < 4.0)
arel (8.0.0) arel (8.0.0)
@ -374,6 +378,10 @@ GEM
bundler (>= 1.3.0) bundler (>= 1.3.0)
railties (= 5.1.7) railties (= 5.1.7)
sprockets-rails (>= 2.0.0) sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.4)
actionpack (>= 5.0.1.x)
actionview (>= 5.0.1.x)
activesupport (>= 5.0.1.x)
rails-dom-testing (2.0.3) rails-dom-testing (2.0.3)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
nokogiri (>= 1.6) nokogiri (>= 1.6)
@ -450,6 +458,8 @@ GEM
childprocess (>= 0.5, < 2.0) childprocess (>= 0.5, < 2.0)
rubyzip (~> 1.2, >= 1.2.2) rubyzip (~> 1.2, >= 1.2.2)
shellany (0.0.1) shellany (0.0.1)
shoulda-matchers (4.0.1)
activesupport (>= 4.2.0)
simple_oauth (0.3.1) simple_oauth (0.3.1)
simplecov (0.16.1) simplecov (0.16.1)
docile (~> 1.1) docile (~> 1.1)
@ -530,9 +540,11 @@ PLATFORMS
ruby ruby
DEPENDENCIES DEPENDENCIES
aasm
activerecord-import activerecord-import
activerecord-nulldb-adapter activerecord-nulldb-adapter
activerecord-session_store activerecord-session_store
acts_as_list
argon2 (= 1.1.5) argon2 (= 1.1.5)
autodiscover! autodiscover!
autoprefixer-rails autoprefixer-rails
@ -592,6 +604,7 @@ DEPENDENCIES
puma puma
rack-livereload rack-livereload
rails (= 5.1.7) rails (= 5.1.7)
rails-controller-testing
rails-observers rails-observers
rb-fsevent rb-fsevent
rchardet (>= 1.8.0) rchardet (>= 1.8.0)
@ -603,6 +616,7 @@ DEPENDENCIES
rubyntlm! rubyntlm!
sassc-rails sassc-rails
selenium-webdriver selenium-webdriver
shoulda-matchers
simplecov simplecov
simplecov-rcov simplecov-rcov
slack-notifier slack-notifier

View file

@ -163,3 +163,28 @@ Source: https://gist.github.com/sbrin/6801034
Copyright: 2015, sbrin - https://github.com/sbrin Copyright: 2015, sbrin - https://github.com/sbrin
License: MIT license License: MIT license
----------------------------------------------------------------------------- -----------------------------------------------------------------------------
ant-design icon font
Source: https://github.com/ant-design/ant-design
Copyright: 2015-present Alipay.com, https://www.alipay.com/
License: MIT license
-----------------------------------------------------------------------------
Font Awesome icon font
Source: http://fontawesome.io/
Copyright: Font Awesome by Dave Gandy - http://fontawesome.io
License: SIL OFL 1.1
-----------------------------------------------------------------------------
Simple line icons font
Source: https://github.com/thesabbir/simple-line-icons
Copyright: 2016 Sabbir Ahmed & All Contributors
License: MIT license
-----------------------------------------------------------------------------
Ionicons icon font
Source: https://github.com/ionic-team/ionicons
Copyright: 2016 Drifty (http://drifty.com/)
License: MIT license
-----------------------------------------------------------------------------
Material icon font
Source: https://github.com/google/material-design-icons
Copyright: Google
License: Apache License, Version 2.0
-----------------------------------------------------------------------------

View file

@ -29,8 +29,13 @@
"url": "", "url": "",
"license": "MIT" "license": "MIT"
}, },
"reply.svg": {
"author": "Felix Niklas",
"url": "",
"license": "MIT"
},
"weibo-button.svg": { "weibo-button.svg": {
"author": "", "author": "Weibo",
"url": "", "url": "",
"license": "" "license": ""
}, },
@ -159,11 +164,46 @@
"url": "", "url": "",
"license": "MIT" "license": "MIT"
}, },
"rearange.svg": {
"author": "Felix Niklas",
"url": "",
"license": "MIT"
},
"external.svg": {
"author": "Felix Niklas",
"url": "",
"license": "MIT"
},
"mood-sad.svg": { "mood-sad.svg": {
"author": "Felix Niklas",
"url": "",
"license": "MIT"
},
"radio.svg": {
"author": "Zammad", "author": "Zammad",
"url": "", "url": "",
"license": "MIT" "license": "MIT"
}, },
"radio-checked.svg": {
"author": "Zammad",
"url": "",
"license": "MIT"
},
"knowledge-base.svg": {
"author": "Felix Niklas",
"url": "",
"license": "MIT"
},
"eye.svg": {
"author": "Felix Niklas",
"url": "",
"license": "MIT"
},
"document.svg": {
"author": "Felix Niklas",
"url": "",
"license": "MIT"
},
"low-priority.svg": { "low-priority.svg": {
"author": "Felix Niklas", "author": "Felix Niklas",
"url": "", "url": "",
@ -180,9 +220,9 @@
"license": "MIT" "license": "MIT"
}, },
"inactive-user.svg": { "inactive-user.svg": {
"author": "R\u00e9my M\u00e9dard", "author": "Felix Niklas",
"url": "https:\/\/thenounproject.com\/search\/?q=user&i=10314", "url": "",
"license": "CC 3.0 Attribution" "license": "MIT"
}, },
"inactive-organization.svg": { "inactive-organization.svg": {
"author": "Felix Niklas", "author": "Felix Niklas",
@ -194,6 +234,16 @@
"url": "", "url": "",
"license": "MIT" "license": "MIT"
}, },
"important.svg": {
"author": "Felix Niklas",
"url": "",
"license": "MIT"
},
"reply-all.svg": {
"author": "Felix Niklas",
"url": "",
"license": "MIT"
},
"paperclip.svg": { "paperclip.svg": {
"author": "Cheesefork", "author": "Cheesefork",
"url": "https:\/\/thenounproject.com\/search\/?q=attachment&i=197956", "url": "https:\/\/thenounproject.com\/search\/?q=attachment&i=197956",
@ -204,6 +254,21 @@
"url": "", "url": "",
"license": "MIT" "license": "MIT"
}, },
"lock.svg": {
"author": "Zammad",
"url": "",
"license": "MIT"
},
"lock-open.svg": {
"author": "Zammad",
"url": "",
"license": "MIT"
},
"forward.svg": {
"author": "Felix Niklas",
"url": "",
"license": "MIT"
},
"file-word.svg": { "file-word.svg": {
"author": "Felix Niklas", "author": "Felix Niklas",
"url": "", "url": "",
@ -220,9 +285,9 @@
"license": "MIT" "license": "MIT"
}, },
"file-pdf.svg": { "file-pdf.svg": {
"author": "Felix Niklas", "author": "Adobe",
"url": "", "url": "",
"license": "MIT" "license": ""
}, },
"file-excel.svg": { "file-excel.svg": {
"author": "Felix Niklas", "author": "Felix Niklas",
@ -244,28 +309,8 @@
"url": "", "url": "",
"license": "MIT" "license": "MIT"
}, },
"reply.svg": {
"author": "Felix Niklas",
"url": "",
"license": "MIT"
},
"reply-all.svg": {
"author": "Felix Niklas",
"url": "",
"license": "MIT"
},
"lock.svg": {
"author": "Zammad",
"url": "",
"license": "MIT"
},
"forward.svg": {
"author": "Felix Niklas",
"url": "",
"license": "MIT"
},
"office365-button.svg": { "office365-button.svg": {
"author": "", "author": "Office 365",
"url": "", "url": "",
"license": "" "license": ""
}, },
@ -589,6 +634,31 @@
"url": "", "url": "",
"license": "MIT" "license": "MIT"
}, },
"checkmark.svg": {
"author": "Zammad",
"url": "",
"license": "MIT"
},
"chain.svg": {
"author": "Felix Niklas",
"url": "",
"license": "MIT"
},
"bold.svg": {
"author": "Felix Niklas",
"url": "",
"license": "MIT"
},
"checkbox.svg": {
"author": "Zammad",
"url": "",
"license": "MIT"
},
"checkbox-indeterminate.svg": {
"author": "Felix Niklas",
"url": "",
"license": "MIT"
},
"checkbox-checked.svg": { "checkbox-checked.svg": {
"author": "Zammad", "author": "Zammad",
"url": "", "url": "",
@ -614,11 +684,6 @@
"url": "", "url": "",
"license": "MIT" "license": "MIT"
}, },
"checkmark.svg": {
"author": "Zammad",
"url": "",
"license": "MIT"
},
"chat.svg": { "chat.svg": {
"author": "Felix Niklas", "author": "Felix Niklas",
"url": "", "url": "",

View file

@ -88,8 +88,10 @@ class App.Controller extends Spine.Controller
for callId in idsToCancel for callId in idsToCancel
App.Ajax.abort(callId) App.Ajax.abort(callId)
# release Spine's event handling
release: -> release: ->
# release custom bindings after it got removed from dom @off()
@stopListening()
# add @title methode to set title # add @title methode to set title
title: (name, translate = false) -> title: (name, translate = false) ->
@ -452,6 +454,7 @@ class App.ControllerModal extends App.Controller
buttonCancel: false buttonCancel: false
buttonCancelClass: 'btn--text btn--subtle' buttonCancelClass: 'btn--text btn--subtle'
buttonSubmit: true buttonSubmit: true
includeForm: true
headPrefix: '' headPrefix: ''
shown: true shown: true
closeOnAnyClick: false closeOnAnyClick: false
@ -516,6 +519,7 @@ class App.ControllerModal extends App.Controller
buttonClass: @buttonClass buttonClass: @buttonClass
centerButtons: @centerButtons centerButtons: @centerButtons
leftButtons: @leftButtons leftButtons: @leftButtons
includeForm: @includeForm
)) ))
modal.find('.modal-body').html(content) modal.find('.modal-body').html(content)
if !@initRenderingDone if !@initRenderingDone
@ -554,18 +558,19 @@ class App.ControllerModal extends App.Controller
if @small if @small
@el.addClass('modal--small') @el.addClass('modal--small')
@el.modal( @el
keyboard: @keyboard .on(
show: true 'show.bs.modal': @localOnShow
backdrop: @backdrop 'shown.bs.modal': @localOnShown
container: @container 'hide.bs.modal': @localOnClose
).on( 'hidden.bs.modal': @localOnClosed
'show.bs.modal': @localOnShow 'dismiss.bs.modal': @localOnCancel
'shown.bs.modal': @localOnShown ).modal(
'hide.bs.modal': @localOnClose keyboard: @keyboard
'hidden.bs.modal': @localOnClosed show: true
'dismiss.bs.modal': @localOnCancel backdrop: @backdrop
) container: @container
)
if @closeOnAnyClick if @closeOnAnyClick
@el.on('click', => @el.on('click', =>
@ -604,7 +609,7 @@ class App.ControllerModal extends App.Controller
onShown: (e) => onShown: (e) =>
if @autoFocusOnFirstInput if @autoFocusOnFirstInput
@$('input:not([disabled]):not([type="hidden"]):not(".btn"), textarea').first().focus() @$('input:not([disabled]):not([type="hidden"]):not(".btn"):not([type="radio"]:not(:checked)), textarea').first().focus()
@initalFormParams = @formParams() @initalFormParams = @formParams()
localOnClose: (e) => localOnClose: (e) =>

View file

@ -1,4 +1,9 @@
class App.ControllerForm extends App.Controller class App.ControllerForm extends App.Controller
fullFormSubmitLabel: 'Submit'
fullFormSubmitAdditionalClasses: ''
fullFormButtonsContainerClass: ''
fullFormAdditionalButtons: [] # [{className: 'js-class', text: 'Label'}]
constructor: (params) -> constructor: (params) ->
super super
for key, value of params for key, value of params
@ -71,7 +76,9 @@ class App.ControllerForm extends App.Controller
App.Log.debug 'ControllerForm', 'formGen', @model.configure_attributes App.Log.debug 'ControllerForm', 'formGen', @model.configure_attributes
# check if own fieldset should be generated # check if own fieldset should be generated
if @noFieldset # forced when the form is a grid form because flex-wrap doesn't work on fieldsets
# source: https://github.com/philipwalton/flexbugs#9-some-html-elements-cant-be-flex-containers
if @noFieldset || @grid
fieldset = @el fieldset = @el
else else
fieldset = $('<fieldset></fieldset>') fieldset = $('<fieldset></fieldset>')
@ -127,7 +134,24 @@ class App.ControllerForm extends App.Controller
if @fullForm if @fullForm
if !@formClass if !@formClass
@formClass = '' @formClass = ''
fieldset = $('<form class="' + @formClass + '" autocomplete="off"><button class="btn">' + App.i18n.translateContent('Submit') + '</button></form>').prepend(fieldset)
fieldset = $("<form class='form #{@formClass}' autocomplete='off'>").prepend(fieldset)
container = $("<div class='form-buttons #{@fullFormButtonsContainerClass}'>")
for buttonConfig in @fullFormAdditionalButtons
btn = $("<button class='btn #{buttonConfig.className}'>").text(buttonConfig.text)
if buttonConfig.disabled
btn.prop('disabled', true)
container.append(btn)
$("<button type=submit class='btn #{@fullFormSubmitAdditionalClasses}\' value=\"#{@fullFormSubmitLabel}\"></button>")
.text(App.i18n.translateContent(@fullFormSubmitLabel))
.appendTo(container)
container.appendTo(fieldset)
#fieldset = $("<form class=\"#{@formClass}\" autocomplete=\"off\"><div class='horizontal #{@fullFormButtonsContainerClass}'><input type=submit class=\"btn #{@fullFormSubmitAdditionalClasses}\" value=\"#{label}\"></div></form>").prepend(fieldset)
#fieldset = $("<form class=\"#{@formClass}\" autocomplete=\"off\"><input type=submit class=\"btn #{@fullFormSubmitAdditionalClasses}\" value=\"#{label}\"></form>").prepend(fieldset)
# bind form events # bind form events
if @events if @events
@ -258,11 +282,15 @@ class App.ControllerForm extends App.Controller
# set params value # set params value
if @params if @params
# check if we have a references
parts = attribute.name.split '::' parts = attribute.name.split '::'
if parts[0] && parts[1]
if @params[ parts[0] ] && parts[1] of @params[ parts[0] ] if parts.length > 1
attribute.value = @params[ parts[0] ][ parts[1] ] deepValue = parts.reduce((memo, elem) ->
memo?[elem]
, @params)
if deepValue isnt undefined
attribute.value = deepValue
# set params value to default # set params value to default
if attribute.name of @params if attribute.name of @params
@ -426,11 +454,16 @@ class App.ControllerForm extends App.Controller
) )
# get all params of the form # get all params of the form
@params: (form) -> # set clearAccessories to true to remove inline image resizing handles
@params: (form, clearAccessories = false) ->
param = {} param = {}
lookupForm = @findForm(form) lookupForm = @findForm(form)
if clearAccessories
# remove inline image resizing handles
lookupForm.find('.richtext.form-control').trigger('click')
# get contenteditable # get contenteditable
for element in lookupForm.find('[contenteditable]') for element in lookupForm.find('[contenteditable]')
name = $(element).data('name') name = $(element).data('name')
@ -656,6 +689,9 @@ class App.ControllerForm extends App.Controller
# set forms to read only during communication with backend # set forms to read only during communication with backend
lookupForm.find('button, input, select, textarea').prop('readonly', true) lookupForm.find('button, input, select, textarea').prop('readonly', true)
# disable radio and checbkox buttons
lookupForm.find('input[type=checkbox], input[type=radio]').prop('disabled', true)
# disable additionals submits # disable additionals submits
lookupForm.find('button').prop('disabled', true) lookupForm.find('button').prop('disabled', true)
else else
@ -678,6 +714,9 @@ class App.ControllerForm extends App.Controller
# enable fields again # enable fields again
lookupForm.find('button, input, select, textarea').prop('readonly', false) lookupForm.find('button, input, select, textarea').prop('readonly', false)
# enable radio and checbkox buttons
lookupForm.find('input[type=checkbox], input[type=radio]').prop('disabled', false)
# enable submits again # enable submits again
lookupForm.find('button').prop('disabled', false) lookupForm.find('button').prop('disabled', false)
else else

View file

@ -403,6 +403,8 @@ class App.ControllerTabs extends App.Controller
subHeader: @subHeader subHeader: @subHeader
tabs: @tabs tabs: @tabs
addTab: @addTab addTab: @addTab
headerSwitchName: @headerSwitchName
headerSwitchChecked: @headerSwitchChecked
) )
# insert content # insert content

View file

@ -0,0 +1,51 @@
class App.ControllerReorderModal extends App.ControllerModal
head: 'Drag to reorder'
content: ->
view = $(App.view('reorder_modal')())
table = new App.ControllerTable(
baseColWidth: null
dndCallback: ->
true
overview: ['title']
attribute_list: [
{ name: 'title', display: 'Name' }
]
objects: @items
)
view.find('.js-table-container').html(table.el)
view
onShown: ->
super
@$('.js-submit').focus()
save: ->
ids = @$('tr.item').toArray().map (el) -> parseInt(el.dataset.id)
@$('.alert').addClass('hidden')
@formDisable(@el)
@ajax(
id: 'reorder_save'
type: 'PATCH'
data: JSON.stringify({ordered_ids: ids})
url: @url
processData: true
success: (data, status, xhr) =>
App.Collection.loadAssets(data)
App.Event.trigger 'knowledge_base::sidebar::rerender'
@close()
error: (xhr) =>
data = JSON.parse(xhr.responseText)
@$('.alert--danger').removeClass('hidden').text(data.error)
@formEnable(@el)
)
onSubmit: ->
super
@save()

View file

@ -116,6 +116,7 @@ class App.ControllerTable extends App.Controller
shownPage: 0 shownPage: 0
destroy: false destroy: false
customActions: []
columnsLength: undefined columnsLength: undefined
headers: undefined headers: undefined
@ -544,7 +545,7 @@ class App.ControllerTable extends App.Controller
# get header data # get header data
@headers = [] @headers = []
@actions = [] @actions = [].concat @customActions
availableWidth = @availableWidth availableWidth = @availableWidth
for item in @overviewAttributes for item in @overviewAttributes
headerFound = false headerFound = false

View file

@ -0,0 +1,130 @@
class App.ManageKnowledgeBase extends App.ControllerTabs
header: 'Knowledge Base'
headerSwitchName: 'kb-activate'
events:
'hidden.bs.tab li': 'didHideTab'
'show.bs.tab li': 'willShowTab'
'change .js-header-switch input': 'didChangeHeaderSwitch'
elements:
'.js-header-switch input': 'headerSwitchInput'
didHideTab: (e) ->
selector = $(e.relatedTarget).attr('href')
@$(selector).trigger('hidden.bs.tab')
willShowTab: (e) ->
selector = $(e.target).attr('href')
@$(selector).trigger('show.bs.tab')
tabs: []
constructor: ->
super
@render()
@fetchAndRender()
fetchAndRender: =>
@startLoading()
@ajax(
id: 'knowledge_bases_init_admin'
type: 'GET'
url: @apiPath + '/knowledge_bases/manage/init'
processData: true
success: (data, status, xhr) =>
App.Collection.loadAssets(data)
@knowledge_base_id = App.KnowledgeBase.first()?.id
@stopLoading()
@processLoaded()
error: (xhr) =>
@knowledge_base_id = undefined
@stopLoading()
)
clear: ->
App.KnowledgeBase.find(@knowledge_base_id).remove(clear: true)
@fetchAndRender()
processLoaded: ->
if @knowledge_base_id
@renderLoaded()
else
@renderNonExistant()
renderNonExistant: ->
@renderScreenError(detail: 'No Knowledge Base. Please create first Knowledge Base', el: @$('.page-content'))
@headerSwitchInput.prop('checked', false)
new App.KnowledgeBaseNewModal(
parentVC: @
container: @el.closest('.main')
)
didChangeHeaderSwitch: ->
@headerSwitchInput.prop('disabled', true)
upcomingState = @headerSwitchInput.prop('checked')
action = if upcomingState then 'activate' else 'deactivate'
kb = App.KnowledgeBase.find(@knowledge_base_id)
@ajax(
id: 'knowledge_bases_init_admin'
type: 'PATCH'
url: kb.manageUrl(action)
processData: true
success: (data, status, xhr) =>
App.Collection.loadAssets(data)
@processLoaded()
@headerSwitchInput.prop('disabled', false)
error: (xhr) =>
@headerSwitchInput.prop('checked', !upcomingState)
@headerSwitchInput.prop('disabled', false)
)
renderLoaded: ->
params = {
knowledge_base_id: @knowledge_base_id
parentVC: @
}
@tabs = [
{
name: 'Style'
target: 'style'
controller: App.KnowledgeBaseForm
params: _.extend({}, params, { screen: 'style', split: true })
},{
name: 'Languages'
target: 'languages'
controller: App.KnowledgeBaseForm
params: _.extend({}, params, { screen: 'languages' })
},{
name: 'Public Menu'
target: 'public_menu'
controller: App.KnowledgeBasePublicMenuForm
params: _.extend({}, params, { screen: 'public_menu' })
},{
name: 'Delete'
target: 'delete'
controller: App.KnowledgeBaseDelete
params: params
}
]
if !App.Config.get('system_online_service')
@tabs.splice(-1, 0, {
name: 'Custom Address'
target: 'custom_address'
controller: App.KnowledgeBaseCustomAddressForm,
params: _.extend({}, params, { screen: 'custom_address' })
})
@render()
@headerSwitchInput.prop('checked', App.KnowledgeBase.find(@knowledge_base_id).active)
App.Config.set('KnowledgeBase', { prio: 10000, name: 'Knowledge Base', parent: '#manage', target: '#manage/knowledge_base', controller: App.ManageKnowledgeBase, permission: ['admin.knowledge_base'] }, 'NavBarAdmin')

View file

@ -49,8 +49,9 @@ class Index extends App.ControllerSubContent
delete: (e) => delete: (e) =>
e.preventDefault() e.preventDefault()
id = $(e.currentTarget).data('token-id')
callback = => callback = =>
id = $(e.target).closest('a').data('token-id')
@ajax( @ajax(
id: 'user_access_token_delete' id: 'user_access_token_delete'
type: 'DELETE' type: 'DELETE'

View file

@ -163,12 +163,21 @@ class App.UiElement.ApplicationUiElement
if attribute.translate if attribute.translate
nameNew = App.i18n.translateInline(nameNew) nameNew = App.i18n.translateInline(nameNew)
attribute.options.push row =
value: item.id, value: item.id,
note: item.note, note: item.note,
name: nameNew, name: nameNew,
title: if item.email then item.email else nameNew title: if item.email then item.email else nameNew
if item.graphic
row.graphic = item.graphic
# only used for graphics
if item.aspect_ratio
row.aspect_ratio = item.aspect_ratio
attribute.options.push row
attribute.sortBy = null attribute.sortBy = null
# execute filter # execute filter

View file

@ -1,18 +1,18 @@
# coffeelint: disable=camel_case_classes # coffeelint: disable=camel_case_classes
class App.UiElement.autocompletion_ajax class App.UiElement.autocompletion_ajax
@render: (attribute, params = {}) -> @render: (attribute, params = {}, form) ->
if params[attribute.name] || attribute.value if params[attribute.name] || attribute.value
object = App[attribute.relation].find(params[attribute.name] || attribute.value) object = App[attribute.relation].find(params[attribute.name] || attribute.value)
valueName = object.displayName() valueName = object.displayName()
# selectable search # selectable search
searchableAjaxSelectObject = new App.SearchableAjaxSelect( searchableAjaxSelectObject = new App.SearchableAjaxSelect(
delegate: form
attribute: attribute:
value: params[attribute.name] || attribute.value value: params[attribute.name] || attribute.value
valueName: valueName valueName: valueName
name: attribute.name name: attribute.name
id: params.organization_id || attribute.value id: params.organization_id || attribute.id
placeholder: App.i18n.translateInline('Search...') placeholder: App.i18n.translateInline('Search...')
limit: 40 limit: 40
object: attribute.relation object: attribute.relation

View file

@ -0,0 +1,4 @@
# coffeelint: disable=camel_case_classes
class App.UiElement.color extends App.UiElement.ApplicationUiElement
@render: (attribute, params) ->
new App.Color(attribute: attribute).element()

View file

@ -0,0 +1,4 @@
# coffeelint: disable=camel_case_classes
class App.UiElement.icon_picker extends App.UiElement.ApplicationUiElement
@render: (attribute, params) ->
new App.IconPicker(attribute: attribute).element()

View file

@ -0,0 +1,4 @@
# coffeelint: disable=camel_case_classes
class App.UiElement.iconset_picker extends App.UiElement.ApplicationUiElement
@render: (attribute, params) ->
new App.IconsetPicker(attribute: attribute).element()

View file

@ -0,0 +1,34 @@
# coffeelint: disable=camel_case_classes
class App.UiElement.multi_locales extends App.UiElement.ApplicationUiElement
@render: (attribute, params, form) ->
new App.MultiLocales(attribute: attribute, object: form?.parentController?.object()).el
@prepareParams: (attribute, dom, params) ->
if typeof params[attribute.name] == 'string'
params[attribute.name] = [params[attribute.name]]
if !Array.isArray params[attribute.name]
return
primary_system_locale_id = dom.find("[name=#{attribute.name}_primary_locale_id]:checked").val()
params["#{attribute.name}_attributes"] = params[attribute.name]
.filter (elem) -> elem
.map (system_locale_id) ->
data = {
system_locale_id: system_locale_id
primary: system_locale_id == primary_system_locale_id
}
domRow = dom.find(".js-primary input[value=#{system_locale_id}]").closest('tr')
if domRow.hasClass('settings-list--deleted')
data['_destroy'] = '1'
if (kb_locale_id = domRow.data('kbLocaleId'))
data['id'] = parseInt(kb_locale_id)
data
delete params["#{attribute.name}"]
delete params["#{attribute.name}_primary_locale_id"]

View file

@ -1,5 +1,7 @@
# coffeelint: disable=camel_case_classes # coffeelint: disable=camel_case_classes
class App.UiElement.radio extends App.UiElement.ApplicationUiElement class App.UiElement.radio extends App.UiElement.ApplicationUiElement
@template_name: 'radio'
@render: (attribute, params) -> @render: (attribute, params) ->
# build options list based on config # build options list based on config
@ -23,4 +25,4 @@ class App.UiElement.radio extends App.UiElement.ApplicationUiElement
# filter attributes # filter attributes
@filterOption(attribute, params) @filterOption(attribute, params)
$( App.view('generic/radio')( attribute: attribute ) ) $( App.view("generic/#{@template_name}")( attribute: attribute ) )

View file

@ -0,0 +1,3 @@
# coffeelint: disable=camel_case_classes
class App.UiElement.radio_graphic extends App.UiElement.radio
@template_name: 'radio_graphic'

View file

@ -1,12 +1,19 @@
# coffeelint: disable=camel_case_classes # coffeelint: disable=camel_case_classes
class App.UiElement.richtext class App.UiElement.richtext
@render: (attribute, params) -> @render: (attribute, params, form) ->
item = $( App.view('generic/richtext')(attribute: attribute) ) if _.isObject(attribute.value)
item.find('[contenteditable]').ce( attribute.attachments = attribute.value.attachments
attribute.value = attribute.value.text
item = $( App.view('generic/richtext')(attribute: attribute, toolButtons: @toolButtons) )
@contenteditable = item.find('[contenteditable]').ce(
mode: attribute.type mode: attribute.type
maxlength: attribute.maxlength maxlength: attribute.maxlength
buttons: attribute.buttons
) )
item.find('a.btn--action[data-action]').click (event) => @toolButtonClicked(event, form)
if attribute.plugins if attribute.plugins
for plugin in attribute.plugins for plugin in attribute.plugins
params = plugin.params || {} params = plugin.params || {}
@ -25,6 +32,10 @@ class App.UiElement.richtext
for file in params.attachments for file in params.attachments
renderFile(file) renderFile(file)
if attribute.attachments
for file in attribute.attachments
renderFile(file)
# remove items # remove items
item.find('.attachments').on('click', '.js-delete', (e) => item.find('.attachments').on('click', '.js-delete', (e) =>
id = $(e.currentTarget).data('id') id = $(e.currentTarget).data('id')
@ -35,10 +46,12 @@ class App.UiElement.richtext
item item
) )
form_id = item.closest('form').find('[name=form_id]').val()
# delete attachment from storage # delete attachment from storage
App.Ajax.request( App.Ajax.request(
type: 'DELETE' type: 'DELETE'
url: "#{App.Config.get('api_path')}/upload_caches/#{@form_id}/items/#{id}" url: "#{App.Config.get('api_path')}/upload_caches/#{form_id}/items/#{id}"
processData: false processData: false
) )
@ -56,58 +69,101 @@ class App.UiElement.richtext
@attachmentsHolder = item.find('.attachments') @attachmentsHolder = item.find('.attachments')
@cancelContainer = item.find('.js-cancel') @cancelContainer = item.find('.js-cancel')
upload_initialize_callback = => u = => html5Upload.initialize(
form_id = item.closest('form').find('[name=form_id]').val() uploadUrl: "#{App.Config.get('api_path')}/attachments"
html5Upload.initialize( dropContainer: item.closest('form').get(0)
uploadUrl: "#{App.Config.get('api_path')}/upload_caches/#{form_id}" cancelContainer: @cancelContainer
dropContainer: item.closest('form').get(0) inputField: item.find('input').get(0)
cancelContainer: @cancelContainer maxSimultaneousUploads: 1,
inputField: item.find('input').get(0) key: 'File'
maxSimultaneousUploads: 1, data:
key: 'File' form_id: item.closest('form').find('[name=form_id]').val()
onFileAdded: (file) => onFileAdded: (file) =>
file.on( file.on(
onStart: => onStart: =>
@attachmentPlaceholder.addClass('hide') @attachmentPlaceholder.addClass('hide')
@attachmentUpload.removeClass('hide') @attachmentUpload.removeClass('hide')
@cancelContainer.removeClass('hide') @cancelContainer.removeClass('hide')
item.find('[contenteditable]').trigger('fileUploadStart') item.find('[contenteditable]').trigger('fileUploadStart')
App.Log.debug 'UiElement.richtext', 'upload start' App.Log.debug 'UiElement.richtext', 'upload start'
onAborted: => onAborted: =>
@attachmentPlaceholder.removeClass('hide') @attachmentPlaceholder.removeClass('hide')
@attachmentUpload.addClass('hide') @attachmentUpload.addClass('hide')
item.find('input').val('') item.find('input').val('')
item.find('[contenteditable]').trigger('fileUploadStop', ['aborted']) item.find('[contenteditable]').trigger('fileUploadStop', ['aborted'])
# Called after received response from the server # Called after received response from the server
onCompleted: (response) => onCompleted: (response) =>
response = JSON.parse(response) response = JSON.parse(response)
@attachmentPlaceholder.removeClass('hide') @attachmentPlaceholder.removeClass('hide')
@attachmentUpload.addClass('hide') @attachmentUpload.addClass('hide')
# reset progress bar # reset progress bar
@progressBar.width(parseInt(0) + '%') @progressBar.width(parseInt(0) + '%')
@progressText.text('') @progressText.text('')
renderFile(response.data) renderFile(response.data)
item.find('input').val('') item.find('input').val('')
item.find('[contenteditable]').trigger('fileUploadStop', ['completed']) item.find('[contenteditable]').trigger('fileUploadStop', ['completed'])
App.Log.debug 'UiElement.richtext', 'upload complete', response.data App.Log.debug 'UiElement.richtext', 'upload complete', response.data
# Called during upload progress, first parameter # Called during upload progress, first parameter
# is decimal value from 0 to 100. # is decimal value from 0 to 100.
onProgress: (progress, fileSize, uploadedBytes) => onProgress: (progress, fileSize, uploadedBytes) =>
@progressBar.width(parseInt(progress) + '%') @progressBar.width(parseInt(progress) + '%')
@progressText.text(parseInt(progress)) @progressText.text(parseInt(progress))
# hide cancel on 90% # hide cancel on 90%
if parseInt(progress) >= 90 if parseInt(progress) >= 90
@cancelContainer.addClass('hide') @cancelContainer.addClass('hide')
App.Log.debug 'UiElement.richtext', 'uploadProgress ', parseInt(progress) App.Log.debug 'UiElement.richtext', 'uploadProgress ', parseInt(progress)
)
) )
App.Delay.set(upload_initialize_callback, 100, undefined, 'form_upload') )
App.Delay.set(u, 100, undefined, 'form_upload')
item item
@toolButtonClicked: (event, form) ->
action = $(event.currentTarget).data('action')
@toolButtons[action]?.onClick(event, form)
@toolButtons = {}
@additions = {}
# 1 next, -1 previous
# jQuery's helper doesn't work because it doesn't include non-element nodes
@allDirectionalSiblings: (elem, direction, to = null) ->
if !elem?
return []
output = []
next = elem
while sibling = App.UiElement.richtext.directionalSibling(next, direction)
next = sibling
if to? and sibling is to
break
output.push sibling
output
# 1 next, -1 previous
@directionalSibling: (elem, direction) ->
if direction > 0
elem.nextSibling
else
elem.previousSibling
@buildParentsList: (elem, container) ->
$(elem)
.parentsUntil(container)
.toArray()
@buildParentsListWithSelf: (elem, container) ->
output = App.UiElement.richtext.buildParentsList(elem, container)
output.unshift(elem)
output

View file

@ -0,0 +1,139 @@
class App.UiElement.richtext.additions.RichTextToolButton
@icon: undefined # 'chain'
@text: undefined # 'Weblink'
@klass: ->
# Needs implementation. Return constructor of RichTextToolPopup subclass.
@initializeAttributes: {}
@instantiateContent: (event, selection, delegate) ->
attrs = @initializeAttributes
attrs['event'] = event
attrs['selection'] = selection
attrs['container'] = $(event.currentTarget).closest('.content')
attrs['delegate'] = delegate
klassConstructor = @klass()
instance = new klassConstructor(attrs)
instance.el
@popoverAttributes: (event, selection, delegate) ->
content = @instantiateContent(event, selection, delegate)
hash =
trigger: 'manual'
backdrop: true
html: true
animation: false
delay: 0
placement: 'auto right'
theme: 'dark'
content: content
container: 'body'
template: '<div class="popover popover--has-horizontal-form" role="tooltip"><div class="arrow"></div><h2 class="popover-title"></h2><div class="popover-content"></div></div>'
hash
@pickLinkInSingleContainer: (elem, containerToLookUpTo) ->
if elem.nodeName == 'A'
elem
else if innerLink = $(elem).find('a')[0]
innerLink
else if containerToLookUpTo and closestLink = $(elem).closest('a', containerToLookUpTo)[0]
closestLink
else
null
@pickLinkAt: (elem, container, direction, boundary = null) ->
for parent in App.UiElement.richtext.buildParentsListWithSelf(elem, container)
if parent.nodeName is 'A'
return parent
for elem in App.UiElement.richtext.allDirectionalSiblings(parent, direction, boundary)
if link = @pickLinkInSingleContainer(elem)
return link
null
@pickLink: (sel, textEditor) ->
range = sel.getRangeAt(0)
if range.startContainer == range.endContainer
return @pickLinkInSingleContainer(range.startContainer, textEditor)
if link = @pickLinkAt(range.startContainer, range.commonAncestorContainer, 1, range.endContainer)
return link
if startParent = App.UiElement.richtext.buildParentsList(range.startContainer, range.commonAncestorContainer).pop()
for elem in App.UiElement.richtext.allDirectionalSiblings(startParent, 1, range.endContainer)
if link = @pickLinkInSingleContainer(elem)
return link
if link = @pickLinkAt(range.endContainer, range.commonAncestorContainer, -1)
return link
return null
# close other buttons' popovers
@closeOtherPopovers: (event) ->
$(event.currentTarget)
.closest('.richtext-controls')
.find('.btn')
.toArray()
.filter (elem) -> $(elem).attr('aria-describedby')
.forEach (elem) -> $(elem).popover('hide')
# normalize selection to parse later
@selectionSnapshot: (sel) ->
textEditor = $(event.currentTarget).closest('.richtext.form-control').find('[contenteditable]')
if sel.isCollapsed and selectedLink = $(sel.anchorNode).closest('a')[0]
{
type: 'existing'
dom: $(selectedLink)
}
else if !sel.isCollapsed and selectedLink = @pickLink(sel, textEditor)
{
type: 'existing'
dom: $(selectedLink)
}
else if sel.type is 'Range' and $(sel.anchorNode).closest('[contenteditable]', textEditor)[0]
range = sel.getRangeAt(0)
{
type: 'range'
range: sel.getRangeAt(0)
}
else if $(sel.anchorNode).closest('[contenteditable]', textEditor)[0] and !$(sel.anchorNode).is('[contenteditable]')
{
type: 'caret'
dom: $(sel.anchorNode)
offset: sel.anchorOffset
}
else
{
type: 'append'
dom: textEditor
}
@onClick: (event, delegate) ->
event.stopPropagation()
event.preventDefault()
# close popover if already open and stop
if $(event.currentTarget).attr('aria-describedby')
$(event.currentTarget).popover('hide')
return
@closeOtherPopovers(event)
textEditor = $(event.currentTarget).closest('.richtext.form-control').find('[contenteditable]')
sel = document.getSelection()
selectionSnapshot = @selectionSnapshot(sel)
sel.removeAllRanges()
$(event.currentTarget)
.popover(@popoverAttributes(event, selectionSnapshot, delegate))
.popover('show')

View file

@ -0,0 +1,151 @@
class App.UiElement.richtext.additions.RichTextToolPopup extends App.ControllerForm
events:
'submit form': 'onSubmit'
'click .js-unlink': 'onUnlink'
formParams: (params) ->
# needs implementation
constructor: (params) ->
if params.selection.type is 'existing'
url = params.selection.dom.attr('href')
label = 'Update'
additional = [{
className: 'btn btn--danger js-unlink'
text: 'Remove'
}]
else
label = 'Link'
defaultParams =
params: @formParams(params)
fullForm: true
formClass: 'form--horizontal'
fullFormSubmitLabel: label
fullFormSubmitAdditionalClasses: 'btn--create'
fullFormAdditionalButtons: additional
autofocus: true
model:
configure_attributes: []
fullParams = $.extend(true, {}, defaultParams, params)
super(fullParams)
@didInitialize()
$(@event.currentTarget).on('hidden.bs.popover', (e) => @willClose(e))
getAjaxAttributes: (field, attributes) ->
@delegate?.getAjaxAttributes?(field, attributes)
onUnlink: (e) ->
e.preventDefault()
e.stopPropagation()
switch @selection.type
when 'existing'
$(@selection.dom).contents().unwrap()
$(@event.currentTarget).popover('hide')
@wrapElement: (wrapper, selection) ->
topLevelOriginals = App.UiElement.richtext.buildParentsList(selection.range.startContainer, selection.range.commonAncestorContainer).reverse()
if topLevelOriginalStart = topLevelOriginals.shift()
clonedStart = topLevelOriginalStart.cloneNode(false)
nextParent = clonedStart
for orig in topLevelOriginals
clone = orig.cloneNode(false)
nextParent.append(clone)
for elem in App.UiElement.richtext.allDirectionalSiblings(orig, 1)
nextParent.append(elem.cloneNode(true))
nextParent = clone
startClone = selection.range.startContainer.cloneNode(true)
remaining = startClone.splitText(selection.range.startOffset)
nextParent.append(remaining)
wrapper.append(clonedStart)
for elem in App.UiElement.richtext.allDirectionalSiblings(selection.range.startContainer, 1)
nextParent.append(elem.cloneNode(true))
else
topLevelOriginalStart = selection.range.startContainer
startClone = selection.range.startContainer.cloneNode(true)
remaining = startClone.splitText(selection.range.startOffset)
wrapper.append(remaining)
for elem in App.UiElement.richtext.allDirectionalSiblings(topLevelOriginalStart, 1, selection.range.endContainer)
wrapper.append(elem.cloneNode(true))
topLevelOriginals = App.UiElement.richtext.buildParentsList(selection.range.endContainer, selection.range.commonAncestorContainer).reverse()
if topLevelOriginalEnd = topLevelOriginals.shift()
clonedEnd = topLevelOriginalEnd.cloneNode(false)
nextParent = clonedEnd
for orig in topLevelOriginals
clone = orig.cloneNode(false)
nextParent.append(clone)
for elem in App.UiElement.richtext.allDirectionalSiblings(orig, -1)
nextParent.prepend(elem.cloneNode(true))
nextParent = clone
endClone = selection.range.endContainer.cloneNode(true)
endClone.splitText(selection.range.endOffset)
nextParent.append(endClone)
wrapper.append(clonedEnd)
else
endClone = selection.range.endContainer.cloneNode(true)
endClone.splitText(selection.range.endOffset)
wrapper.append(endClone)
document.getSelection().removeAllRanges()
document.getSelection().addRange(selection.range)
document.getSelection().deleteFromDocument()
document.getSelection().removeAllRanges()
wrapper.insertAfter(topLevelOriginalStart)
wrapLink: ->
# needs implementation
onSubmit: (e) ->
e.preventDefault()
@wrapLink()
$(@event.currentTarget).popover('destroy')
didInitialize: ->
switch @selection.type
when 'existing'
@selection.dom.addClass('highlight-emulator')
when 'range'
span = $('<span>').addClass('highlight-emulator')
if @selection.range.startContainer == @selection.range.endContainer
@selection.range.startContainer.splitText(@selection.range.endOffset)
visibleText = @selection.range.startContainer.splitText(@selection.range.startOffset)
$(visibleText).wrap(span)
else
@constructor.wrapElement(span, @selection)
willClose: (e) ->
switch @selection.type
when 'existing'
@selection.dom.removeClass('highlight-emulator')
when 'range'
textEditor = $(@event.currentTarget).closest('.richtext.form-control').find('[contenteditable]')
textEditor.find('span.highlight-emulator').contents().unwrap()
$(@event.currentTarget).off('hidden.bs.popover')
$(e.currentTarget).popover('destroy')

View file

@ -0,0 +1,15 @@
# coffeelint: disable=camel_case_classes
class App.UiElement.richtext.toolButtons.link_answer extends App.UiElement.richtext.additions.RichTextToolButton
@icon: 'knowledge-base-answer'
@text: 'Link Answer'
@klass: -> App.UiElement.richtext.additions.RichTextToolPopupAnswer
@initializeAttributes:
model:
configure_attributes: [
{
name: 'link'
display: 'Answer'
relation: 'KnowledgeBaseAnswerTranslation'
tag: 'autocompletion_ajax'
}
]

View file

@ -0,0 +1,15 @@
# coffeelint: disable=camel_case_classes
class App.UiElement.richtext.toolButtons.link extends App.UiElement.richtext.additions.RichTextToolButton
@icon: 'chain'
@text: 'Weblink'
@klass: -> App.UiElement.richtext.additions.RichTextToolPopupLink
@initializeAttributes:
model:
configure_attributes: [
{
name: 'link'
display: 'Link'
tag: 'input'
placeholder: 'http://'
}
]

View file

@ -0,0 +1,43 @@
class App.UiElement.richtext.additions.RichTextToolPopupAnswer extends App.UiElement.richtext.additions.RichTextToolPopup
formParams: (params) ->
# coffeelint: disable=indentation
url = if params.selection.type is 'existing' && params.selection.dom.attr('data-target-type') is 'knowledge-base-answer'
params.selection.dom.attr('data-target-id')
# coffeelint: enable=indentation
link: url
applyOnto: (dom, object, text = null) ->
dom
.attr('href', object.uiUrl('edit'))
.attr('data-target-id', object.id)
.attr('data-target-type', 'knowledge-base-answer')
if text?
dom.text(text)
dom
wrapLink: ->
id = @el.find('input').val()
object = App.KnowledgeBaseAnswerTranslation.find(id)
textEditor = $(@event.currentTarget).closest('.richtext.form-control').find('[contenteditable]')
switch @selection.type
when 'existing'
@applyOnto(@selection.dom, object)
when 'append'
newElem = $('<a>')
@applyOnto(newElem, object, object.title)
@selection.dom.append(newElem)
when 'caret'
newElem = $('<a>')
@applyOnto(newElem, object, object.title)
@selection.dom[0].splitText(@selection.offset)
newElem.insertAfter(@selection.dom)
when 'range'
placeholder = textEditor.find('span.highlight-emulator')
newElem = $('<a>')
@applyOnto(newElem, object)
placeholder.wrap(newElem)
placeholder.contents()

View file

@ -0,0 +1,52 @@
class App.UiElement.richtext.additions.RichTextToolPopupLink extends App.UiElement.richtext.additions.RichTextToolPopup
formParams: (params) ->
# coffeelint: disable=indentation
url = if params.selection.type is 'existing' && !params.selection.dom.attr('data-target-type')?
params.selection.dom.attr('href')
# coffeelint: enable=indentation
link: url
applyOnto: (dom, url, text = null) ->
dom
.attr('href', url)
.removeAttr('data-target-id')
.removeAttr('data-target-type')
if text?
dom.text(text)
dom
ensureProtocol: (input) ->
input = input.trim()
if !input.match(/^\S+\:\/\//) and input[0] isnt '/'
'http://' + input
else
input
wrapLink: ->
input = @el.find('input').val()
url = @ensureProtocol(input)
textEditor = $(@event.currentTarget).closest('.richtext.form-control').find('[contenteditable]')
switch @selection.type
when 'existing'
@applyOnto(@selection.dom, url)
when 'append'
newElem = $('<a>')
@applyOnto(newElem, url, input)
@selection.dom.append(newElem)
when 'caret'
newElem = $('<a>')
@applyOnto(newElem, url, input)
@selection.dom[0].splitText?(@selection.offset)
newElem.insertAfter(@selection.dom)
when 'range'
placeholder = textEditor.find('span.highlight-emulator')
newElem = $('<a>')
@applyOnto(newElem, url)
placeholder.wrap(newElem)
placeholder.contents()

View file

@ -51,6 +51,16 @@ class App.UiElement.select extends App.UiElement.ApplicationUiElement
return if value of attribute.options return if value of attribute.options
return if value in (temp for own prop, temp of attribute.options) return if value in (temp for own prop, temp of attribute.options)
if _.isArray(attribute.options)
# Array of Strings (value)
return if value of attribute.options
# Array of Objects (for ordering purposes)
return if attribute.options.filter((elem) -> elem.value == value) isnt null
else
# regular Object
return if value in (temp for own prop, temp of attribute.options)
if attribute.historical_options && value of attribute.historical_options if attribute.historical_options && value of attribute.historical_options
attribute.options[value] = attribute.historical_options[value] attribute.options[value] = attribute.historical_options[value]
else else

View file

@ -0,0 +1,33 @@
class App.KnowledgeBaseAddForm extends App.ControllerModal
constructor: (params) ->
for key, value of params
@[key] = value
@head = @object.objectActionName()
super(params)
buttonSubmit: 'Create'
content: ->
kb_locale = @parentController.kb_locale()
@formController = new App.KnowledgeBaseFormController(@object, kb_locale, 'agent_create', $('<div>'))
@form = @formController.form # used for disabling inputs during saving
@formController.el
submit: (e) ->
@preventDefaultAndStopPropagation(e)
if !@formController.validateAndShowErrors()
return
params = @formController.paramsForSaving()
params.translations_attributes[0].content_attributes = { body: '' }
@parentController.coordinator.saveChanges(@object, params, @)
showAlert: (text) ->
@formController?.showAlert(text)
didSaveCallback: (data) ->
url = @object.constructor.find(data.id).uiUrl(@parentController.kb_locale(), 'edit')
@parentController.navigate(url)

View file

@ -0,0 +1,385 @@
class App.KnowledgeBaseAgentController extends App.Controller
className: 'knowledge-base vertical'
name: 'Knowledge Base'
elements:
'.js-body': 'body'
'.js-navigation': 'navigation'
'.js-sidebar': 'sidebar'
constructor: (params) ->
super
@bind 'config_update_local', (data) => @configUpdated(data)
if @permissionCheck('knowledge_base.*') and App.Config.get('kb_active')
@updateNavMenu()
else if App.Config.get('kb_active_publicly')
@loadInitial(
{},
success: (data, status, xhr) =>
@updateNavMenu()
)
configUpdated: (data) ->
if data.name isnt 'kb_active' and data.name isnt 'kb_active_publicly'
return
@updateNavMenu()
firstRunIfNeeded: ->
if @firstRunDone
return
@firstRunDone = true
@coordinator = new App.KnowledgeBaseEditorCoordinator(parentController: @)
@fetchAndRender()
@bind('ui:rerender',
=>
@render(true)
@contentController?.url = null
@lastParams.selectedSystemLocale = App.KnowledgeBaseLocale.detect(@getKnowledgeBase()).systemLocale()
@show(@lastParams)
)
@bind 'kb_data_changed', (pushed_data) =>
key = "kb_pull_#{pushed_data.class}_#{pushed_data.id}"
App.Delay.set( =>
@loadChange(pushed_data)
, 1000, key, 'kb_data_changed_loading')
@listenTo App.KnowledgeBase, 'kb_data_change_loaded', =>
return if !@displayingError
object = @constructor.pickObjectUsing(@lastParams, @)
if !@objectVisibleInternally(object)
return
@renderControllers(@lastParams)
@checkForUpdates()
loadChange: (pushed_data) =>
url = pushed_data.url + '?full=true'
if pushed_data.class is 'KnowledgeBase::Answer'
object = App.KnowledgeBaseAnswer.find pushed_data.id
# coffeelint: disable=indentation
loaded_ids = object
?.translations()
.map (elem) -> elem.content()?.id
.filter (elem) -> elem isnt undefined
# coffeelint: enable=indentation
if loaded_ids and loaded_ids.length isnt 0
url += '&include_contents=' + loaded_ids.join(',')
@ajax(
id: "kb_pull_#{pushed_data.class}_#{pushed_data.id}"
type: 'GET'
url: url
processData: true
success: (data, status, xhr) =>
App.Collection.loadAssets(data.assets)
@notifyChangeLoaded()
error: (xhr) =>
if xhr.status != 404
return
klassName = pushed_data.class.replace(/::/g,'')
if object = App[klassName]?.find(pushed_data.id)
object.remove(clear: true)
@notifyChangeLoaded()
)
objectVisibleInternally: (object) ->
if !object
return false
else if object instanceof App.KnowledgeBaseAnswer and !object.exists()
return false
else if object instanceof App.KnowledgeBaseCategory and !object.visibleInternally(@kb_locale())
return false
true
notifyChangeLoaded: ->
App.KnowledgeBase.trigger('kb_data_change_loaded')
active: (state) ->
return @shown if state is undefined
@shown = state
featureActive: ->
(@permissionCheck('knowledge_base.*') and App.Config.get('kb_active')) or (App.Config.get('kb_active_publicly') and App.KnowledgeBase.first()?)
activeLocaleSuffix: ->
@kb_locale().urlSuffix()
requiredPermissionSuffix: (params) ->
if params.action is 'edit'
'editor'
else
'*'
show: (params) =>
@firstRunIfNeeded()
@navupdate '#knowledge_base'
@bodyModal?.close()
@bodyModal = null
if !@permissionCheckRedirect("knowledge_base.#{@requiredPermissionSuffix(params)}")
return
if @loaded && @rendered && @lastParams && !params.knowledge_base_id && @contentController && @kb_locale()?
@navigate @lastParams.match[0] , true
return
if @contentController && @contentController.url is params.match[0]
@title @lastTitle
@contentController.restoreVisibility?()
return
@rendered = true
@lastParams = params
if @loaded and params.selectedSystemLocale is null and params.selectedSystemLocalePresent
@renderError()
return
@displayingError = false
if @loaded
if params.knowledge_base_id
@renderControllers(params)
else
if (kb = App.KnowledgeBase.all()[0])
@navigate kb.uiUrl(App.KnowledgeBaseLocale.detect(kb)), true
else
@renderScreenErrorInContent('No Knowledge Base created')
else
@pendingParams = params
renderScreenErrorInContent: (text) ->
@contentController = undefined
@renderScreenError(detail: text, el: @$('.page-content'))
@displayingError = true
renderControllers: (params) ->
object = @constructor.pickObjectUsing(params, @)
if !object || (!@isEditor() && !object.visibleInternally(@kb_locale()))
@renderNotFound()
return
titleSuffix = if !(object instanceof App.KnowledgeBase)
object.guaranteedTitle(@kb_locale().id)
else if params.action is 'search'
App.i18n.translateInline('Search')
else
''
@updateTitle(titleSuffix)
klass = @contentControllerClass(params)
@contentController = @buildUsing(klass, params, object)
@navigationController?.show(object, params.action)
@sidebarController?.show(object, params.action)
updateTitle: (titleSuffix) ->
newTitle = @getKnowledgeBase()?.guaranteedTitle(@kb_locale()?.id) || ''
if titleSuffix != ''
if newTitle
newTitle += ' - '
newTitle += titleSuffix
@title newTitle
@lastTitle = newTitle
contentControllerClass: (params) ->
if params.action is 'search'
return App.KnowledgeBaseSearchController
if params.action is 'edit'
return App.KnowledgeBaseContentController
if params.answer_id
App.KnowledgeBaseReaderController
else
App.KnowledgeBaseReaderListController
edit: false
renderNotFound: ->
title = App.i18n.translateInline('Not Found')
@updateTitle(title)
@navigationController?.show(undefined, title)
@renderScreenErrorInContent('The page was not found')
@sidebarController?.hide()
renderNotAvailableAnymore: ->
@updateTitle(App.i18n.translateInline('Not Available'))
@renderScreenErrorInContent('The page is not available anymore')
renderError: ->
@bodyModal?.close()
url = App.Utils.joinUrlComponents @lastParams.effectivePath, @getKnowledgeBase().primaryKbLocale().urlSuffix()
@bodyModal = new App.ControllerModal(
head: 'Locale not found'
contentInline: "<a href='#{url}'>Open in primary locale</a>"
buttonClose: false
buttonSubmit: false
backdrop: 'static'
keyboard: false
container: @el
)
kb_locale: ->
kb = @getKnowledgeBase()
return if !kb
if @lastParams.selectedSystemLocale
kb.kb_locales().filter((elem) => elem.system_locale_id == @lastParams.selectedSystemLocale.id)[0]
getKnowledgeBase: ->
App.KnowledgeBase.find(@lastParams.knowledge_base_id)
fetchAndRender: =>
@fetch(true, true)
fetch: (showLoader, processLoaded) ->
if showLoader
@startLoading()
loaded_content_ids = App.KnowledgeBaseAnswerTranslationContent.all().map (elem) -> elem.id
params = {
answer_translation_content_ids: loaded_content_ids
}
@loadInitial(
params,
success: (data, status, xhr) =>
if showLoader
@stopLoading()
if processLoaded
@processLoaded()
,
error: (xhr) =>
if showLoader
@stopLoading()
)
loadInitial: (params, options = {}) =>
@ajax(
id: 'knowledge_bases_init'
type: 'POST'
url: @apiPath + '/knowledge_bases/init'
data: JSON.stringify(params)
processData: true
success: (data, status, xhr) =>
@loaded = true
@loadKbData(data)
options.success?(data, status, xhr)
error: (xhr) ->
options.error?(xhr)
)
loadKbData: (data) ->
App.Collection.loadAssets(data)
for elem in @calculateIdsToDelete(data)
for id in elem.ids
App[elem.modelName].find(id)?.remove(clear: true)
calculateIdsToDelete: (data) ->
Object
.keys(data)
.filter (elem) -> elem.match(/^KnowledgeBase/)
.map (model) ->
newIds = Object.keys data[model]
oldIds = App[model].all().map (elem) -> elem.id
diff = oldIds.filter (elem) -> !newIds.includes(String(elem))
{modelName: model, ids: diff}
, {}
processLoaded: ->
@render(true)
if @pendingParams
@show(@pendingParams)
@pendingParams = undefined
render: (force = false) =>
@html App.view('knowledge_base/agent')()
@navigationController = new App.KnowledgeBaseNavigation(
el: @$('.js-navigation')
parentController: @
)
@sidebarController = new App.KnowledgeBaseSidebar(
el: @$('.js-sidebar')
parentController: @
)
isEditor: ->
App.User.current().permission('knowledge_base.editor')
checkForUpdates: ->
@interval(@checkUpdatesAction, 10 * 60 * 1000, 'kb_interval_check')
checkUpdatesAction: =>
if !@loaded
return
@fetch(false, false)
buildUsing: (klass, params, object) ->
new klass(
el: @$('.page-content')
object: object
parentController: @
selectedSystemLocale: params.selectedSystemLocale
url: params.match[0]
)
onclick: ->
!(@permissionCheck('knowledge_base.*') and App.Config.get('kb_active')) and (App.Config.get('kb_active_publicly') and App.KnowledgeBase.first()?)
accessoryIcon: ->
return if !@onclick()
'external'
clicked: ->
window.open(App.KnowledgeBase.first().publicBaseUrl(), '_blank')
@pickObjectUsing: (params, parentController) ->
kb = parentController.getKnowledgeBase()
return if !kb
if answer_id = params['answer_id']
App.KnowledgeBaseAnswer.find(answer_id)
else if category_id = params['category_id']
App.KnowledgeBaseCategory.find(category_id)
else if knowledge_base_id = params['knowledge_base_id']
kb
App.Config.set('KnowledgeBase', { controller: 'KnowledgeBaseAgentController' }, 'permanentTask')
App.Config.set('KnowledgeBase', { prio: 1150, parent: '', name: 'Knowledge Base', target: '#knowledge_base', key: 'KnowledgeBase', class: 'knowledge-base', shown: false}, 'NavBar')

View file

@ -0,0 +1,68 @@
class App.KnowledgeBaseContentCanBePublishedDialog extends App.ControllerModal
events:
'click .scheduled-widget-delete': 'clickedCancelTimer'
'submit form': 'submitTiming'
head: 'Visibility'
includeForm: false
buttonSubmit: false
constructor: (params) ->
super
content: =>
@formController = new App.KnowledgeBaseContentCanBePublishedForm(
object: @object
)
@formController.form
saveUpdate: (params, successCallback = null) =>
@clearAlerts()
@formController.toggleDisabled(true)
@ajax(
id: 'knowledge_base_can_be_published'
type: 'POST'
data: JSON.stringify(params)
url: @object.generateURL('has_publishing_update')
processData: true
success: (data, status, xhr) =>
App.Collection.load(type: 'KnowledgeBaseAnswer', data: [data])
successCallback?()
@formController.toggleDisabled(false)
error: (xhr) =>
@formController.toggleDisabled(false)
@showAlert(xhr.responseJSON?.error || 'Unable to save changes')
)
clickedCancelTimer: (e) ->
widget = $(e.currentTarget).closest('.scheduled-widget')
state = widget.data('state')
params = { "#{state}_at": null }
@saveUpdate params, ->
widget.remove()
submitTiming: (e) =>
@preventDefaultAndStopPropagation(e)
data = @formParams()
params =
"#{data.visibility}_at": if data.timing is 'scheduled' then data.scheduled else '--now--'
newVisibilityIndex = @formController.states.indexOf(data.visibility)
oldVisibilityIndex = @formController.states.indexOf(@formController.params.visibility)
if newVisibilityIndex < oldVisibilityIndex
for index in [(newVisibilityIndex+1)..oldVisibilityIndex]
params["#{@formController.states[index]}_at"] = null
@saveUpdate params, =>
if data.timing is 'now'
@close()
return
@update()
@initalFormParams = @formParams()

View file

@ -0,0 +1,133 @@
class App.KnowledgeBaseContentCanBePublishedForm extends App.ControllerForm
elements:
'.js-datepicker': 'datePicker'
'[name=visibility]': 'visibilityRadios'
'[value=now]': 'timingNow'
'[value=scheduled]': 'timingScheduled'
constructor: (params) ->
@prepare(params)
super
@postRendering()
@visibilityRadios.trigger('change')
prepare: (params) ->
@handlers = [@timingHandler, @visibilityHandler, @scheduledHandler]
@params =
visibility: params.object.can_be_published_state()
scheduledHandler: (params, attribute, attributes, classname, form, ui) =>
if attribute.name isnt 'scheduled'
return
if !params.scheduled
return
@timingScheduled.prop('checked', true)
visibilityHandler: (params, attribute, attributes, classname, form, ui) =>
if attribute.name isnt 'visibility'
return
@toggleDisabled(false)
scheduledWidget = @form.find(".scheduled-widget[data-state=#{params.visibility}]")
if scheduledWidget.length > 0 and !@form.find('.controls--datetime input[data-item=date]').val()
date = scheduledWidget.data('date')
@datePicker.datepicker('setDate', date)
else
@datePicker.datepicker('clearDates')
@timingNow.prop('checked', true)
timingHandler: (params, attribute, attributes, classname, form, ui) =>
if attribute.name isnt 'timing'
return
if params.timing isnt 'now'
return
if !params.scheduled
return
@datePicker.datepicker('clearDates')
postRendering: =>
# simulate elements
for key, value of @elements
@[value] = @form.find(key)
# move date picker to inside of timing radio
@timingScheduled.parent().addClass('additional-radio-controls').append(@form.find('[data-name="scheduled"]'))
@form.find('[data-attribute-name="scheduled"]').remove()
@datePicker.datepicker('setStartDate', new Date())
# add scheduled tiemr widgets
now = new Date()
for state in @states
if @object["#{state}_at"] && new Date(@object["#{state}_at"]) > now
label = @form.find("input[value=#{state}]").closest('label')
timer = new App.KnowledgeBaseScheduledWidget(object: @object, state: state)
label.after timer.el
toggleDisabled: (state) ->
selectedState = @visibilityRadios.filter(':checked').val()
timingDisabled = @params.visibility is selectedState
isRollback = @states.indexOf(@params.visibility) > @states.indexOf(selectedState)
@form.find('[value=now], [type=submit]')
.attr('disabled', state or timingDisabled)
@form.find('[value=scheduled], .controls--datetime input')
.attr('disabled', state or timingDisabled or isRollback)
@visibilityRadios.attr('disabled', state)
fullForm: true
fullFormSubmitLabel: 'Update'
fullFormSubmitAdditionalClasses: 'btn--primary'
states: ['draft', 'internal', 'published', 'archived']
model:
configure_attributes: [
name: 'visibility'
display: 'Visibility'
tag: 'radio'
default: false
options: [
value: 'draft'
name: 'Draft'
note: 'Only visible to editors'
,
value: 'internal'
name: 'Internal'
note: 'Only visible to agents & editors'
,
value: 'published'
name: 'Public'
note: 'Visible to everyone'
,
value: 'archived'
name: 'Archived'
]
,
name: 'timing'
display: 'Timing'
tag: 'radio'
default: 'now'
options: [
value: 'now'
name: 'Now'
,
value: 'scheduled'
name: 'Schedule for'
]
,
name: 'scheduled'
display: 'Date'
tag: 'datetime'
class: 'form-control--small'
null: true
]

View file

@ -0,0 +1,161 @@
class App.KnowledgeBaseContentController extends App.Controller
elements:
'.js-form': 'form'
'.js-discard': 'discardButton'
'.js-submitContainer': 'submitContainer'
events:
'click .js-submit': 'submit'
'click .js-discard': 'discardChanges'
'submit .js-form': 'submit'
'input .js-form': 'showDiscardButton'
'click .js-submit-action': 'submit'
constructor: ->
super
translation = @object.translation(@parentController.kb_locale().id)
if translation and !translation.fullyLoaded()
@html App.view('knowledge_base/content')(@)
@startLoading()
translation.loadFull (isSuccess) =>
@stopLoading()
if !isSuccess
return
@initialize()
return
@initialize()
initialize: ->
@render()
@listenTo App.KnowledgeBase, 'kb_data_change_loaded', =>
@objectRefreshed()
true
# update availability display whenever object is touched
@listenTo @object, 'refresh', =>
@renderAvailabilityWidgets()
render: ->
@html App.view('knowledge_base/content')(@)
@renderAvailabilityWidgets()
@formController = @buildFormController(@form)
@startingParams = App.ControllerForm.params(@formController.el)
buildFormController: (dom = undefined) ->
new App.KnowledgeBaseFormController(@object, @parentController.kb_locale(), 'agent_edit', dom)
remoteDidntChangeSinceStart: ->
remoteParams = @buildFormController().rawParams()
App.KnowledgeBaseFormController.compareParams(remoteParams, @startingParams)
objectRefreshed: ->
@renderAvailabilityWidgets()
if @remoteDidntChangeSinceStart()
@pendingRerender = false
return
if !@parentController.shown
@pendingRerender = true
return
@rerenderIfConfirmed()
rerenderIfConfirmed: ->
text = App.i18n.translatePlain('Changes were made. Do you want to reload? You\'ll loose your changes')
if confirm(text)
@render()
renderAvailabilityWidgets: ->
if !@object.constructor.canBePublished?()
return
new App.WidgetButtonWithDropdown(
el: @submitContainer
mainActionLabel: 'Update'
actions: @quickActions()
)
html = App.view('knowledge_base/content_can_be_published_header_suffix')(object: @object)
@el.find('.js-published-header-suffix').replaceWith(html)
submit: (e) ->
@preventDefaultAndStopPropagation(e)
if !@formController.validateAndShowErrors()
return
paramsForSaving = @formController.paramsForSaving()
additional_action = $(e.currentTarget).data('id')
if @remoteDidntChangeSinceStart()
@parentController.coordinator.saveChanges(@object, paramsForSaving, @, additional_action)
return
new App.ControllerConfirm(
head: 'Content was changed since loading'
message: 'Your changes may override someone else\'s changes. Are you sure to save?'
callback: =>
@parentController.coordinator.saveChanges(@object, paramsForSaving, @)
)
missingTranslation: ->
@object.translation(@parentController.kb_locale().id) is undefined && !@object.isNew()
showDiscardButton: ->
@delay =>
noChanges = App.KnowledgeBaseFormController.compareParams(@formController.rawParams(), @startingParams)
@discardButton.toggleClass('hide', noChanges)
, 500, 'check_unsaved_changes'
quickActions: ->
prefix = App.i18n.translatePlain('Update') + ' & '
actions = @object.can_be_published_quick_actions()
[
{
id: 'internal'
name: prefix + App.i18n.translatePlain('Internal')
disabled: !_.includes(actions, 'internal')
},{
id: 'publish'
name: prefix + App.i18n.translatePlain('Publish')
disabled: !_.includes(actions, 'publish')
},{
id: 'archive'
name: prefix + App.i18n.translatePlain('Archive')
disabled: !_.includes(actions, 'archive')
}
]
discardChanges: ->
@render()
showAlert: (text) ->
@formController?.showAlert(text)
didSaveCallback: (data) ->
@render()
App.Event.trigger 'knowledge_base::sidebar::rerender'
App.Event.trigger 'knowledge_base::navigation::rerender'
# this method is called when user comes back to already instantiated view
restoreVisibility: ->
if !@pendingRerender
return
@pendingRerender = false
# add delay to give it time to rerender before showing prompt
App.Delay.set => @rerenderIfConfirmed()

View file

@ -0,0 +1,71 @@
class App.KnowledgeBaseDeleteAction
constructor: (params) ->
for key, value of params
@[key] = value
if @object instanceof App.KnowledgeBaseCategory and !@object.isEmpty()
@showCannotDelete(
'Cannot delete category',
'Please delete all children categories and answers first.'
)
return
@showConfirm()
showConfirm: ->
kb_locale = @parentController.kb_locale()
translation = @object.guaranteedTranslation(kb_locale.id)
@dialog = new App.ControllerConfirm(
head: 'Delete'
message: "Are you sure to delete \"#{translation?.title}\"?"
callback: @doDelete
container: @parentController.el
onSubmit: ->
@formDisable(@el)
@callback(@)
@dialog = null
)
showCannotDelete: (title, message) ->
modal = new App.ControllerModal(
head: title
contentInline: message
container: @parentController.el
buttonClose: true
buttonSubmit: 'Ok'
onSubmit: (e) =>
modal.close()
@dialog = null
)
@dialog = modal
doDelete: (modal) =>
App.Ajax.request(
type: 'DELETE'
url: @object.generateURL() + '?full=true'
success: =>
@deleteOk(modal)
error: (xhr) =>
@deleteFailure(modal, xhr)
)
deleteOk: (modal) =>
futureObject = @object.parent?() || @object.category?() || @object.knowledge_base()
@parentController.contentController.stopListening()
@object.removeIncludingTranslations(clear: true)
modal.close()
@parentController.navigate futureObject.uiUrl(@parentController.kb_locale(), 'edit')
deleteFailure: (modal, xhr) ->
modal.formEnable(modal.el)
modal.showAlert xhr.responseJSON?.error || 'Unable to delete.'
# simulate modal's close function
close: ->
@dialog?.close()

View file

@ -0,0 +1,44 @@
class App.KnowledgeBaseEditorCoordinator
constructor: (params) ->
for key, value of params
@[key] = value
clickedCanBePublished: (object) ->
new App.KnowledgeBaseContentCanBePublishedDialog(
object: object
container: @parentController.el
)
clickedDelete: (object) ->
new App.KnowledgeBaseDeleteAction(
object: object
parentController: @parentController
)
# built-in Spine's function doesn't work when object has no ID set and includes "undefined" in URL
urlFor: (object) ->
if object.id
object.generateURL()
else
object.url()
saveChanges: (object, data, formController, action) ->
App.ControllerForm.disable(formController.form)
url = @urlFor(object) + '?full=true'
if action
url += "&additional_action=#{action}"
App.Ajax.request(
type: object.writeMethod()
data: JSON.stringify(data)
url: url
success: (data) ->
App.Collection.loadAssets(data.assets)
formController.didSaveCallback(data)
error: (xhr) ->
data = JSON.parse(xhr.responseText)
App.ControllerForm.enable(formController.form)
formController.showAlert(data.error || 'Unable to save changes.')
)

View file

@ -0,0 +1,69 @@
class App.KnowledgeBaseFormController extends App.ControllerForm
# set screen to agent_edit or agent_create
constructor: (object, kb_locale, screen, dom) ->
@object = object
@kb_locale = kb_locale
objectParams = @currentParams()
objectParams['form_id'] = App.ControllerForm.formId()
super(
params: objectParams
autofocus: dom isnt null
grid: true
el: dom || $('<form>')
screen: screen
model: { configure_attributes: @getAttrs() }
)
getAjaxAttributes: (field, attributes) ->
@apiPath = App.Config.get('api_path')
attributes.type = 'POST'
attributes.url = "#{@apiPath}/knowledge_bases/search"
attributes.data.flavor = 'agent'
attributes.data.knowledge_base_id = @object.knowledge_base().id
attributes.data.exclude_ids = [@object.translation(@kb_locale.id)?.id]
attributes.data.index = 'KnowledgeBase::Answer::Translation'
attributes.data.locale = @kb_locale.systemLocale().locale
attributes.data.highlight_enabled = false
attributes.data = JSON.stringify(attributes.data)
attributes
currentParams: ->
@object.attributesIncludingTranslation(@kb_locale.id)
rawParams: ->
App.ControllerForm.params(@el)
paramsForSaving: ->
@object.prepareNestedParams(@rawParams(), @kb_locale.id)
validateAndShowErrors: ->
errors = @validate(@rawParams())
@constructor.validate(
errors: errors
form: @.el
)
!errors
getAttrs: ->
attrs = @object.configure_attributes?(@kb_locale) || @object.constructor.configure_attributes
attrs.push {
name: 'form_id'
tag: 'input'
type: 'hidden'
}
attrs
@compareParams: (a, b) ->
for params in [a, b]
delete params.form_id
_.isEqual(a, b)

View file

@ -0,0 +1,97 @@
class App.KnowledgeBaseReaderPagination extends App.Controller
constructor: ->
super
@render()
className: 'knowledge-base-article-nav'
render: ->
@stopListening()
previousAnswer = @calculatePreviousAnswer()
nextAnswer = @calculateNextAnswer()
@html App.view('knowledge_base/_reader_pagination')(
previousAnswer: previousAnswer?.attributesForRendering(@kb_locale)
nextAnswer: nextAnswer?.attributesForRendering(@kb_locale)
)
for object in [@object, previousAnswer, nextAnswer, @object.category()]
if object
@listenTo object, 'refresh', (e) =>
@render()
calculatePreviousAnswer: ->
@calculateSiblingAnswer(-1)
calculateNextAnswer: ->
@calculateSiblingAnswer(+1)
calculateSiblingAnswer: (direction) ->
if sibling = @calculateSibling(@object.category().answers(), @object, direction)
return sibling
if direction < 0 and cat_answer = @findlastAnswer(@object.category())
return cat_answer
scope = @object
while scope
parent = scope.category?() || scope.parent?()
list = if parent
parent.children()
else
scope.knowledge_base().rootCategories()
if siblingAtScope = @findAnswerInSiblingCategory(scope, list, direction)
return siblingAtScope
scope = parent
null
calculateSibling: (list, current, direction) ->
list[@getIndexOf(list, current) + direction]
getIndexOf: (list, current) ->
matching = list.filter((elem) -> elem.id == current.id)[0]
list.indexOf(matching)
findlastAnswer: (category, include_direct_answers = false) ->
if include_direct_answers and last_direct = category.answers().slice(-1)[0]
return last_direct
for category in category.children().reverse()
if answer = @findlastAnswer(category, true)
return answer
return null
findFirstAnswer: (category) ->
for category in category.children()
if answer = @findFirstAnswer(category)
return answer
category.answers()[0]
findAnswerInSiblingCategory: (category, list, direction) ->
currentCategoryIndex = @getIndexOf(list, category)
categories = if direction < 0
list.slice(0, currentCategoryIndex).reverse()
else
list.slice(currentCategoryIndex + 1)
for category in categories
# coffeelint: disable=indentation
found = if direction < 0
@findlastAnswer(category, true)
else
@findFirstAnswer(category)
# coffeelint: enable=indentation
if found
return found
null

View file

@ -0,0 +1,173 @@
class App.KnowledgeBaseNavigation extends App.Controller
@extend(Spine.Events)
events:
'click .js-search': 'clickedToggleSearch'
elements:
'.js-edit': 'editButton'
constructor: ->
super
@render()
@bind 'knowledge_base::navigation::rerender', => @needsUpdate()
@listenTo App.KnowledgeBase, 'kb_data_change_loaded', =>
@needsUpdate()
buildCrumbsForRendering: (array, kb_locale, action) ->
if action is 'search'
action = null
if !kb_locale
return []
array
.filter (elem) ->
elem != undefined and elem != null
.map (elem) =>
if typeof elem is 'string'
return { title: elem }
@listenToChangesOn(elem)
elem.attributesForRendering(kb_locale, action: action)
listenToChangesOn: (object) ->
locale = @parentController.kb_locale()
if !locale
return
@stopListening object, 'refresh'
@listenToOnce object.translationBindlableObject(locale.id), 'refresh', (obj) =>
@needsUpdate()
show: (object, action) ->
@savedAction = action
if @dontRenderFor(object)
return
# coffeelint: disable=indentation
crumbs = if title = @calculateTitle(object, action)
[@parentController.getKnowledgeBase(), title]
else
@breadcrumbTo(object).reverse()
# coffeelint: enable=indentation
crumbsForRendering = @buildCrumbsForRendering(crumbs, @parentController.kb_locale(), action)
@render(crumbsForRendering, object, action)
@savedParams = object
calculateTitle: (object, action) ->
if action is 'search'
App.i18n.translateInline 'Search'
else if !object
App.i18n.translateInline 'Not found'
dontRenderFor: (object) ->
if object instanceof App.Model
object.isNew() && !object.isFresh
else
false
needsUpdate: ->
@show(@savedParams, @savedAction)
selectedLocaleDisplay: ->
@parentController.kb_locale()?.systemLocale().alias || '-'
render: (crumbs = [], object = null, action = null) ->
kb_locale = @parentController.kb_locale()
return if !kb_locale
@html App.view('knowledge_base/navigation')(
crumbs: crumbs
kbLocales: @kbLocaleOptions(object, kb_locale, action)
search: @searchOptions(object, kb_locale, action)
edit: @editOptions(object, kb_locale, action)
externalUrl: @externalUrl(object, kb_locale, action)
iconset: @parentController.getKnowledgeBase().iconset
)
kbLocaleOptions: (object, kb_locale, action) ->
{
selected: kb_locale
collection: @kb_locales()
}
searchOptions: (object, kb_locale, action) ->
enabled = action is 'search'
url = if enabled == true
@toggleSearchSource || @parentController.getKnowledgeBase()?.uiUrl(kb_locale)
else
@parentController.getKnowledgeBase()?.uiUrl(kb_locale, 'search')
{
enabled: enabled
url: url
}
editOptions: (object, kb_locale, action) ->
enabled = action is 'edit'
{
url: object?.uiUrl(kb_locale, if !enabled then 'edit')
enabled: enabled
available: @parentController.isEditor()
}
externalUrl: (object, kb_locale, action) ->
if action and action != 'edit'
return
if !(object?.visiblePublicly?(kb_locale) or (object?.translation?(kb_locale?.id)? and @parentController.isEditor()))
return
object.publicBaseUrl(kb_locale)
kb_locales: ->
path = '#' + @parentController.lastParams.match.input
@parentController
.getKnowledgeBase()
.kb_locales()
.map (elem) -> elem.attributesForRendering(path)
toggleSearchSource: undefined
clickedToggleSearch: ->
if @savedAction is 'search'
return
@toggleSearchSource = location.hash
breadcrumbTo: (object) ->
if !object
return []
output = switch object.constructor
when App.KnowledgeBaseAnswer
@breadcrumbToAnswer(object)
when App.KnowledgeBaseCategory
@breadcrumbToCategory(object)
when App.KnowledgeBase
@breadcrumbToKb(object)
breadcrumbToAnswer: (answer) ->
[answer].concat @breadcrumbToCategory(answer.category())
breadcrumbToCategory: (category) ->
array = [category]
while parent = (parent || category).parent()
array = array.concat parent
array.concat @breadcrumbToKb(category.knowledge_base())
breadcrumbToKb: (kb) ->
[kb]

View file

@ -0,0 +1,17 @@
class App.KnowledgeBasePublicMenuForm extends App.Controller
events:
'show.bs.tab': 'willShow'
willShow: ->
@el.empty()
for kb_locale in App.KnowledgeBase.find(@knowledge_base_id).kb_locales()
menu_items = App.KnowledgeBaseMenuItem.using_kb_locale(kb_locale)
form_item = new App.KnowledgeBasePublicMenuFormItem(
knowledge_base_id: @knowledge_base_id,
kb_locale: kb_locale,
menu_items: menu_items
)
@el.append form_item.el

View file

@ -0,0 +1,144 @@
class App.KnowledgeBasePublicMenuFormItem extends App.Controller
events:
'click .js-add': 'add'
'click .js-remove': 'remove'
'input input': 'input'
'submit form': 'submit'
elements:
'.js-alert': 'alert'
constructor: ->
super
@render()
render: ->
@html App.view('knowledge_base/public_menu_form_item')(
kb_locale_id: @kb_locale.id
rows: @menu_items
title: @kb_locale.systemLocale().name
)
@applySortable()
applySortable: ->
dndOptions =
tolerance: 'pointer'
distance: 15
opacity: 0.6
items: 'tr.sortable'
start: (e, ui) ->
ui.placeholder.height( ui.item.height() )
helper: (e, tr) ->
originals = tr.children()
helper = tr
helper.children().each (index, el) ->
# Set helper cell sizes to match the original sizes
$(@).width( originals.eq(index).width() )
return helper
update: @dndCallback
stop: (e, ui) ->
ui.item.children().each (index, element) ->
element.style.width = ''
@el.find('tbody').sortable(dndOptions)
toggleUserInteraction: (enabled) ->
if enabled
App.ControllerForm.enable(@el)
else
App.ControllerForm.disable(@el)
@$('.js-remove, .js-add').attr('disabled', !enabled)
@el.find('tbody').sortable(disabled: !enabled)
buildData: ->
items = @$('tr.sortable')
.toArray()
.map (elem) -> $(elem)
.map (elem) ->
{
id: elem.data('id')
title: elem.find('input[data-name=title]').val()
url: elem.find('input[data-name=url]').val()
new_tab: elem.find('input[data-name=new_tab]').prop('checked')
_destroy: elem.hasClass('js-deleted')
}
{
kb_locale_id: @$('form').data('kb-locale-id'),
menu_items: items
}
input: ->
if @validateForm(false)
@hideAlert()
add: ->
el = App.view('knowledge_base/public_menu_form_item_row')()
$(el).insertBefore(@$('tr:has(.js-add)'))
remove: (e) ->
row = $(e.currentTarget).closest('tr')
if row.data('id')
row.toggleClass('settings-list--deleted js-deleted')
row.find('.js-remove input').prop('checked', row.hasClass('settings-list--deleted'))
row.find('.js-new-tab input').attr('disabled', row.hasClass('js-deleted'))
else
row.remove()
showAlert: (message) ->
translated = App.i18n.translatePlain(message)
@alert
.text(translated)
.removeClass('hidden')
hideAlert: ->
@alert.addClass('hidden')
emptyFields: ->
@$('tr.sortable:not(.js-deleted)')
.find('input[data-name]')
.toArray()
.filter (elem) -> $(elem).val().length == 0
validateForm: (showAlert = true) ->
if @emptyFields().length == 0
return true
if showAlert
@showAlert('Please fill in all fields')
false
submit: (e) ->
@preventDefaultAndStopPropagation(e)
if !@validateForm()
return
@hideAlert()
@toggleUserInteraction(false)
kb = App.KnowledgeBase.find(@knowledge_base_id)
@ajax(
id: 'update_menu_items'
type: 'PATCH'
url: kb.manageUrl('update_menu_items')
data: JSON.stringify(@buildData())
processData: true
success: (data, status, xhr) =>
for menu_item in App.KnowledgeBaseMenuItem.using_kb_locale(@kb_locale)
menu_item.remove(clear: true)
App.Collection.loadAssets(data.assets)
@menu_items = App.KnowledgeBaseMenuItem.using_kb_locale(@kb_locale)
@render()
error: (xhr) =>
@showAlert(xhr.responseJSON?.error_human || 'Couldn\'t save changes')
@toggleUserInteraction(true)
)

View file

@ -0,0 +1,126 @@
class App.KnowledgeBaseReaderController extends App.Controller
@extend App.PopoverProvidable
@registerPopovers 'Ticket'
elements:
'.js-answer-title': 'answerTitle'
'.js-answer-body': 'answerBody'
'.js-answer-pagination': 'answerPagination'
'.js-answer-attachments': 'answerAttachments'
'.js-answer-linked-tickets': 'answerLinkedTickets'
'.js-answer-meta': 'answerMeta'
constructor: ->
super
translation = @object.translation(@parentController.kb_locale().id)
@html App.view('knowledge_base/reader')(
search_return_url: @buildSearchReturnUrl()
)
if translation and !translation.fullyLoaded()
@startLoading(@answerBody)
translation.loadFull (isSuccess) =>
@stopLoading()
if !isSuccess
return
@initialize()
return
@initialize()
initialize: ->
@render()
render: ->
@stopListening()
kb_locale = @parentController.kb_locale()
@renderAnswer(@object, kb_locale)
if !@object
return
@listenTo App.KnowledgeBase, 'kb_data_change_loaded', =>
@renderAnswer(@object, kb_locale)
renderAnswer: (answer, kb_locale) ->
if !answer
@parentController.renderNotFound()
return
if !answer.exists()
@parentController.renderNotAvailableAnymore()
return
@renderAttachments(answer.attachments)
@renderLinkedTickets(answer.translation(kb_locale.id)?.linked_tickets())
paginator = new App.KnowledgeBaseReaderPagination(object: @object, kb_locale: kb_locale)
@answerPagination.html paginator.el
answer_translation = answer.translation(kb_locale.id)
if !answer_translation
@renderTranslationMissing(answer)
return
@answerTitle.text(answer_translation.title)
@renderBody(answer_translation)
@answerMeta.html App.view('knowledge_base/_reader_answer_meta')(
answer: answer
)
@renderPopovers()
renderBody: (translation) ->
body = $($.parseHTML(translation.content().body))
for linkDom in body.find('a').andSelf('a').toArray()
switch $(linkDom).attr('data-target-type')
when 'knowledge-base-answer'
if object = App.KnowledgeBaseAnswerTranslation.find $(linkDom).attr('data-target-id')
$(linkDom).attr 'href', object.uiUrl()
else
$(linkDom).attr 'href', '#'
@answerBody.html(body)
renderAttachments: (attachments) ->
@answerAttachments.html App.view('generic/attachments')(
attachments: attachments
)
renderLinkedTickets: (linked_tickets) ->
@answerLinkedTickets.html App.view('knowledge_base/_reader_linked_tickets')(
tickets: linked_tickets
)
renderTranslationMissing: (answer) ->
if !@parentController.isEditor()
@parentController.renderNotFound()
return
@renderScreenPlaceholder(
icon: App.Utils.icon('mood-ok')
detail: 'Not available in selected language'
el: @answerBody
action: 'Create a translation'
actionCallback: =>
url = answer.uiUrl(@parentController.kb_locale(), 'edit')
@navigate url
)
buildSearchReturnUrl: ->
if @parentController.lastParams.action != 'search-return'
return
decodeURIComponent @parentController.lastParams.arguments

View file

@ -0,0 +1,79 @@
class App.KnowledgeBaseReaderListContainer extends App.Controller
constructor: ->
super
@render()
@listenTo App.KnowledgeBase, 'kb_data_change_loaded', =>
@parentRefreshed()
tag: 'ul'
className: 'sections'
parentRefreshed: ->
newIds = @children().map (elem) -> elem.id
oldIds = @el.children().toArray().map (elem) -> parseInt(elem.dataset.id)
if _.isEqual(newIds, oldIds)
return
App.Delay.set(=>
@render()
, 200, "#{@constructor.className}_#{@parent.constructor.className}:#{@parent.id}", 'kb_category_refresh')
render: ->
@el.empty()
for child in @children()
@el.append new App.KnowledgeBaseReaderListItem(
item: child
isEditor: @isEditor
iconset: @parent.knowledge_base().iconset
kb_locale: @kb_locale
parentController: @
).el
class App.KnowledgeBaseReaderListContainer.Answers extends App.KnowledgeBaseReaderListContainer
children: ->
if !(@parent instanceof App.KnowledgeBaseCategory)
return []
answers = @parent.answers()
if !@isEditor
answers = answers.filter (elem) => elem.is_internally_published(@kb_locale)
answers
class App.KnowledgeBaseReaderListContainer.Categories extends App.KnowledgeBaseReaderListContainer
render: ->
super
@el.addClass "sections--#{@layout()}"
@el[0].dataset['size'] = @size()
children: ->
# coffeelint: disable=indentation
items = if @parent instanceof App.KnowledgeBase
@parent.rootCategories()
else if @parent instanceof App.KnowledgeBaseCategory
@parent.children()
else
[]
# coffeelint: enable=indentation
if !@isEditor
items = items.filter (elem) => elem.visibleInternally(@kb_locale)
items
layout: ->
if @parent instanceof App.KnowledgeBase
@parent.knowledge_base().homepage_layout
else
@parent.knowledge_base().category_layout
size: ->
if @parent instanceof App.KnowledgeBase
'large'
else
'medium'

View file

@ -0,0 +1,64 @@
class App.KnowledgeBaseReaderListController extends App.Controller
constructor: ->
super
@render()
@listenTo App.KnowledgeBase, 'kb_data_change_loaded', =>
if !@objectVisibleInternally()
@parentController.renderNotAvailableAnymore()
elements:
'.js-readerListContainer': 'container'
objectVisibleInternally: ->
@object.visibleInternally(@parentController.kb_locale())
render: ->
if !@parentController.isEditor() && (!@object || !@object.exists() || !@objectVisibleInternally())
@parentController.renderNotFound()
return
if @object.isEmpty()
@renderScreenPlaceholder(
icon: App.Utils.icon('mood-ok')
detail: 'This category is empty'
action: 'Start Editing'
actionCallback: =>
url = @object.uiUrl(@parentController.kb_locale(), 'edit')
@navigate url
)
return
@html App.view('knowledge_base/reader_list')()
@searchFieldPanel = new App.KnowledgeBaseSearchFieldPanel(
el: @$('.js-searchFieldContainer')
context: @object
kb_locale: @parentController.kb_locale()
return_path: @object.uiUrl(@parentController.kb_locale(), 'search-inline')
willStart: @searchPanelWillStart
didEnd: @searchPanelDidEnd
)
if @parentController.lastParams.action is 'search-inline'
@searchFieldPanel.widget.startSearch(@parentController.lastParams.arguments)
isEditor = @parentController.isEditor()
kb_locale = @parentController.kb_locale()
setTimeout =>
for kind in ['Categories', 'Answers']
@container.append new App.KnowledgeBaseReaderListContainer[kind](
parent: @object
isEditor: isEditor
kb_locale: kb_locale
).el
, 100
searchPanelWillStart: =>
@container.addClass('hide')
searchPanelDidEnd: =>
@container.removeClass('hide')

View file

@ -0,0 +1,34 @@
class App.KnowledgeBaseReaderListItem extends App.Controller
constructor: ->
super
@render()
@el[0].dataset.id = @item.id
@listenTo App.KnowledgeBase, 'kb_data_change_loaded', =>
@render()
tag: 'li'
className: 'section'
render: ->
if @sort_order != null && @sort_order != @item.position
App.Delay.set(=>
@parentController.parentRefreshed()
, 1000, 'kb_reader_list_resort')
@sort_order = @item.position
attrs = @item.attributesForRendering(@kb_locale, isEditor: @isEditor)
@el
.prop('className')
.split(' ')
.filter (elem) -> elem.match 'kb-item--'
.forEach (elem) -> @el.removeClass(elem)
@el.addClass attrs.className
@html App.view('knowledge_base/_reader_list_item')(
item: attrs
iconset: @iconset
)

View file

@ -0,0 +1,35 @@
class Router extends App.ControllerPermanent
requiredPermission: 'knowledge_base.*'
constructor: (params) ->
super
if params['locale']
params.selectedSystemLocale = App.Locale.findByAttribute('locale', params['locale'])
params.selectedSystemLocalePresent = true
# check authentication
@authenticateCheckRedirect()
App.TaskManager.execute(
key: 'KnowledgeBase'
controller: 'KnowledgeBaseAgentController'
params: params
show: true
persistent: true
)
[
'/category/:category_id'
'/answer/:answer_id'
''
]
.reduce((memo, elem) ->
memo.concat [elem, elem + '/:action', elem + '/:action/:arguments']
, [])
.forEach (elem) ->
url = "knowledge_base/:knowledge_base_id/locale/:locale#{elem}" # App.Utils not yet available, thus not using App.Utils.joinUrlComponents
App.Config.set(url, Router, 'Routes')
App.Config.set('knowledge_base', Router, 'Routes')

View file

@ -0,0 +1,20 @@
class App.KnowledgeBaseScheduledWidget extends App.Controller
className: 'scheduled-widget'
constructor: ->
super
@el.attr('data-state', @state)
@el.data('date', @getDate())
@render()
getDate: ->
if string = @object["#{@state}_at"]
new Date(string)
render: ->
@html App.view('knowledge_base/scheduled_widget')(
timestamp: App.i18n.translateTimestamp(@getDate())
state: @state
)

View file

@ -0,0 +1,20 @@
class App.KnowledgeBaseSearchController extends App.Controller
constructor: ->
super
@html App.view('knowledge_base/search')(
knowledge_base: @parentController.getKnowledgeBase()
kb_locale: @parentController.kb_locale()
)
@searchFieldPanel = new App.KnowledgeBaseSearchFieldPanel(
el: @$('.js-searchFieldContainer')
context: @parentController.getKnowledgeBase()
kb_locale: @parentController.kb_locale()
return_path: @parentController.getKnowledgeBase().uiUrl(@parentController.kb_locale(), 'search')
)
if query = @parentController.lastParams.arguments
@searchFieldPanel.widget.startSearch(query)
@searchFieldPanel.widget.focus()

View file

@ -0,0 +1,84 @@
class App.KnowledgeBaseSearchFieldPanel extends App.Controller
elements:
'.js-placeholderEmpty': 'emptyPlaceholder'
'.js-placeholderError': 'errorPlaceholder'
'.js-results': 'resultsContainer'
context: undefined
kb_locale: null
#callbacks
willStart: null
didEnd: null
constructor: ->
super
@html App.view('knowledge_base/search_field_panel')()
@widget = new App.KnowledgeBaseSearchFieldWidget(
el: @$('.searchfield')
kb_locale: @kb_locale
context: @context
willStart: @widgetWillStart
didEnd: @widgetDidEnd
willStartLoading: @widgetWillStartLoading
renderError: @renderError
renderResults: @renderResults
)
clear: =>
@resultsContainer.empty()
@errorPlaceholder.addClass('hide')
@emptyPlaceholder.addClass('hide')
widgetWillStart: =>
@willStart?()
widgetDidEnd: =>
@clear()
@didEnd?()
widgetWillStartLoading: =>
@clear()
renderError: (text) =>
@errorPlaceholder
.removeClass('hide')
.find('.help-block--inner')
.text(App.i18n.translateInline(text))
renderResults: (results, originalQuery) =>
@clear()
if results.result.length == 0
@emptyPlaceholder.removeClass('hide')
return
suffix = @buildReturnSuffix(originalQuery)
return_path = App.Utils.joinUrlComponents(@return_path, originalQuery)
views = results
.result
.map (elem, index) ->
details = results.details[index]
klass_name = elem.type.replace /::/g, ''
object = App[klass_name].find(elem.id)
new App.KnowledgeBaseSearchItem(
object: object
meta: elem
details: details
pathSuffix: suffix
return_path: return_path
)
.map (elem) -> elem.el
@resultsContainer.append views
buildReturnSuffix: (query) ->
encodeURIComponent App.Utils.joinUrlComponents(@return_path, query)

View file

@ -0,0 +1,117 @@
class App.KnowledgeBaseSearchFieldWidget extends App.Controller
className: 'searchfield'
elements:
'.js-searchField': 'searchField'
'.js-emptySearchButton': 'emptySearchButton'
events:
'input .js-searchField': 'input'
'click .js-emptySearchButton': 'clear'
isActive: false
context: undefined
kb_locale: null
# callbacks
renderError: null
renderResults: null
willStartLoading: null
willStart: null
didEnd: null
constructor: ->
super
@cache = {}
@html App.view('knowledge_base/search_field_widget')(
placeholder_suffix: @context?.guaranteedTitle(@kb_locale.id)
)
clear: ->
@searchField.val('')
@emptySearchButton.addClass 'hide'
@isActive = false
@didEnd?()
input: ->
query = @searchField.val()
@emptySearchButton.toggleClass 'hide', query.length == 0
if query == ''
@abortAjaxCalls()
@isActive = false
@didEnd?()
return
if !@isActive
@isActive = true
@willStart?()
@willStartLoading?()
@searchField.addClass('loading')
@delay( =>
@makeRequest(query)
, 100, 'makeRequest')
data: (query) ->
attrs = {
query: query,
flavor: 'agent',
knowledge_base_id: @context.knowledge_base().id
locale: @kb_locale.systemLocale().locale
}
if @context instanceof App.KnowledgeBaseCategory
attrs['scope_id'] = @context.id
attrs
url: ->
App.Utils.joinUrlComponents(App.KnowledgeBase.url, 'search')
makeRequest: (query) ->
if (cachedResult = @cache[query])
@onSuccess(cachedResult)
return
@ajax(
id: 'kb_search_loading'
type: 'POST'
url: @url()
data: JSON.stringify(@data(query))
success: (data, status, xhr) =>
@cache[query] = data
@onSuccess(data, query)
error: @onError
)
onError: (xhr) =>
if xhr.status == 0
if @ajaxCalls.length == 0
@searchField.removeClass('loading')
return
@searchField.removeClass('loading')
text = xhr.responseJSON?.error_human || xhr.responseJSON?.errorr || 'Unable to load'
@renderError(text)
onSuccess: (data, originalQuery) =>
@searchField.removeClass('loading')
App.Collection.loadAssets(data.assets)
@renderResults?(data, originalQuery)
focus: ->
@searchField.focus()
startSearch: (query) ->
@searchField
.val(query)
.trigger('input')

View file

@ -0,0 +1,23 @@
class App.KnowledgeBaseSearchItem extends App.Controller
tag: 'li'
className: 'section'
events:
'click a': 'searchLinkClicked'
constructor: ->
super
@render()
data: ->
output = @details || {}
output['url'] = @object?.uiUrl("search-return/#{@pathSuffix}") || '#'
output
render: ->
@html App.view('knowledge_base/search_item')(data: @data())
searchLinkClicked: -> # setup history and let it continue, no need to prevent default action or bubbling
if window.history? and @return_path?
window.history.replaceState(null, null, @return_path)

View file

@ -0,0 +1,63 @@
class App.KnowledgeBaseSidebar extends App.Controller
@extend(Spine.Events)
events:
'click .js-content-actions-container a': 'contentActionClicked'
constructor: ->
super
@show()
@bind 'knowledge_base::sidebar::rerender', => @rerender()
@listenTo App.KnowledgeBase, 'kb_data_change_loaded', =>
@rerender()
true
rerender: ->
@show(@savedParams, @savedAction)
contentActionClicked: (e) ->
# coffeelint: disable=indentation
actionName = switch e.target.dataset.action
when 'delete' then 'clickedDelete'
when 'visibility' then 'clickedCanBePublished'
# coffeelint: enable=indentation
@parentController.bodyModal = @parentController.coordinator[actionName]?(@savedParams)
show: (object, action) ->
isEdit = action is 'edit'
@el.toggleClass('hidden', !isEdit)
@savedParams = object
@savedAction = action
@el.empty()
if !isEdit
return
for widget in @widgets(object)
@el.append new widget(
object: object
kb_locale: @parentController.kb_locale()
parentController: @parentController
).el
hide: ->
@el.addClass('hidden')
widgets: (object) ->
output = [App.KnowledgeBaseSidebarActions]
if object instanceof App.KnowledgeBase || object instanceof App.KnowledgeBaseCategory
output.push App.KnowledgeBaseSidebarCategories
if object instanceof App.KnowledgeBaseCategory
output.push App.KnowledgeBaseSidebarAnswers
if object instanceof App.KnowledgeBaseAnswer
output.push App.KnowledgeBaseSidebarLinkedTickets
output.push App.KnowledgeBaseSidebarAttachments
output

View file

@ -0,0 +1,54 @@
class App.KnowledgeBaseSidebarGenericList extends App.Controller
className: 'sidebar-block'
events:
'click .js-reorder': 'openReorder'
'click .js-add': 'openAdd'
constructor: ->
super
@html App.view('knowledge_base/sidebar/generic_list')(@templateOptions())
templateOptions: ->
iconset: @object.knowledge_base().iconset
items: @items()
urlNew: @urlNew()
enabled: true
title: @title
emptyNote: @emptyNote
openReorder: (e) ->
e.preventDefault()
e.stopPropagation()
@parentController.bodyModal = new App.ControllerReorderModal(
container: @parentController.body
items: @items()
url: @reorderSaveUrl()
)
openAdd: (e) ->
e.preventDefault()
e.stopPropagation()
newObject = @newObject()
newObject.isFresh = true
@parentController.bodyModal = new App.KnowledgeBaseAddForm(
object: newObject
container: @parentController.body
parentController: @parentController
)
newObject: ->
#has to be overridden
reorderSaveUrl: ->
#has to be overridden
items: ->
#has to be overridden
urlNew: ->
#has to be overridden

View file

@ -0,0 +1,14 @@
class App.KnowledgeBaseSidebarActions extends App.Controller
className: 'sidebar-block'
constructor: ->
super
actions = @object?.contentSidebarActions(@kb_locale)
html = if actions
App.view('knowledge_base/sidebar/actions')(actions: actions)
else
''
@html html

View file

@ -0,0 +1,23 @@
class App.KnowledgeBaseSidebarAnswers extends App.KnowledgeBaseSidebarGenericList
templateName: 'answers'
title: 'Answers'
emptyNote: 'No answers'
urlNew: ->
"#knowledge_base/#{@object.knowledge_base().id}/category/#{@object.id}/answers/new"
answers: ->
@object.answers()
items: ->
@answers()
.sort (a, b) ->
a.position - b.position
.map (elem) =>
elem.attributesForRendering(@kb_locale, action: 'edit', isEditor: true)
reorderSaveUrl: ->
@object.generateURL('reorder_answers')
newObject: ->
new App.KnowledgeBaseAnswer(category_id: @object.id)

View file

@ -0,0 +1,135 @@
class App.KnowledgeBaseSidebarAttachments extends App.Controller
className: 'sidebar-block'
events:
'click .js-delete': 'delete'
'html5Upload.dropZone.show': 'showDropZone'
'html5Upload.dropZone.hide': 'hideDropZone'
elements:
'.attachmentUpload-progressBar': 'progressBar'
'.js-percentage': 'progressText'
'.attachmentPlaceholder': 'attachmentPlaceholder'
'.attachmentUpload': 'attachmentUpload'
'.js-cancel': 'cancelContainer'
'input': 'input'
'.dropContainer': 'dropContainer'
constructor: ->
super
@render()
@listenTo @object, 'refresh', @needsUpdate
needsUpdate: =>
@render()
render: ->
@html App.view('knowledge_base/sidebar/attachments')(
attachments: @object.attachments
)
html5Upload.initialize(
uploadUrl: @object.generateURL('attachments')
dropContainer: @el.get(0)
cancelContainer: @cancelContainer
inputField: @input.get(0)
maxSimultaneousUploads: 1,
key: 'file'
onFileAdded: @onFileAdded
)
delete: (e) =>
e.preventDefault()
id = parseInt($(e.currentTarget).attr('data-object-id'))
attachment = @object.attachments.filter((elem) -> elem.id == id)[0]
new DeleteConfirm(
container: @container
answer: @object
attachment: attachment
parentController: @
)
fetch: =>
@ajax(
id: "attachments_#{@object.id}_knowledge_base_answer"
type: 'GET'
url: @object.generateURL() + '?full=true'
processData: true
success: (data, status, xhr) =>
App.Collection.loadAssets(data.assets)
@render()
)
onFileAdded: (file) =>
file.on(
onStart: @onStart
onAborted: @onAborted
onCompleted: @onCompleted
onProgress: @onProgress
)
onStart: =>
@attachmentPlaceholder.addClass('hide')
@attachmentUpload.removeClass('hide')
@cancelContainer.removeClass('hide')
onAborted: =>
@attachmentPlaceholder.removeClass('hide')
@attachmentUpload.addClass('hide')
@input.val('')
onCompleted: (response) =>
@attachmentPlaceholder.removeClass('hide')
@attachmentUpload.addClass('hide')
@progressBar.width(parseInt(0) + '%')
@progressText.text('')
@input.val('')
data = JSON.parse(response)
App.Collection.loadAssets(data)
onProgress: (progress, fileSize, uploadedBytes) =>
@progressBar.width(parseInt(progress) + '%')
@progressText.text(parseInt(progress))
# hide cancel on 90%
if parseInt(progress) >= 90
@cancelContainer.addClass('hide')
showDropZone: ->
if @dropContainer.hasClass('is-dropTarget')
return
@dropContainer.addClass('is-dropTarget')
hideDropZone: ->
@dropContainer.removeClass('is-dropTarget')
class DeleteConfirm extends App.ControllerConfirm
content: ->
sentence = App.i18n.translateContent('Are you sure to delete')
"#{sentence} #{@attachment.filename}?"
buttonSubmit: 'delete'
onSubmit: ->
@formDisable(@el)
@ajax(
id: 'attachment_delete'
type: 'DELETE'
url: @answer.generateURL("attachments/#{@attachment.id}")
processData: true
success: @success
error: @error
)
success: (data, status, xhr) =>
@close()
App.Collection.loadAssets(data)
@parentController.render()
error: (xhr) =>
@formEnable(@el)
@showAlert(xhr.responseJSON?.error || 'Unable to save changes')

View file

@ -0,0 +1,45 @@
class App.KnowledgeBaseSidebarCategories extends App.KnowledgeBaseSidebarGenericList
templateName: 'categories'
title: 'Categories'
emptyNote: 'No categories'
constructor: ->
super
templateOptions: ->
attrs = super
attrs.isRoot = @object instanceof App.KnowledgeBase
attrs
urlNew: ->
prefix = "#knowledge_base/#{@object.knowledge_base().id}/category/"
if @object instanceof App.KnowledgeBaseCategory
prefix + "#{@object.id}/new"
else if @object instanceof App.KnowledgeBase
prefix + 'category/new'
categories: ->
if @object instanceof App.KnowledgeBaseCategory
@object.children()
else if @object instanceof App.KnowledgeBase
@object.rootCategories()
else
[]
items: ->
@categories()
.sort (a, b) ->
a.position - b.position
.map (elem) =>
elem.attributesForRendering(@kb_locale, action: 'edit', isEditor: true)
reorderSaveUrl: ->
if @object instanceof App.KnowledgeBaseCategory
@object.generateURL('reorder_categories')
else
@object.url() + '/categories/reorder_root_categories'
newObject: ->
parent = if @object instanceof App.KnowledgeBaseCategory then @object
new App.KnowledgeBaseCategory(parent_id: parent?.id, knowledge_base_id: @object.knowledge_base().id)

View file

@ -0,0 +1,68 @@
class App.KnowledgeBaseSidebarLinkedTickets extends App.Controller
@extend App.PopoverProvidable
@registerPopovers 'Ticket'
className: 'sidebar-block'
events:
'click .js-add': 'clickedAdd'
'click .js-delete': 'delete'
constructor: ->
super
@render()
@listenTo @object, 'refresh', @needsUpdate
needsUpdate: =>
@render()
render: ->
@html App.view('knowledge_base/sidebar/linked_tickets')(
tickets: @object.translation(@kb_locale.id)?.linked_tickets() || []
)
@renderPopovers()
fetch: =>
@ajax(
id: "links_#{@object.id}_knowledge_base_answer"
type: 'GET'
url: @object.generateURL() + '?full=true'
processData: true
success: (data, status, xhr) =>
App.Collection.loadAssets(data.assets)
@render()
)
clickedAdd: (e) =>
e.preventDefault()
new App.TicketLinkAdd(
link_object: 'KnowledgeBase::Answer::Translation'
link_object_id: @object.translation(@kb_locale.id)?.id
link_types: [['normal', 'Normal']]
object: @object.translation(@kb_locale.id)
parent: @
container: @el.closest('.content')
)
delete: (e) =>
e.preventDefault()
data =
link_type: $(e.currentTarget).data('link-type')
link_object_source: $(e.currentTarget).data('object')
link_object_source_value: $(e.currentTarget).data('object-id')
link_object_target: 'KnowledgeBase::Answer::Translation'
link_object_target_value: @object.translation(@kb_locale.id)?.id
# get data
@ajax(
id: "links_remove_#{@object.id}_#{@object_type}"
type: 'GET'
url: "#{@apiPath}/links/remove"
data: data
processData: true
success: @fetch
)

View file

@ -2239,4 +2239,137 @@ class ChatToTicketRef extends App.ControllerContent
y2: y1 + @attachments.outerHeight() y2: y1 + @attachments.outerHeight()
App.Config.set('layout_ref/chat_to_ticket', ChatToTicketRef, 'Routes') App.Config.set('layout_ref/chat_to_ticket', ChatToTicketRef, 'Routes')
class KnowledgeBaseAgentReaderRef extends App.ControllerContent
className: 'flex knowledge-base vertical'
elements:
'.js-search': 'searchInput'
events:
'click [data-target]': 'onTargetClicked'
'click .js-open-search': 'toggleSearch'
constructor: ->
super
App.Utils.loadIconFont('anticon')
@render()
@level(1)
render: ->
@html App.view('layout_ref/kb_agent_reader_ref')()
toggleSearch: (event) ->
active = $(event.currentTarget).toggleClass('btn--primary')
if $(event.currentTarget).is('.btn--primary')
@el.find('.main[data-level]').addClass('hidden')
@el.find('[data-level~="search"]').removeClass('hidden')
@searchInput.focus()
else
@el.find("[data-level~=\"#{@currentLevel}\"]").removeClass('hidden')
@el.find('[data-level~="search"]').addClass('hidden')
onTargetClicked: (event) ->
event.preventDefault()
@level(event.currentTarget.dataset.target)
level: (level) ->
@currentLevel = level
@el.find('[data-level]').addClass('hidden')
@el.find("[data-level~=\"#{@currentLevel}\"]").removeClass('hidden')
App.Config.set('layout_ref/kb_agent_reader', KnowledgeBaseAgentReaderRef, 'Routes')
class KnowledgeBaseLinkTicketToAnswerRef extends App.ControllerContent
constructor: ->
super
App.Utils.loadIconFont('anticon')
@render()
render: =>
new App.ControllerModal
head: 'Link Answer'
buttonSubmit: false
container: @el
content: App.view('layout_ref/kb_link_ticket_to_answer_ref')
App.Config.set('layout_ref/kb_link_ticket_to_answer', KnowledgeBaseLinkTicketToAnswerRef, 'Routes')
class KnowledgeBaseLinkAnswerToAnswerRef extends App.ControllerContent
elements:
'.js-form': 'form'
constructor: ->
super
@render()
render: ->
@html App.view('layout_ref/kb_link_answer_to_answer_ref')()
new App.ControllerForm(
grid: true
params:
category_id: 2
translation_ids: [
1
2
]
archived_at: null
internal_at: null
published_at: '2018-10-22T13:58:08.730Z'
attachments: []
id: 1
translation:
title: 'Lithium en-us'
content:
body:
text: 'Lithium (from Greek: λίθος, translit. lithos, lit. "stone") is a chemical element with symbol Li and atomic number 3. It is a soft, silvery-white alkali metal. Under standard conditions, it is the lightest metal and the lightest solid element. Like all alkali metals, lithium is highly reactive and flammable, and is stored in mineral oil.'
attachments: []
id: 1
answer_id: 1
id: 1
screen: 'agent'
autofocus: true
el: @form
model:
configure_attributes: [
{
name: 'translation::title'
model: 'translation'
display: 'Title'
tag: 'input'
grid_width: '1/2'
}
{
name: 'category_id'
model: 'answer'
display: 'Category'
tag: 'select'
null: true
options: [
{
value: 1
name: 'Metal'
}
{
value: 2
name: 'Alkali metal'
}
]
grid_width: '1/2'
}
{
name: 'translation::content::body'
model: 'translation'
display: 'Content'
tag: 'richtext'
buttons: [
'link'
'link_answer'
]
}
]
)
App.Config.set('layout_ref/kb_link_answer_to_answer', KnowledgeBaseLinkAnswerToAnswerRef, 'Routes')
App.Config.set('LayoutRef', { prio: 1600, parent: '#current_user', name: 'Layout Reference', translate: true, target: '#layout_ref', permission: [ 'admin' ] }, 'NavBarRight') App.Config.set('LayoutRef', { prio: 1600, parent: '#current_user', name: 'Layout Reference', translate: true, target: '#layout_ref', permission: [ 'admin' ] }, 'NavBarRight')

View file

@ -21,6 +21,7 @@ class App.Navigation extends App.ControllerWidgetPermanent
'click .js-global-search-result': 'emptyAndCloseDelayed' 'click .js-global-search-result': 'emptyAndCloseDelayed'
'click .js-details-link': 'openExtendedSearch' 'click .js-details-link': 'openExtendedSearch'
'change .js-menu .js-switch input': 'switch' 'change .js-menu .js-switch input': 'switch'
'click .js-onclick': 'click'
constructor: -> constructor: ->
super super
@ -97,6 +98,10 @@ class App.Navigation extends App.ControllerWidgetPermanent
item.switch = worker.switch() item.switch = worker.switch()
if worker.active && worker.active() if worker.active && worker.active()
activeTab[item.target] = true activeTab[item.target] = true
if worker.onclick
item.onclick = worker.onclick()
if worker.accessoryIcon
item.accessoryIcon = worker.accessoryIcon()
if worker.featureActive if worker.featureActive
if worker.featureActive() if worker.featureActive()
shown = true shown = true
@ -120,6 +125,13 @@ class App.Navigation extends App.ControllerWidgetPermanent
activeTab: activeTab activeTab: activeTab
) )
click: (e) ->
@preventDefaultAndStopPropagation(e)
key = $(e.currentTarget).data('key')
worker = App.TaskManager.worker(key)
worker.clicked(e)
# on switch changes and execute it on controller # on switch changes and execute it on controller
switch: (e) -> switch: (e) ->
val = $(e.target).prop('checked') val = $(e.target).prop('checked')

View file

@ -85,9 +85,9 @@ class App.Search extends App.Controller
@tabs = [] @tabs = []
for model in App.Config.get('models_searchable') for model in App.Config.get('models_searchable')
model = model.replace(/::/, '') model = model.replace(/::/g, '')
tab = tab =
name: model name: App[model]?.display_name || model
model: model model: model
count: 0 count: 0
active: false active: false

View file

@ -131,6 +131,9 @@ class ArticleViewItem extends App.ObserverController
attachments = App.TicketArticle.contentAttachments(article) attachments = App.TicketArticle.contentAttachments(article)
if article.attachments if article.attachments
for attachment in article.attachments for attachment in article.attachments
attachment.url = "#{App.Config.get('api_path')}/ticket_attachment/#{article.ticket_id}/#{article.id}/#{attachment.id}?disposition=attachment"
attachment.preview_url = "#{App.Config.get('api_path')}/ticket_attachment/#{article.ticket_id}/#{article.id}/#{attachment.id}?view=preview"
if attachment && attachment.preferences && attachment.preferences['original-format'] is true if attachment && attachment.preferences && attachment.preferences['original-format'] is true
link = link =
url: "#{App.Config.get('api_path')}/ticket_attachment/#{article.ticket_id}/#{article.id}/#{attachment.id}?disposition=attachment" url: "#{App.Config.get('api_path')}/ticket_attachment/#{article.ticket_id}/#{article.id}/#{attachment.id}?disposition=attachment"
@ -192,7 +195,7 @@ class ArticleViewItem extends App.ObserverController
@html App.view('ticket_zoom/article_view')( @html App.view('ticket_zoom/article_view')(
ticket: @ticket ticket: @ticket
article: article article: article
attachments: attachments attachments: App.view('generic/attachments')(attachments: attachments)
links: links links: links
) )

View file

@ -54,6 +54,19 @@ class Edit extends App.ObserverController
) )
class SidebarTicket extends App.Controller class SidebarTicket extends App.Controller
constructor: ->
super
@bind 'config_update_local', (data) => @configUpdated(data)
configUpdated: (data) ->
if data.name != 'kb_active'
return
if data.value
return
@editTicket(@el)
sidebarItem: => sidebarItem: =>
@item = { @item = {
name: 'ticket' name: 'ticket'
@ -96,6 +109,9 @@ class SidebarTicket extends App.Controller
if @linkWidget && args.links if @linkWidget && args.links
@linkWidget.reload(args.links) @linkWidget.reload(args.links)
if @linkKbAnswerWidget && args.links
@linkKbAnswerWidget.reload(args.links)
editTicket: (el) => editTicket: (el) =>
@el = el @el = el
localEl = $(App.view('ticket_zoom/sidebar_ticket')()) localEl = $(App.view('ticket_zoom/sidebar_ticket')())
@ -121,6 +137,15 @@ class SidebarTicket extends App.Controller
object: @ticket object: @ticket
links: @links links: @links
) )
if @permissionCheck('knowledge_base.*') and App.Config.get('kb_active')
@linkKbAnswerWidget = new App.WidgetLinkKbAnswer(
el: localEl.filter('.link_kb_answers')
object_type: 'Ticket'
object: @ticket
links: @links
)
@timeUnitWidget = new App.TicketZoomTimeUnit( @timeUnitWidget = new App.TicketZoomTimeUnit(
el: localEl.filter('.js-timeUnit') el: localEl.filter('.js-timeUnit')
object_id: @ticket.id object_id: @ticket.id

View file

@ -61,8 +61,8 @@ class Index extends App.ControllerSubContent
display: 'Action' display: 'Action'
className: 'actionCell' className: 'actionCell'
translation: true translation: true
width: '200px' width: '250px'
displayWidth: 200 displayWidth: 250
unresizable: true unresizable: true
header.push attribute header.push attribute
header header
@ -70,7 +70,7 @@ class Index extends App.ControllerSubContent
callbackAttributes = (value, object, attribute, header) -> callbackAttributes = (value, object, attribute, header) ->
text = App.i18n.translateInline('View from user\'s perspective') text = App.i18n.translateInline('View from user\'s perspective')
value = ' ' value = ' '
attribute.raw = ' <span class="btn btn--primary btn--table switchView" title="' + text + '">' + App.Utils.icon('switchView') + text + '</span>' attribute.raw = ' <span class="btn btn--primary btn--small btn--slim switchView" title="' + text + '">' + App.Utils.icon('switchView') + '<span>' + text + '</span></span>'
attribute.class = '' attribute.class = ''
attribute.parentClass = 'actionCell no-padding' attribute.parentClass = 'actionCell no-padding'
attribute.link = '' attribute.link = ''

View file

@ -0,0 +1,93 @@
class App.AnswerList extends App.Controller
@extend App.PopoverProvidable
@registerPopovers 'Organization', 'User'
constructor: ->
super
@render()
render: =>
openTicket = (id,e) =>
ticket = App.Ticket.findNative(id)
@navigate ticket.uiUrl()
callbackTicketTitleAdd = (value, object, attribute, attributes) ->
attribute.title = object.title
value
'1111'
callbackLinkToTicket = (value, object, attribute, attributes) ->
attribute.link = object.uiUrl()
value
'22222'
callbackUserPopover = (value, object, attribute, attributes) ->
return value if !object
refObjectId = undefined
if attribute.name is 'customer_id'
refObjectId = object.customer_id
if attribute.name is 'owner_id'
refObjectId = object.owner_id
return value if !refObjectId
attribute.class = 'user-popover'
attribute.data =
id: refObjectId
value
callbackOrganizationPopover = (value, object, attribute, attributes) ->
return value if !object
return value if !object.organization_id
attribute.class = 'organization-popover'
attribute.data =
id: object.organization_id
value
callbackIconHeader = (headers) ->
attribute =
name: 'icon'
display: ''
translation: false
width: '28px'
displayWidth:28
unresizable: true
headers.unshift(0)
headers[0] = attribute
headers
callbackIcon = (value, object, attribute, header) ->
value = ' '
attribute.class = object.iconClass()
attribute.link = ''
attribute.title = object.iconTitle()
value
list = []
for ticket_id in @ticket_ids
ticketItem = App.KnowledgeBaseAnswer.fullLocal(ticket_id)
list.push ticketItem
@el.html('')
new App.ControllerTable(
tableId: @tableId
el: @el
overview: @columns || [ 'id', 'translation::title', 'customer', 'group', 'created_at' ]
model: App.KnowledgeBaseAnswer
objects: list
#bindRow:
# events:
# 'click': openTicket
callbackHeader: [ callbackIconHeader ]
callbackAttributes:
#icon:
#[ callbackIcon ]
#customer_id:
#[ callbackUserPopover ]
organization_id:
[ callbackOrganizationPopover ]
owner_id:
[ callbackUserPopover ]
title:
[ callbackLinkToTicket, callbackTicketTitleAdd ]
number:
[ callbackLinkToTicket, callbackTicketTitleAdd ]
radio: @radio
)
@renderPopovers()

View file

@ -0,0 +1,31 @@
class App.WidgetButtonWithDropdown extends App.Controller
elements:
'.dropdown-menu-accessories': 'accessoriesContainer'
events:
'click li': 'clickedOption'
constructor: ->
super
@render()
mainActionLabel: 'Submit'
mainActionIdentifier: 'js-submit'
accessoryActionsIdentifier: 'js-submit-action'
render: ->
@el.addClass 'buttonDropdown dropdown dropup'
@html App.view('widget/button_with_dropdown')(
mainActionIdentifier: @mainActionIdentifier
accessoryActionsIdentifier: @accessoryActionsIdentifier
mainActionLabel: @mainActionLabel
actions: @actions || []
)
clickedOption: (e) ->
if e.currentTarget.hasAttribute('disabled')
@preventDefaultAndStopPropagation(e)
return
@accessoriesContainer.blur()

View file

@ -0,0 +1,105 @@
class App.WidgetLinkKbAnswer extends App.WidgetLink
@registerPopovers 'KnowledgeBaseAnswer'
elements:
'.js-add': 'addButton'
'.searchableSelect': 'searchableSelect'
'.js-shadow': 'shadowField'
'.js-input': 'inputField'
events:
'change .js-shadow': 'didSubmit'
'blur .js-input': 'didBlur'
getAjaxAttributes: (field, attributes) ->
@apiPath = App.Config.get('api_path')
attributes.type = 'POST'
attributes.url = "#{@apiPath}/knowledge_bases/search"
attributes.data.flavor = 'agent'
attributes.data.include_locale = true
attributes.data.index = 'KnowledgeBase::Answer::Translation'
attributes.data.highlight_enabled = false
attributes.data = JSON.stringify(attributes.data)
attributes
linksForRendering: ->
@localLinks
.map (elem) ->
switch elem.link_object
when 'KnowledgeBase::Answer::Translation'
if translation = App.KnowledgeBaseAnswerTranslation.fullLocal( elem.link_object_value )
title: translation.title
id: translation.id
url: translation.uiUrl()
.filter (elem) ->
elem?
render: ->
@html App.view('link/kb_answer')(
list: @linksForRendering()
)
@renderPopovers()
@el.append(new App.SearchableAjaxSelect(
delegate: @
useAjaxDetails: true
attribute:
id: 'link_kb_answer'
name: 'input'
placeholder: App.i18n.translateInline('Search...')
limit: 40
object: 'KnowledgeBaseAnswerTranslation'
ajax: true
).element())
@refreshElements()
@searchableSelect.addClass('hidden')
didSubmit: =>
@clearDelay('hideField')
@inputField.attr('disabled', true)
@saveToServer(@shadowField.val())
didBlur: (e) =>
@delay( =>
@setInputVisible(false)
, 200, 'hideField')
add: ->
@shadowField.val('')
@inputField.attr('disabled', false).val('')
@setInputVisible(true)
@inputField.focus()
setInputVisible: (setInputVisible) ->
@searchableSelect.toggleClass('hidden', !setInputVisible)
@addButton.toggleClass('hidden', setInputVisible)
saveToServer: (id) ->
@ajax(
id: "links_add_#{@object.id}_#{@object_type}"
type: 'GET'
url: "#{@apiPath}/links/add"
data:
link_type: 'normal'
link_object_target: 'Ticket'
link_object_target_value: @object.id
link_object_source: 'KnowledgeBase::Answer::Translation'
link_object_source_number: id
processData: true
success: (data, status, xhr) =>
@fetch()
@setInputVisible(false)
error: (xhr, statusText, error) =>
@setInputVisible(false)
@notify(
type: 'error'
msg: App.i18n.translateContent(xhr.responseJSON?.error || "Couldn't save changes")
removeAll: true
)
)

View file

@ -28,7 +28,7 @@ class App.Track
class _trackSingleton class _trackSingleton
constructor: -> constructor: ->
@trackId = "track-#{new Date().getTime()}-#{Math.floor(Math.random() * 99999)}" @trackId = "track-#{new Date().getTime()}-#{Math.floor(Math.random() * 99999)}"
@browser = App.Browser.detection() @browser = App.Browser.detection() if App.Browser
@data = [] @data = []
# @url = 'http://localhost:3005/api/v1/ui' # @url = 'http://localhost:3005/api/v1/ui'
@url = 'https://log.zammad.com/api/v1/ui' @url = 'https://log.zammad.com/api/v1/ui'

View file

@ -0,0 +1,140 @@
# coffeelint: disable=camel_case_classes
class App.Color extends Spine.Controller
hsl: undefined
elements:
'.js-input': 'input'
'.js-shadow': 'shadow'
'.js-swatch': 'swatch'
'.js-colorpicker-hue-saturation': 'hueSaturation'
'.js-colorpicker-lightness-plane': 'lightnessPlane'
'.js-colorpicker-saturation-gradient': 'saturationGradient'
'.js-colorpicker-circle': 'circle'
'.js-colorpicker-lightness': 'lightness'
'.js-colorpicker-hue-plane': 'huePlane'
'.js-colorpicker-slider': 'slider'
events:
'input .js-input': 'onInput'
'mousedown .js-colorpicker-hue-saturation': 'onHueSaturationMousedown'
'mousedown .js-colorpicker-lightness': 'onLightnessMousedown'
'click .js-dropdown': 'stopPropagation'
stopPropagation: (event) ->
event.stopPropagation()
constructor: ->
super
@render()
element: =>
@el
render: ->
@hsl = @rgbToHsl(@parseColor(@attribute.value))
@html App.view('generic/color')
attribute: @attribute
hsl: @hsl
onInput: ->
@update @input.val()
@output()
update: (color) ->
@updateSwatch(color)
@hsl = @rgbToHsl(@parseColor(color))
@renderPicker()
updateSwatch: (color) ->
@swatch.css 'background-color', ''
@swatch.css 'background-color', color
output: ->
hslString = @hslString(@hsl)
@input.val hslString
@updateSwatch hslString
@shadow.val @rgbToHex(@parseColor(hslString))
componentToHex: (c) ->
hex = c.toString(16)
if hex.length == 1 then '0' + hex else hex
rgbToHex: (rgba) ->
'#' + @componentToHex(rgba[0]) + @componentToHex(rgba[1]) + @componentToHex(rgba[2])
parseColor: (color) ->
canvas = document.createElement('canvas')
canvas.width = canvas.height = 1
ctx = canvas.getContext('2d')
ctx.clearRect(0, 0, 1, 1)
ctx.fillStyle = color
ctx.fillRect(0, 0, 1, 1)
ctx.getImageData(0, 0, 1, 1).data
rgbToHsl: (rgb) ->
return [0, 0, 0] if !rgb
r = rgb[0] / 255
g = rgb[1] / 255
b = rgb[2] / 255
max = Math.max(r, g, b)
min = Math.min(r, g, b)
l = (max + min) / 2
if (max == min)
h = s = 0 # achromatic
else
d = max - min
s = if l > 0.5 then d / (2 - max - min) else d / (max + min)
h = switch
when r is max then (g - b) / d + (g < b ? 6 : 0)
when g is max then (b - r) / d + 2
when b is max then (r - g) / d + 4
h /= 6
[h, s, l]
hslString: ->
"hsl(#{Math.round(360 * @hsl[0])},#{Math.round(100 * @hsl[1])}%,#{Math.round(100 * @hsl[2])}%)"
onHueSaturationMousedown: (event) ->
@offset = @hueSaturation.offset()
$(document).on 'mousemove.colorpicker', @onHueSaturationMousemove
$(document).on 'mouseup.colorpicker', @onMouseup
@onHueSaturationMousemove(event)
onHueSaturationMousemove: (event) =>
@hsl[0] = Math.max(0, Math.min(1, (event.pageX - @offset.left)/@hueSaturation.width()))
@hsl[1] = Math.max(0, Math.min(1, 1-(event.pageY - @offset.top)/@hueSaturation.height()))
@renderPicker()
@output()
onLightnessMousedown: (event) ->
@offset = @lightness.offset()
$(document).on 'mousemove.colorpicker', @onLightnessMousemove
$(document).on 'mouseup.colorpicker', @onMouseup
@onLightnessMousemove(event)
onLightnessMousemove: (event) =>
@hsl[2] = Math.max(0, Math.min(1, 1-(event.pageY - @offset.top)/@lightness.height()))
@renderPicker()
@output()
onMouseup: ->
$(document).off 'mousemove.colorpicker'
$(document).off 'mouseup.colorpicker'
renderPicker: ->
@lightnessPlane.css 'background-color': "hsla(0,0%,#{if @hsl[2] > 0.5 then 100 else 0}%,#{2*Math.abs(@hsl[2]-0.5)})"
@saturationGradient.css 'background-image': "linear-gradient(transparent, hsl(0, 0%, #{@hsl[2]*100}%))"
@circle.css
left: @hsl[0]*100 +'%'
top: 100 - @hsl[1]*100 +'%'
borderColor: if @hsl[2] > 0.5 then 'black' else 'white'
@huePlane.css 'background-color': "hsl(#{@hsl[0]*360}, 100%, 50%)"
@slider.css top: 100 - @hsl[2]*100 +'%'

View file

@ -0,0 +1,154 @@
# coffeelint: disable=camel_case_classes
class App.IconPicker extends Spine.Controller
library: null
empty: false
columns: 8
currentItem: null
events:
'focus .js-input': 'onFocus'
'input .js-filter-icons': 'filterIcons'
'click .js-filter-icons': 'stopPropagation'
'click .js-pick': 'onIconClick'
'mouseenter .js-pick': 'highlightItem'
'shown.bs.dropdown': 'onPickerOpen'
'hidden.bs.dropdown': 'onPickerClose'
'focus .js-shadow': 'onShadowFocus'
elements:
'.js-iconGrid': 'iconGrid'
'.js-noMatch': 'noMatch'
'.js-shadow': 'shadow'
'.js-input': 'input'
'.js-filter-icons': 'filter'
'.js-pick': 'icons'
stopPropagation: (event) ->
event.stopPropagation()
constructor: ->
super
@throttledRenderIcons = _.throttle(@renderIcons, 300)
@render()
App.Utils.loadIconFont(@attribute.iconset)
App.Utils.loadIconFontInfo @attribute.iconset, (icons) =>
@library = icons
@renderIcons()
element: =>
@el
render: ->
attributeValue = @attribute.value
@html App.view('generic/icon_picker')
attribute: @attribute
value: @attribute.value
renderIcons: (filter) =>
fragment = document.createDocumentFragment()
regex = new RegExp(filter, 'i') if filter
count = 0
_.each @library, (icon) =>
if !filter || filter && (regex.test(icon.name) || icon.filter && _.some(icon.filter, (w) -> regex.test(w)))
count++
fragment.appendChild $("<li class=\"icon js-pick\" data-font=\"#{@attribute.iconset}\" data-unicode=\"#{icon.unicode}\">#{String.fromCharCode('0x'+ icon.unicode)}</li>").get(0)
if count
@iconGrid.html fragment
@empty = false
@refreshElements()
else
if not @empty
# show a random placeholder
next = Math.floor(Math.random() * @noMatch.length)
if next == @noMatch.filter('.is-active').index()
next = (next + 1) % @noMatch.length
@noMatch.removeClass('is-active').eq(next).addClass('is-active')
@empty = true
@iconGrid.empty()
filterIcons: (event) =>
@throttledRenderIcons event.currentTarget.value
onIconClick: (event) ->
@pick event.currentTarget.getAttribute('data-unicode')
pick: (unicode) ->
@shadow.val unicode
@input.text String.fromCharCode("0x#{unicode}")
@el.closest('form').trigger('input')
# propergate focus to our visible input
onShadowFocus: ->
@input.focus()
onPickerOpen: ->
@filter.focus()
@isOpen = true
onPickerClose: ->
@isOpen = false
@filter.val ''
@renderIcons()
$(document).off 'keydown.icon_picker'
onFocus: ->
$(document).on 'keydown.icon_picker', @navigate
navigate: (event) =>
switch event.keyCode
when 40 then @nudge event, 0, 1 # down
when 38 then @nudge event, 0, -1 # up
when 39 then @nudge event, 1 # right
when 37 then @nudge event, -1 # left
when 13 then @onEnter event
when 27 then @onEscape()
onEscape: ->
@currentItem = null
@toggle() if @isOpen
onEnter: (event) ->
if !@isOpen
return @toggle()
if @currentItem
@pick @currentItem.attr('data-unicode')
@toggle()
toggle: ->
@$('[data-toggle="dropdown"]').dropdown('toggle')
nudge: (event, x, y) ->
event.preventDefault()
if !@currentItem
selectedIndex = 0
else
selectedIndex = @currentItem.index()
distance = switch
when x > 0 then 1
when x < 0 then -1
when y > 0 then @columns
when y < 0 then -@columns
if selectedIndex + distance >= @icons.length or selectedIndex + distance < 0
# out of boundary
return
selectedIndex += distance
@unhighlightCurrentItem()
@currentItem = @icons.eq(selectedIndex)
@currentItem.addClass('is-active').get(0).scrollIntoView(behavior: 'instant')
highlightItem: (event) =>
@unhighlightCurrentItem()
@currentItem = $(event.currentTarget)
@currentItem.addClass('is-active')
unhighlightCurrentItem: ->
return if !@currentItem
@currentItem.removeClass('is-active')
@currentItem = null

View file

@ -0,0 +1,78 @@
# coffeelint: disable=camel_case_classes
class App.IconsetPicker extends Spine.Controller
sets:
FontAwesome:
name: 'Font Awesome'
version: '4.7'
website: 'https://fontawesome.com/v4.7.0/'
anticon:
name: 'Anticon'
version: '2.10'
website: 'https://2x.ant.design/components/icon/'
material:
name: 'Material'
version: '2.2.0'
website: 'https://material.io/icons/'
ionicons:
name: 'Ionicons'
version: '2.0.1'
website: 'https://ionicons.com/v2/'
'Simple-Line-Icons':
name: 'Simple Line Icons'
version: '0.0.1'
website: 'https://simplelineicons.github.io/'
elements:
'.js-set': 'setElements'
'input': 'input'
events:
'click .js-set': 'pick'
# 'mouseenter .icon': 'flip'
constructor: ->
super
@render()
element: =>
@el
render: ->
@html App.view('generic/iconset_picker')
attribute: @attribute
sets: @sets
for family, set of @sets
App.Utils.loadIconFont(family)
App.Utils.loadIconFontInfo family, @initializePreview.bind(@, family)
initializePreview: (family, icons) ->
@sets[family].icons = icons
@renderPreview(family, icons)
renderPreview: (family) ->
fragment = document.createDocumentFragment()
icons = _.shuffle(@sets[family].icons)
for i in [0..(11*5-1)]
fragment.appendChild $("<i class=\"icon\" data-font=\"#{family}\">#{String.fromCharCode('0x'+ icons[i].unicode)}</i>").get(0)
@el.find("[data-family=\"#{family}\"] .js-preview").html fragment
pick: (event) ->
family = $(event.currentTarget).attr('data-family')
@input.val family
@setElements.removeClass('is-active')
event.currentTarget.classList.add('is-active')
flip: (event) ->
$icon = $(event.currentTarget)
family = $icon.closest('.js-set').attr('data-family')
if $icon.hasClass('do-flash')
$icon.removeClass('do-flash')
# force redraw
$icon.get(0).offsetWidth
$icon.text String.fromCharCode('0x'+ _.sample(@sets[family].icons).unicode)
$icon.addClass('do-flash')

View file

@ -0,0 +1,95 @@
class App.MultiLocales extends App.Controller
events:
'click .js-remove': 'remove'
'click .js-primary': 'primary'
'change .js-shadow': 'changeOnRow'
constructor: ->
super
@multiple_rows_supported = App.Config.get('kb_multi_lingual_support')
@rows = []
@render()
if @object
@listenTo @object, 'refresh', @parentObjectUpdated
parentObjectUpdated: =>
App.Delay.set =>
@attribute.value = @object.attributes()[@attribute.name]
@render()
render: ->
@html App.view('generic/multi_locales')(attribute: @attribute, vc: @)
if Array.isArray(@attribute.value)
for locale in @attribute.value
@appendRow @renderRow(locale, @attribute.value.length == 1)
if @multiple_rows_supported || !Array.isArray(@attribute.value) || @attribute.value.length == 0
@appendRow @renderRow()
renderRow: (kb_locale_attributes, solo = false) ->
kb_locale = App.KnowledgeBaseLocale.find kb_locale_attributes?.id
new App.MultiLocalesRow(
attribute: @attribute
kb_locale: kb_locale
available_locales: @selectableLocales(kb_locale?.systemLocale()?.id)
solo: solo
)
selectableLocales: (self_value) ->
takenCodes = @$('.js-shadow')
.toArray()
.map (elem) -> $(elem).val()
.filter (elem) -> elem && elem != self_value
App.Locale.all().filter (elem) ->
!takenCodes.includes(String(elem.id))
remove: (e) ->
domRow = $(e.currentTarget).closest('tr')[0]
row = _.find @rows, (elem) -> elem.el[0] == domRow
if row?.primaryCheckbox.prop('checked')
return
else if row?.kb_locale?.id
row.toggleDelete()
else
row.el.remove()
@rows.splice @rows.indexOf(row), 1
@changeOnRow()
primary: (e) ->
input = $(e.currentTarget).find('input')
if input.attr('disabled')
return
input.prop('checked', true)
@changeOnRow()
changeOnRow: (e) ->
if !@hasEmptyRow() && @multiple_rows_supported
@appendRow @renderRow()
nonempty_rows = @rows.filter (row) -> row.selector.shadowInput.val()
if nonempty_rows.length == 1
nonempty_rows[0].updateButtons(true, true)
else
for row in nonempty_rows
row.updateButtons(false)
for row in @rows
row.updateOptions( @selectableLocales(row.selector.shadowInput.val()) )
hasEmptyRow: ->
@$('.js-shadow').is (i, elem) -> !$(elem).val()
appendRow: (row) ->
@rows.push row
@$('tbody').append row.el

View file

@ -0,0 +1,77 @@
class App.MultiLocalesRow extends App.Controller
tag: 'tr'
elements:
'.js-primary input': 'primaryCheckbox'
'.js-remove input': 'removeButton'
'.js-selectorContainer': 'selectorContainer'
events:
'change .js-shadow': 'change'
constructor: ->
super
@el.data('kbLocaleId', @kb_locale?.id)
@render()
render: ->
@html App.view('generic/multi_locales_row')(
attribute: @attribute
kb_locale: @kb_locale
)
value = @kb_locale?.systemLocale()?.id
@_updateButtons(value, @solo , @kb_locale?.primary)
@selector = @localesSelectBuild(@attribute.name, value, @selectorContainer)
@updateOptions(@available_locales)
localesSelectBuild: (name, value, el) ->
new App.SearchableSelect(
el: el
attribute:
name: name
value: value
null: false
placeholder: 'Select locale:'
options: [] #formattedLocales
class: 'form-control--small'
)
updateOptions: (options) ->
value = @selector.shadowInput.val() # @selector.attribute.value
formattedLocales = options
.map (elem) ->
{
name: elem.name
value: elem.id
selected: (elem.id + '') == value
}
formattedLocales.sort (a, b) -> a.name.localeCompare(b.name)
@selector.attribute.options = formattedLocales
@selector.render()
updateButtons: (is_solo, is_primary = undefined) ->
if is_primary == undefined
is_primary = @primaryCheckbox[0].checked
@_updateButtons(@selector.shadowInput.val(), is_solo, is_primary)
_updateButtons: (value, is_solo, is_primary) ->
is_deleted = @el.hasClass('settings-list--deleted')
@removeButton.attr('disabled', is_solo || !value || is_primary)
@primaryCheckbox.attr('disabled', is_solo || !value || is_deleted)
@primaryCheckbox.prop('checked' , is_primary)
change: ->
@primaryCheckbox.attr 'value', @selector.shadowInput.val()
toggleDelete: ->
@el.toggleClass('settings-list--deleted')
@removeButton.prop('checked', @el.hasClass('settings-list--deleted'))
@selector.el.toggleClass('u-unclickable')

View file

@ -0,0 +1,5 @@
class App.KbPopoverProvider extends App.SingleObjectPopoverProvider
@templateName = 'kb_generic'
@includeData = false
displayTitleUsing: (object) ->
object.title

View file

@ -0,0 +1,5 @@
class KnowledgeBaseAnswer extends App.KbPopoverProvider
@klass = App.KnowledgeBaseAnswerTranslation
@selectorCssClassPrefix = 'kb-answer'
App.PopoverProvider.registerProvider('KnowledgeBaseAnswer', KnowledgeBaseAnswer)

View file

@ -0,0 +1,5 @@
class KnowledgeBaseCategory extends App.KbPopoverProvider
@klass = App.KnowledgeBaseCategoryTranslation
@selectorCssClassPrefix = 'kb-category'
App.PopoverProvider.registerProvider('KnowledgeBaseCategory', KnowledgeBaseCategory)

View file

@ -0,0 +1,5 @@
class KnowledgeBase extends App.KbPopoverProvider
@klass = App.KnowledgeBaseTranslation
@selectorCssClassPrefix = 'kb'
App.PopoverProvider.registerProvider('KnowledgeBase', KnowledgeBase)

View file

@ -115,7 +115,6 @@ class App.SearchableSelect extends Spine.Controller
onDropdownShown: => onDropdownShown: =>
@input.on 'click', @stopPropagation @input.on 'click', @stopPropagation
@highlightFirst() @highlightFirst()
$(document).on 'keydown.searchable_select', @navigate
if @level > 0 if @level > 0
@showSubmenu(@currentMenu) @showSubmenu(@currentMenu)
@isOpen = true @isOpen = true
@ -123,7 +122,6 @@ class App.SearchableSelect extends Spine.Controller
onDropdownHidden: => onDropdownHidden: =>
@input.off 'click', @stopPropagation @input.off 'click', @stopPropagation
@unhighlightCurrentItem() @unhighlightCurrentItem()
$(document).off 'keydown.searchable_select'
@isOpen = false @isOpen = false
if !@input.val() if !@input.val()
@ -359,8 +357,10 @@ class App.SearchableSelect extends Spine.Controller
onBlur: -> onBlur: ->
@clearAutocomplete() @clearAutocomplete()
@input.off 'keydown.searchable_select'
onFocus: -> onFocus: ->
@input.on 'keydown.searchable_select', @navigate
textEnd = @input.val().length textEnd = @input.val().length
@input.prop('selectionStart', textEnd) @input.prop('selectionStart', textEnd)
@input.prop('selectionEnd', textEnd) @input.prop('selectionEnd', textEnd)
@ -372,8 +372,9 @@ class App.SearchableSelect extends Spine.Controller
onShadowChange: -> onShadowChange: ->
value = @shadowInput.val() value = @shadowInput.val()
for option in @attribute.options if Array.isArray(@attribute.options)
option.selected = (option.value + '') == value # makes sure option value is always a string for option in @attribute.options
option.selected = (option.value + '') == value # makes sure option value is always a string
onInput: (event) => onInput: (event) =>
@toggle() if not @isOpen @toggle() if not @isOpen

View file

@ -1213,6 +1213,37 @@ class App.Utils
ctx.drawImage(img, 0, 0) ctx.drawImage(img, 0, 0)
canvas.toDataURL('image/png') canvas.toDataURL('image/png')
# works asynchronously to make sure images are loaded before converting to base64
# output is passed to callback
@htmlImage2DataUrlAsync: (html, callback) ->
output = @_checkTypeOf("<div>#{html}</div>")
# coffeelint: disable=indentation
elems = output
.find('img')
.toArray()
.filter (elem) -> !elem.src.match(/^(data|cid):/i)
# coffeelint: enable=indentation
cacheOrDone = ->
if (nextElem = elems.pop())
App.Utils._htmlImage2DataUrlAsync(nextElem, (data) ->
$(nextElem).attr('src', data)
cacheOrDone()
)
else
callback(output[0].innerHTML)
cacheOrDone()
@_htmlImage2DataUrlAsync: (originalImage, callback) ->
imageCache = new Image()
imageCache.onload = ->
data = App.Utils._htmlImage2DataUrl(originalImage)
callback(data)
imageCache.src = originalImage.src
@baseUrl: -> @baseUrl: ->
fqdn = App.Config.get('fqdn') fqdn = App.Config.get('fqdn')
http_type = App.Config.get('http_type') http_type = App.Config.get('http_type')

View file

@ -1,4 +1,9 @@
class App.SearchableAjaxSelect extends App.SearchableSelect class App.SearchableAjaxSelect extends App.SearchableSelect
constructor: ->
super
# create cache
@searchResultCache = {}
onInput: (event) => onInput: (event) =>
super super
@ -7,67 +12,62 @@ class App.SearchableAjaxSelect extends App.SearchableSelect
# e.g. Ticket to ticket or AnotherObject to another_object # e.g. Ticket to ticket or AnotherObject to another_object
objectString = underscored(@options.attribute.object) objectString = underscored(@options.attribute.object)
# create common accessors query = @input.val()
@apiPath = App.Config.get('api_path')
# create cache and cache key # create cache key
@searchResultCache = @searchResultCache || {} cacheKey = "#{objectString}+#{query}"
cacheKey = "#{objectString}+#{@query}"
# use cache for search result # use cache for search result
if @searchResultCache[cacheKey] if @searchResultCache[cacheKey]
return @renderResponse( @searchResultCache[cacheKey] ) App.Ajax.abort @options.attribute.id
@renderResponse @searchResultCache[cacheKey], query
return
# add timeout for loader icon # add timeout for loader icon
clearTimeout @loaderTimeoutId if !@loaderTimeoutId
@loaderTimeoutId = setTimeout @showLoader, 1000 @loaderTimeoutId = setTimeout @showLoader, 1000
# start search request and update options attributes =
App.Ajax.request(
id: @options.attribute.id id: @options.attribute.id
type: 'GET' type: 'GET'
url: "#{@apiPath}/search/#{objectString}" url: "#{App.Config.get('api_path')}/search/#{objectString}"
data: data:
query: @query query: query
limit: @options.attribute.limit limit: @options.attribute.limit
processData: true processData: true
success: (data, status, xhr) => success: (data, status, xhr) =>
# cache search result # cache search result
@searchResultCache[cacheKey] = data @searchResultCache[cacheKey] = data
@renderResponse(data) @renderResponse(data, query)
)
renderResponse: (data) => # if delegate is given and provides getAjaxAttributes method, try to extend ajax call
# this is needed for autocompletion field in KB answer-to-answer linking to submit search context
if @delegate?.getAjaxAttributes
attributes = @delegate?.getAjaxAttributes?(@, attributes)
# start search request and update options
App.Ajax.request(attributes)
renderResponse: (data, originalQuery) =>
# clear timout and remove loader icon # clear timout and remove loader icon
clearTimeout @loaderTimeoutId clearTimeout @loaderTimeoutId
@loaderTimeoutId = undefined
@el.removeClass('is-loading') @el.removeClass('is-loading')
# load assets # load assets
App.Collection.loadAssets(data.assets) App.Collection.loadAssets(data.assets)
# get options from search result # get options from search result
options = [] options = data
for object in data.result .result
if object.type is 'Ticket' .map (elem) =>
ticket = App.Ticket.find(object.id) # use search results directly to avoid loading KB assets in Ticket view
data = if @useAjaxDetails
name: "##{ticket.number} - #{ticket.title}" @renderResponseItemAjax(elem, data)
value: ticket.id else
options.push data @renderResponseItem(elem)
else if object.type is 'User' .filter (elem) -> elem?
user = App.User.find( object.id )
data =
name: "#{user.displayName()}"
value: user.id
options.push data
else if object.type is 'Organization'
organization = App.Organization.find(object.id)
data =
name: "#{organization.displayName()}"
value: organization.id
options.push data
# fill template with gathered options # fill template with gathered options
@optionsList.html @renderOptions options @optionsList.html @renderOptions options
@ -76,7 +76,32 @@ class App.SearchableAjaxSelect extends App.SearchableSelect
@refreshElements() @refreshElements()
# execute filter # execute filter
@filterByQuery @query @filterByQuery originalQuery
renderResponseItemAjax: (elem, data) ->
result = _.find(data.details, (detailElem) -> detailElem.type == elem.type and detailElem.id == elem.id)
if result
{
name: result.title
value: elem.id
}
renderResponseItem: (elem) ->
object = App[elem.type.replace(/::/g, '')]?.find(elem.id)
if !object
return
name = if object instanceof App.Ticket
"##{object.number} - #{object.title}"
else
object.displayName()
{
name: name
value: object.id
}
showLoader: => showLoader: =>
@el.addClass('is-loading') @el.addClass('is-loading')

View file

@ -97,11 +97,15 @@
} }
}; };
showDropZone = function(dropContainer) { showDropZone = function(dropContainer) {
$(dropContainer).trigger('html5Upload.dropZone.show')
if ( !$(dropContainer).find('.article-content, .richtext').hasClass('is-dropTarget') ) { if ( !$(dropContainer).find('.article-content, .richtext').hasClass('is-dropTarget') ) {
$(dropContainer).find('.article-content, .richtext').addClass('is-dropTarget') $(dropContainer).find('.article-content, .richtext').addClass('is-dropTarget')
} }
} }
hideDropZone = function(dropContainer) { hideDropZone = function(dropContainer) {
$(dropContainer).trigger('html5Upload.dropZone.hide')
if ( $(dropContainer).find('.article-content, .richtext').hasClass('is-dropTarget') ) { if ( $(dropContainer).find('.article-content, .richtext').hasClass('is-dropTarget') ) {
$(dropContainer).find('.article-content, .richtext').removeClass('is-dropTarget') $(dropContainer).find('.article-content, .richtext').removeClass('is-dropTarget')
} }

View file

@ -68,18 +68,18 @@
if (e.keyCode === 13) { if (e.keyCode === 13) {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
var id = this.$widget.find('.dropdown-menu li.is-active').data('id') var elem = this.$widget.find('.dropdown-menu li.is-active')[0]
// as fallback use hovered element // as fallback use hovered element
if (!id) { if (!elem) {
id = this.$widget.find('.dropdown-menu li:hover').data('id') elem = this.$widget.find('.dropdown-menu li:hover')[0]
} }
// as fallback first element // as fallback first element
if (!id) { if (!elem) {
id = this.$widget.find('.dropdown-menu li:first-child').data('id') elem = this.$widget.find('.dropdown-menu li:first-child')[0]
} }
this.take(id) this.take(elem)
return return
} }
@ -132,8 +132,9 @@
// backspace // backspace
if (e.keyCode === 8 && this.buffer) { if (e.keyCode === 8 && this.buffer) {
var trigger = this.findTrigger(this.buffer)
// backspace + buffer === :: -> close textmodule // backspace + buffer === :: -> close textmodule
if (this.buffer === '::') { if (trigger && trigger.trigger === this.buffer) {
this.close(true) this.close(true)
e.preventDefault() e.preventDefault()
return return
@ -143,7 +144,7 @@
var length = this.buffer.length var length = this.buffer.length
this.buffer = this.buffer.substr(0, length-1) this.buffer = this.buffer.substr(0, length-1)
this.log('BS backspace', this.buffer) this.log('BS backspace', this.buffer)
this.result(this.buffer.substr(2, length-1)) this.result(trigger)
} }
} }
@ -159,41 +160,53 @@
// arrow keys // arrow keys
if (e.keyCode === 37 || e.keyCode === 38 || e.keyCode === 39 || e.keyCode === 40) return if (e.keyCode === 37 || e.keyCode === 38 || e.keyCode === 39 || e.keyCode === 40) return
// observer other second key var newChar = String.fromCharCode(e.which)
if (this.buffer === ':' && String.fromCharCode(e.which) !== ':') {
this.buffer = ''
}
// oberserve second : // observe other keys
if (this.buffer === ':' && String.fromCharCode(e.which) === ':') { if (this.hasAvailableTriggers(this.buffer)) {
this.buffer = this.buffer + ':' if(this.hasAvailableTriggers(this.buffer + newChar)) {
this.buffer = this.buffer + newChar
} else if (!this.findTrigger(this.buffer)) {
this.buffer = ''
}
} }
// oberserve first : // oberserve first :
if (!this.buffer && String.fromCharCode(e.which) === ':') { if (!this.buffer && this.hasAvailableTriggers(newChar)) {
this.buffer = this.buffer + ':' this.buffer = this.buffer + newChar
} }
if (this.buffer && this.buffer.substr(0,2) === '::') { var trigger = this.findTrigger(this.buffer)
if (trigger) {
var sign = String.fromCharCode(e.which)
if ( sign && sign !== ':' && e.which != 8 ) { // 8 == backspace
this.buffer = this.buffer + sign
//this.log('BUFF ADD', sign, this.buffer, sign.length, e.which)
}
this.log('BUFF HINT', this.buffer, this.buffer.length, e.which, String.fromCharCode(e.which)) this.log('BUFF HINT', this.buffer, this.buffer.length, e.which, String.fromCharCode(e.which))
if (!this.isActive()) { if (!this.isActive()) {
this.open() this.open()
} }
this.result(this.buffer.substr(2, this.buffer.length)) this.result(trigger)
} }
} }
// check if at least one trigger is available with the given prefix
Plugin.prototype.hasAvailableTriggers = function(prefix) {
var result = _.find(this.helpers, function(helper) {
var trigger = helper.trigger
return trigger.substr(0, prefix.length) == prefix.substr(0, trigger.length)
})
return result != undefined
}
// find a matching trigger
Plugin.prototype.findTrigger = function(string) {
return _.find(this.helpers, function(helper) {
return helper.trigger == string.substr(0, helper.trigger.length)
})
}
// create base template // create base template
Plugin.prototype.renderBase = function() { Plugin.prototype.renderBase = function() {
this.untouched = true
this.$element.after('<div class="shortcut dropdown"><ul class="dropdown-menu" style="max-height: 200px;"></ul></div>') this.$element.after('<div class="shortcut dropdown"><ul class="dropdown-menu" style="max-height: 200px;"></ul></div>')
this.$widget = this.$element.next() this.$widget = this.$element.next()
this.$widget.on('mousedown', 'li', $.proxy(this.onEntryClick, this)) this.$widget.on('mousedown', 'li', $.proxy(this.onEntryClick, this))
@ -344,27 +357,27 @@
Plugin.prototype.onEntryClick = function(event) { Plugin.prototype.onEntryClick = function(event) {
event.preventDefault() event.preventDefault()
var id = $(event.currentTarget).data('id') this.take(event.currentTarget)
this.take(id)
} }
// select text module and insert into text // select text module and insert into text
Plugin.prototype.take = function(id) { Plugin.prototype.take = function(elem) {
if (!id) { if (!elem) {
this.close(true) this.close(true)
return return
} }
for (var i = 0; i < this.collection.length; i++) {
var item = this.collection[i] var trigger = this.findTrigger(this.buffer)
if (item.id == id) {
var content = item.content if (trigger) {
this.cutInput() var _this = this;
this.paste(content)
this.close(true) trigger.renderValue(this, elem, function(text) {
return _this.cutInput()
} _this.paste(text)
_this.close(true)
})
} }
return
} }
Plugin.prototype.getFirstRange = function() { Plugin.prototype.getFirstRange = function() {
@ -381,47 +394,33 @@
} }
// render result // render result
Plugin.prototype.result = function(term) { Plugin.prototype.result = function(trigger) {
var _this = this if (!trigger) return
var result = _.filter(this.collection, function(item) {
var reg = new RegExp(term, 'i')
if (item.name && item.name.match(reg)) {
return item
}
if (item.keywords && item.keywords.match(reg)) {
return item
}
return
})
result.reverse() var term = this.buffer.substr(trigger.trigger.length, this.buffer.length)
trigger.renderResults(this, term)
}
this.$widget.find('ul').html('') Plugin.prototype.emptyResultsContainer = function() {
this.log('result', term, result) this.$widget.find('ul').empty()
}
var elements = $() Plugin.prototype.appendResults = function(collection) {
this.$widget.find('ul').append(collection).scrollTop(9999)
for (var i = 0; i < result.length; i++) { this.afterResultRendering()
var item = result[i] }
var element = $('<li>')
element.attr('data-id', item.id)
element.text(item.name)
element.addClass('u-clickable u-textTruncate')
if (i == result.length-1) {
element.addClass('is-active')
}
if (item.keywords) {
element.append($('<kbd>').text(item.keywords))
}
elements = elements.add(element)
}
this.$widget.find('ul').append(elements).scrollTop(9999)
Plugin.prototype.afterResultRendering = function() {
// keep the width of the dropdown the same even when longer items got filtered out // keep the width of the dropdown the same even when longer items got filtered out
if(this._fixedWidth && this.untouched){ if(this._fixedWidth){
this.$widget.find('ul').css('width', this.$widget.find('ul').width()); var elem = this.$widget.find('ul')
this.untouched = false;
var currentMinWidth = parseInt(elem.css('min-width'))
var realWidth = elem.width()
if(!currentMinWidth || realWidth > currentMinWidth) {
elem.css('min-width', realWidth + 'px')
}
} }
this.movePosition() this.movePosition()
@ -445,4 +444,144 @@
}); });
} }
function Collection() {}
Collection.renderValue = function(textmodule, elem, callback) {
var id = $(elem).data('id')
var item = _.find(textmodule.collection, function(elem) { return elem.id == id })
if (!item) return
callback(item.content)
}
Collection.renderResults = function(textmodule, term) {
var reg = new RegExp(term, 'i')
var result = textmodule.collection.filter(function(item) {
return (item.name && item.name.match(reg)) || (item.keywords && item.keywords.match(reg))
})
result.reverse()
textmodule.emptyResultsContainer()
var elements = result.map(function(elem, index, array){
var element = $('<li>')
.attr('data-id', elem.id)
.text(elem.name)
.addClass('u-clickable u-textTruncate')
if (index == array.length-1) {
element.addClass('is-active')
}
if (elem.keywords) {
element.append($('<kbd>').text(elem.keywords))
}
return element
})
textmodule.appendResults(elements)
}
Collection.trigger = '::'
function KbAnswer() {}
KbAnswer.renderValue = function(textmodule, elem, callback) {
textmodule.emptyResultsContainer()
var element = $('<li>').text(App.i18n.translateInline('Please wait...'))
textmodule.appendResults(element)
App.Ajax.request({
id: 'textmoduleKbAnswer',
type: 'GET',
url: $(elem).data('url'),
success: function(data, status, xhr) {
App.Collection.loadAssets(data.assets)
var translation = App.KnowledgeBaseAnswerTranslation.find($(elem).data('id'))
var body = translation.content().bodyWithPublicURLs()
App.Utils.htmlImage2DataUrlAsync(body, function(output){
callback(output)
})
},
error: function(xhr) {
callback('')
}
})
}
KbAnswer.renderResults = function(textmodule, term) {
textmodule.emptyResultsContainer()
if(!term) {
var element = $('<li>').text(App.i18n.translateInline('Start typing to search in Knowledge Base...'))
textmodule.appendResults(element)
return
}
var element = $('<li>').text(App.i18n.translateInline('Loading...'))
textmodule.appendResults(element)
App.Delay.set(function() {
App.Ajax.request({
id: 'textmoduleKbAnswer',
type: 'POST',
url: App.Config.get('api_path') + '/knowledge_bases/search',
data: JSON.stringify({
'query': term,
'flavor': 'agent',
'index': 'KnowledgeBase::Answer::Translation',
'url_type': 'agent',
'highlight_enabled': false
}),
processData: true,
success: function(data, status, xhr) {
textmodule.emptyResultsContainer()
var items = data
.result
.map(function(elem){
if(result = _.find(data.details, function(detailElem) { return detailElem.type == elem.type && detailElem.id == elem.id })) {
return {
'name': result.title,
'value': elem.id,
'url': result.url
}
}
})
.filter(function(elem){ return elem != undefined })
.map(function(elem, index, array) {
var element = $('<li>')
.attr('data-id', elem.value)
.attr('data-url', elem.url)
.text(elem.name)
.addClass('u-clickable u-textTruncate')
if (index == array.length-1) {
element.addClass('is-active')
}
return element
})
if(items.length == 0) {
items.push($('<li>').text(App.i18n.translateInline('No results found')))
}
textmodule.appendResults(items)
}
})
}, 200, 'textmoduleKbAnswerDelay', 'textmodule')
}
KbAnswer.trigger = '??'
Plugin.prototype.helpers = [Collection, KbAnswer]
}(jQuery, window)); }(jQuery, window));

View file

@ -83,8 +83,8 @@
.show() .show()
.scrollTop(0) .scrollTop(0)
if (that.options.backdrop) that.adjustBackdrop()
that.adjustDialog() that.adjustDialog()
if (that.options.backdrop) that.adjustBackdrop()
if (transition) { if (transition) {
that.$element[0].offsetWidth // force reflow that.$element[0].offsetWidth // force reflow
@ -241,8 +241,8 @@
// these following methods are used to handle overflowing modals // these following methods are used to handle overflowing modals
Modal.prototype.handleUpdate = function () { Modal.prototype.handleUpdate = function () {
if (this.options.backdrop) this.adjustBackdrop()
this.adjustDialog() this.adjustDialog()
if (this.options.backdrop) this.adjustBackdrop()
} }
Modal.prototype.adjustBackdrop = function () { Modal.prototype.adjustBackdrop = function () {

View file

@ -33,12 +33,14 @@
animation: true, animation: true,
placement: 'top', placement: 'top',
selector: false, selector: false,
backdrop: false,
template: '<div class="tooltip" role="tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>', template: '<div class="tooltip" role="tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>',
trigger: 'hover focus', trigger: 'hover focus',
title: '', title: '',
delay: 0, delay: 0,
html: false, html: false,
container: false, container: false,
theme: 'light',
viewport: { viewport: {
selector: 'body', selector: 'body',
padding: 0 padding: 0
@ -161,9 +163,12 @@
var tipId = this.getUID(this.type) var tipId = this.getUID(this.type)
this.setContent() this.setContent()
$tip.attr('id', tipId) $tip.attr('id', tipId).attr('data-theme', this.options.theme)
this.$element.attr('aria-describedby', tipId) this.$element.attr('aria-describedby', tipId)
if(this.options.backdrop)
this.$tip.on('click.bs.tooltip.stopPropagation', function(event){ event.stopPropagation() })
if (this.options.animation) $tip.addClass('fade') if (this.options.animation) $tip.addClass('fade')
var placement = typeof this.options.placement == 'function' ? var placement = typeof this.options.placement == 'function' ?
@ -210,6 +215,8 @@
var prevHoverState = that.hoverState var prevHoverState = that.hoverState
that.$element.trigger('shown.bs.' + that.type) that.$element.trigger('shown.bs.' + that.type)
that.hoverState = null that.hoverState = null
if(that.options.backdrop)
$(document).one('click.bs.tooltip', function(){ that.hide() })
if (prevHoverState == 'out') that.leave(that) if (prevHoverState == 'out') that.leave(that)
} }
@ -436,6 +443,9 @@
clearTimeout(this.timeout) clearTimeout(this.timeout)
this.hide(function () { this.hide(function () {
that.$element.off('.' + that.type).removeData('bs.' + that.type) that.$element.off('.' + that.type).removeData('bs.' + that.type)
if(that.options.backdrop)
that.$tip.off('click.bs.tooltip.stopPropagation')
}) })
} }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,27 @@
InstanceMethods =
contentSidebarActions: (kb_locale) ->
buttons = []
if @constructor.canBePublished?()
buttons.push {
iconName: 'eye'
name: 'Visibility'
action: 'visibility'
cssClass: 'btn--success'
disabled: @isNew()
}
if !(@ instanceof App.KnowledgeBase)
buttons.push {
iconName: 'trash'
name: 'Delete'
action: 'delete'
cssClass: 'btn--danger'
disabled: @isNew()
}
buttons
App.KnowledgeBaseActions =
extended: ->
@include InstanceMethods

View file

@ -0,0 +1,101 @@
InstanceMethods =
canBePublished: -> true
can_be_published_state: ->
matching_time = (new Date()).getTime()
switch
when @date(@archived_at) < matching_time
'archived'
when @date(@published_at) < matching_time
'published'
when @date(@internal_at) < matching_time
'internal'
else
'draft'
can_be_published_by: (state = @can_be_published_state()) ->
if state == 'draft'
return
user_id = @["#{state}_by_id"]
App.User.find user_id
can_be_published_at: (state = @can_be_published_state()) ->
if state == 'draft'
return @created_at
@["#{state}_at"]
can_be_published_state_css: ->
switch @can_be_published_state()
when 'archived'
'label-danger'
when 'published'
'label-success'
when 'draft'
'label-subtle'
can_be_published_quick_actions: ->
switch @can_be_published_state()
when 'published'
['archive']
when 'internal'
['publish', 'archive']
when 'draft'
['publish', 'internal']
else
[]
next_call_to_action: ->
switch @can_be_published_state()
when 'archived'
['unarchive']
when 'published'
['archive']
when 'internal'
['publish', 'archive']
else
['publish', 'internal']
can_be_published_publish_in_future: ->
@date(@published_at) > (new Date()).getTime()
can_be_published_archive_in_future: ->
@date(@archived_at) > (new Date()).getTime()
can_be_published_internal_in_future: ->
@date(@internal_at) > (new Date()).getTime()
is_internally_published: (kb_locale) ->
state = @can_be_published_state()
object_published = state == 'internal' || state == 'published'
if !object_published
return false
if !@translation(kb_locale.id)
return false
true
is_published: (kb_locale) ->
if @can_be_published_state() isnt 'published'
return false
if !@translation(kb_locale.id)
return false
true
date: (string) ->
return undefined if !string
new Date(string).getTime()
App.KnowledgeBaseCanBePublished =
canBePublished: -> true
extended: ->
@include InstanceMethods

View file

@ -0,0 +1,110 @@
InstanceMethods =
translations: ->
klass = @constructor.translatableClass()
key = @constructor.translatableForeignKey()
klass.all().filter (elem) => elem[key] == @id
translation: (kb_locale_id) ->
return null if kb_locale_id is null or kb_locale_id is undefined
@translations().filter((elem) -> elem.kb_locale_id == kb_locale_id)[0]
primaryTranslation: ->
primaryKbLocale = @knowledge_base().primaryKbLocale()
@translation(primaryKbLocale.id)
attributesIncludingTranslation: (kb_locale_id) ->
output = @attributes()
output.translation = @translation(kb_locale_id)?.attributes()
output
attributesForRendering: (kb_locale, options = {}) ->
attrs = {
id: @id
url: @uiUrl(kb_locale, options.action)
title: @guaranteedTitle(kb_locale.id)
missingTranslation: @translation(kb_locale.id) is undefined
}
if @ instanceof App.KnowledgeBase
attrs.icon = 'knowledge-base'
attrs.title = ''
if @ instanceof App.KnowledgeBaseCategory
attrs.iconFont = true
attrs.icon = @category_icon
attrs.count = @countDeepAnswers()
if options.isEditor
attrs.editorOnly = !@visibleInternally(kb_locale)
else
attrs.editorOnly = false
if @ instanceof App.KnowledgeBaseAnswer
attrs.icon = 'knowledge-base-answer'
if options.isEditor
attrs.editorOnly = !@is_internally_published(kb_locale)
else
attrs.editorOnly = false
# attrs.className = if attrs.missingTranslation
# 'kb-item--missing-translation'
# else if attrs.editorOnly
# 'kb-item--invisible'
attrs.className = if attrs.editorOnly
'kb-item--invisible'
attrs.icons = {}
if attrs.missingTranslation
attrs.icons['danger'] = true
attrs
writeMethod: ->
if @id then 'PATCH' else 'POST'
prepareNestedParams: (params, kb_locale_id) ->
if @baseParams
params = _.extendOwn(@baseParams(), params)
translation_params = params['translation']
delete params['translation']
if translation = @translation(kb_locale_id)
translation_params['id'] = translation.id
else
translation_params['kb_locale_id'] = kb_locale_id
if @constructor.translatableClass().processAttributes
translation_params = @constructor.translatableClass().processAttributes(translation_params)
params['translations_attributes'] = [translation_params]
params
objectActionName: ->
action = if @isNew() then 'New' else 'Edit'
"#{action} #{@objectName()}"
removeTranslations: (options = {}) ->
for translation in @translations()
translation.remove(options)
removeIncludingTranslations: (options = {}) ->
@removeTranslations(options)
@remove(options)
guaranteedTranslation: (kb_locale_id) ->
@translation(kb_locale_id) || @primaryTranslation() || @translations()[0]
guaranteedTitle: (kb_locale, placeholder = '-') ->
@guaranteedTranslation(kb_locale)?.title || placeholder
translationBindlableObject: (kb_locale_id) ->
@translation(kb_locale_id) || @constructor.translatableClass()
App.KnowledgeBaseTranslatable =
extended: ->
@include InstanceMethods

View file

@ -0,0 +1,29 @@
InstanceMethods =
parent: ->
throw 'Please implement parent method, fetching parent object'
uiUrl: ->
kb_locale = App.KnowledgeBaseLocale.localeFor(@)
@parent().uiUrl(kb_locale)
fullyLoaded: ->
if @ instanceof App.KnowledgeBaseAnswerTranslation
return @content() isnt null
true
defaultSearchResultAttributes: ->
kb_locale = App.KnowledgeBaseLocale.localeFor(@)
{
display: @title
id: @id
url: @uiUrl()
}
searchResultAttributes: ->
@defaultSearchResultAttributes()
App.KnowledgeBaseTranslationable =
extended: ->
@include InstanceMethods

View file

@ -0,0 +1,230 @@
class App.KnowledgeBase extends App.Model
@configure 'KnowledgeBase', 'iconset', 'color_highlight', 'color_header', 'translation_ids', 'locale_ids', 'homepage_layout', 'category_layout', 'custom_address'
@extend Spine.Model.Ajax
@extend App.KnowledgeBaseActions
@url: @apiPath + '/knowledge_bases'
@manageUrl: @apiPath + '/knowledge_bases/manage'
manageUrl: (action = null) ->
App.Utils.joinUrlComponents(@constructor.manageUrl, @id, action)
publicBaseUrl: (kb_locale = undefined) ->
# coffeelint: disable=indentation
components = if @custom_address? && @custom_address[0] != '/'
["http://#{@custom_address}"]
else if @custom_address?
[App.Utils.baseUrl(), @custom_address.substr(1, @custom_address.length - 1)]
else
[App.Utils.baseUrl(), 'help']
# coffeelint: enable=indentation
if kb_locale
components.push kb_locale.systemLocale().locale
App.Utils.joinUrlComponents components
uiUrl: (kb_locale, suffix = undefined) ->
App.Utils.joinUrlComponents @uiUrlComponent(), kb_locale.urlSuffix(), suffix
uiUrlComponent: ->
"#knowledge_base/#{@id}"
categories: ->
App.KnowledgeBaseCategory.all().filter (item) => item.knowledge_base_id == @id
rootCategories: ->
@categories()
.filter (item) -> item.parent_id is null
.sort (a, b) -> a.position - b.position
kb_locales: ->
App.KnowledgeBaseLocale.findAll(@kb_locale_ids)
primaryKbLocale: ->
@kb_locales().filter((elem) -> elem.primary)[0]
knowledge_base: ->
@
isEmpty: ->
@rootCategories().length is 0
@translatableClass: -> App.KnowledgeBaseTranslation
@translatableForeignKey: -> 'knowledge_base_id'
@extend App.KnowledgeBaseTranslatable
remove: (options = {}) ->
@rootCategories().forEach (elem) -> elem.remove(options)
@removeTranslations(options)
super
objectName: ->
'Knowledge Base'
categoriesForDropdown: (options = {}) ->
initial = []
if options.includeRoot
initial.push { value: null, name: '>> Homepage <<'}
initialNestLevel = if options.includeRoot
1
else
0
@rootCategories().reduce (memo, elem) ->
memo.concat elem.categoriesForDropdown(nested: initialNestLevel, kb_locale: options.kb_locale)
, initial
visibleInternally: (kb_locale) ->
@active
visiblePublicly: (kb_locale) ->
@active
attributes: ->
attrs = super()
attrs.kb_locales = @kb_locales().map (elem) -> elem.attributes()
attrs
@configure_attributes: [
{
name: 'translation::title'
model: 'translation'
display: 'Title'
tag: 'input'
null: false
screen:
agent_edit:
shown: true
#}, {
#name: 'homepage_layout'
#model: 'knowledge_base'
#display: 'Layout'
#tag: 'radio'
#null: true
#screen:
#agent:
#shown: true
#options: [
#value: 'grid',
#name: 'Grid'
#graphic: 'knowledge_base_grid.svg'
#,
#value: 'list'
#name: 'List'
#graphic: 'knowledge_base_list.svg'
#]
##relation: 'KnowledgeBaseLayout'
}, {
name: 'translation::footer_note'
model: 'translation'
display: 'Footer Note'
tag: 'input'
null: false
screen:
agent_edit:
shown: true
}, {
name: 'color_highlight'
display: 'Highlight Color'
tag: 'color'
style: 'block'
null: false
help: 'The highlight color is used to make elements of the interface stand out. For example the links and icons.'
screen:
admin_style_color_highlight:
display: false
horizontal: true
shown: true
}, {
name: 'color_header'
display: 'Header Color'
tag: 'color'
style: 'block'
null: false
screen:
admin_style_color_header:
display: false
horizontal: true
shown: true
# Layout picker is disabled in V1
#}, {
# name: 'homepage_layout'
# display: 'Landing page layout'
# tag: 'radio_graphic'
# null: false
# style: 'block'
# screen:
# admin_style_homepage:
# display: false
# horizontal: true
# shown: true
# options: [
# {
# value: 'grid',
# name: 'Grid'
# graphic: 'knowledge_base_grid.svg'
# }, {
# value: 'list'
# name: 'List'
# graphic: 'knowledge_base_list.svg'
# }
# ]
#}, {
# name: 'category_layout'
# display: 'Category page layout'
# tag: 'radio_graphic'
# null: false
# style: 'block'
# screen:
# admin_style_category:
# display: false
# horizontal: true
# shown: true
# options: [
# {
# value: 'grid',
# name: 'Grid'
# graphic: 'knowledge_base_grid.svg'
# }, {
# value: 'list'
# name: 'List'
# graphic: 'knowledge_base_list.svg'
# }
# ]
}, {
name: 'iconset'
display: 'Icon Set'
tag: 'iconset_picker'
style: 'block'
help: "Pick an iconset that fits your style. The icons from this set can be assigned to categories. Choose wisely because the icon sets don't match with each other. If you change it later on you'll have to reset every icon."
null: false
screen:
admin_style_iconset:
shown: true
}, {
name: 'kb_locales'
display: 'Languages'
tag: 'multi_locales'
style: 'block'
null: false
help: 'Set up the languages for the Knowledge Base. Zammad detects the prefered language of the visitor. When its not available it will fall back to the primary language.'
screen:
admin_languages:
shown: true
admin_create:
shown: true
}, {
name: 'custom_address'
display: 'Custom Address'
tag: 'input'
style: 'block'
null: true
help: 'Fill in full domain (e.g. example.com or example.com/help) or path (e.g. /support) to use custom address. See Apache or Nginx for further instructions'
screen:
admin_custom_address:
shown: true
}
]

View file

@ -0,0 +1,94 @@
class App.KnowledgeBaseAnswer extends App.Model
@configure 'KnowledgeBaseAnswer', 'category_id', 'translation_ids', 'archived_at', 'internal_at', 'published_at', 'attachments'
@extend Spine.Model.Ajax
@extend App.KnowledgeBaseActions
@extend App.KnowledgeBaseCanBePublished
url: ->
@knowledge_base().generateURL('answers')
uiUrl: (kb_locale, action = null) ->
App.Utils.joinUrlComponents @knowledge_base().uiUrl(kb_locale), @uiUrlComponent(), action
uiUrlComponent: ->
"answer/#{@id}"
knowledge_base: ->
App.KnowledgeBase.find(@category().knowledge_base_id)
category: ->
App.KnowledgeBaseCategory.find(@category_id)
@configure_attributes = [
{
name: 'translation::title'
model: 'translation'
display: 'Title'
tag: 'input'
grid_width: '1/2'
},
]
configure_attributes: (kb_locale = undefined) ->
[
{
name: 'translation::title'
model: 'translation'
display: 'Title'
tag: 'input'
grid_width: '1/2'
null: false
screen:
agent_create:
shown: true
},
{
name: 'category_id'
model: 'answer'
display: 'Category'
tag: 'select'
null: false
options: @knowledge_base().categoriesForDropdown(kb_locale: kb_locale)
grid_width: '1/2'
screen:
agent_create:
tag: 'input'
type: 'hidden'
display: false
},
{
name: 'translation::content::body'
model: 'translation'
buttons: [
'link'
'link_answer'
]
display: 'Content'
tag: 'richtext'
null: true
}
]
publicBaseUrl: (kb_locale) ->
return null if @isNew()
App.Utils.joinUrlComponents [@category().publicBaseUrl(kb_locale), @id]
@translatableClass: -> App.KnowledgeBaseAnswerTranslation
@translatableForeignKey: -> 'answer_id'
@extend App.KnowledgeBaseTranslatable
remove: (options = {}) ->
@removeTranslations(options)
super
baseParams: ->
{ category_id: @category_id }
category: ->
App.KnowledgeBaseCategory.find(@category_id)
objectName: ->
'Answer'
visibleInternally: (kb_locale) =>
@is_internally_published(kb_locale)

View file

@ -0,0 +1,112 @@
class App.KnowledgeBaseAnswerTranslation extends App.Model
@configure 'KnowledgeBaseAnswerTranslation', 'title', 'preview', 'content', 'note', 'source', 'outdated', 'promoted', 'answer_id', 'locale_id', 'state_id'
@extend Spine.Model.Ajax
@extend App.KnowledgeBaseTranslationable
@configure_attributes = [
{ name: 'title', display: 'Name', tag: 'input', type: 'text', limit: 300, null: false, info: true },
{ name: 'created_at', display: 'Created at', tag: 'datetime', readonly: 1, info: false },
{ name: 'updated_at', display: 'Updated at', tag: 'datetime', readonly: 1, info: false },
]
url: ->
@parent().generateURL('translations')
uiUrl: (action = null) ->
@parent().uiUrl(App.KnowledgeBaseLocale.localeFor(@), action)
publicBaseUrl: ->
@parent().publicBaseUrl(App.KnowledgeBaseLocale.localeFor(@))
content: ->
App.KnowledgeBaseAnswerTranslationContent.find(@content_id)
displayName: ->
@title
parent: ->
App.KnowledgeBaseAnswer.find(@answer_id)
remove: (options = {}) ->
@content()?.remove(options)
super
attributes: ->
attributes = super
attributes.content = @content()?.attributes()
attributes
loadFull: (callback) ->
url = @parent().generateURL() + "?full=1&include_contents=#{@content_id}"
App.Ajax.request(
url: url
success: (data, status, xhr) ->
App.Collection.loadAssets(data.assets)
callback(true)
error: (xhr) ->
callback(false)
App.Event.trigger 'notify', {
type: 'error'
msg: xhr.responseJSON?.error || 'Unable to load'
}
)
@processAttributes: (params) ->
if (content_params = params['content']) && _.isObject(content_params)
delete params['content']
params['content_attributes'] = content_params
params
searchResultAttributes: ->
_.extend {}, @defaultSearchResultAttributes(),
class: 'kb-answer-popover'
icon: 'knowledge-base'
@configure_overview = [
'title', 'updated_at'
]
@display_name = 'Knowledge Base Answer'
linked_tickets: ->
@linked_references
.filter (elem) -> elem['link_object'] == 'Ticket'
.map (elem) -> App.Ticket.find(elem['link_object_value'])
class App.KnowledgeBaseAnswerTranslationContent extends App.Model
@configure 'KnowledgeBaseAnswerTranslationContent', 'body'
@extend Spine.Model.Ajax
@url: @apiPath + '/knowledge_base/translation/content'
@configure_attributes = [
{ name: 'body', display: 'Body', tag: 'input' },
]
attributes: ->
attributes = super
attributes.body =
text: @body
attachments: @attachments
attributes
bodyTruncated: ->
string = @body.replace(/<([^>]+)>/g, '')
if string.length < 100
return string
string.substring(0, 100) + '...'
bodyWithPublicURLs: ->
parsed = $("<div>#{@body}</div>")
for linkDom in parsed.find('a').andSelf('a').toArray()
switch $(linkDom).attr('data-target-type')
when 'knowledge-base-answer'
if object = App.KnowledgeBaseAnswerTranslation.find $(linkDom).attr('data-target-id')
$(linkDom).attr 'href', object.publicBaseUrl()
else
$(linkDom).attr 'href', '#'
parsed[0].innerHTML

View file

@ -0,0 +1,4 @@
class App.KnowledgeBaseAnswerTranslationState extends App.Model
@configure 'KnowledgeBaseAnswerTranslationState', 'name'
@extend Spine.Model.Ajax
@url: @apiPath + '/knowledge_base/answer/translation/states'

View file

@ -0,0 +1,168 @@
class App.KnowledgeBaseCategory extends App.Model
@configure 'KnowledgeBaseCategory', 'category_icon', 'parent_id', 'child_ids', 'translation_ids'
@extend Spine.Model.Ajax
@extend App.KnowledgeBaseActions
url: ->
@knowledge_base().generateURL('categories')
uiUrl: (kb_locale, action = null) ->
App.Utils.joinUrlComponents @knowledge_base().uiUrl(kb_locale), @uiUrlComponent(), action
uiUrlComponent: ->
"category/#{@id}"
knowledge_base: ->
App.KnowledgeBase.find(@knowledge_base_id)
isEmpty: ->
@children().length is 0 and @answers().length is 0
remove: (options = {}) ->
@removeTranslations(options)
@children().forEach (elem) -> elem.remove(options)
@answers().forEach (elem) -> elem.remove(options)
super
categoriesForDropdown: (options) ->
spacer = Array.apply(null, {length: options.nested}).map(-> '- ').join('')
initial = [
{
value: @id
name: spacer + @guaranteedTitle(options.kb_locale.id)
}
]
@children().reduce (memo, elem) ->
memo.concat elem.categoriesForDropdown(nested: options.nested + 1, kb_locale: options.kb_locale)
, initial
configure_attributes: (kb_locale = undefined) ->
[
{
name: 'category_icon'
model: 'category'
display: 'Icon'
tag: 'icon_picker'
iconset: @knowledge_base().iconset
grid_width: '1/5'
null: false
default: @constructor.defaultIconFor(@knowledge_base())
screen:
agent_create:
shown: true
},
{
name: 'translation::title'
model: 'translation'
display: 'Title'
tag: 'input'
grid_width: '4/5'
null: false
screen:
agent_create:
shown: true
},
{
name: 'parent_id'
model: 'category'
display: 'Parent'
tag: 'select'
null: true
options: @knowledge_base().categoriesForDropdown(includeRoot: true, kb_locale: kb_locale)
grid_width: '1/2'
screen:
agent_create:
tag: 'input'
type: 'hidden'
display: false
}
]
publicBaseUrl: (kb_locale) ->
return null if @isNew()
App.Utils.joinUrlComponents [@knowledge_base().publicBaseUrl(kb_locale), @id]
@translatableClass: -> App.KnowledgeBaseCategoryTranslation
@translatableForeignKey: -> 'category_id'
@extend App.KnowledgeBaseTranslatable
baseParams: ->
{ parent_id: @parent_id }
children: ->
return [] if @id == undefined
App.KnowledgeBaseCategory
.findAllByAttribute('parent_id', @id)
.sort (a, b) -> a.position - b.position
deepChildrenIds: ->
children = @children()
ids = children.map (elem) -> elem.deepChildrenIds()
ids.push children.map (elem) -> elem.id
_.flatten(ids)
parent: ->
App.KnowledgeBaseCategory.find(@parent_id)
answers: ->
App.KnowledgeBaseAnswer
.findAllByAttribute('category_id', @id)
.sort (a, b) -> a.position - b.position
countDeepAnswers: ->
category_ids = @deepChildrenIds()
category_ids.push @id
App.KnowledgeBaseAnswer
.records
.filter (elem) -> _.contains(category_ids, elem.category_id)
.length
findDeepAnswer: (callback) =>
output = _.find(App.KnowledgeBaseAnswer.records, (record) =>
if record.category_id isnt @id
return false
callback(record)
)
if output?
return output
_.find(App.KnowledgeBaseCategory.records, (record) =>
if record.parent_id isnt @id
return false
record.findDeepAnswer(callback)
)
visibleInternally: (kb_locale) =>
@findDeepAnswer( (record) ->
record.is_internally_published(kb_locale)
)?
visiblePublicly: (kb_locale) =>
@findDeepAnswer( (record) ->
record.is_published(kb_locale)
)?
objectName: ->
'Category'
@defaultIconFor: (kb) ->
switch kb?.iconset
when 'FontAwesome'
'f115'
when 'anticon'
'e662'
when 'material'
'e94d'
when 'ionicons'
'f139'
when 'Simple-Line-Icons'
'e039'

View file

@ -0,0 +1,8 @@
class App.KnowledgeBaseCategoryTranslation extends App.Model
@configure 'KnowledgeBaseCategoryTranslation', 'title', 'locale_id', 'category_id'
@extend Spine.Model.Ajax
@extend App.KnowledgeBaseTranslationable
@url: @apiPath + '/knowledge_base/category/translations'
parent: ->
App.KnowledgeBaseCategory.find(@category_id)

View file

@ -0,0 +1,4 @@
class App.KnowledgeBaseLayout extends App.Model
@configure 'KnowledgeBaseLayout', 'name'
@extend Spine.Model.Ajax
@url: @apiPath + '/knowledge_base/layouts'

View file

@ -0,0 +1,36 @@
class App.KnowledgeBaseLocale extends App.Model
@configure 'KnowledgeBaseLocale', 'knowledge_base_id', 'system_locale_id', 'primary'
@extend Spine.Model.Ajax
@url: @apiPath + '/knowledge_base/locales'
systemLocale: ->
App.Locale.find(@system_locale_id)
urlSuffix: ->
"locale/#{@systemLocale().locale}"
@localeFor: (object) ->
if object.kb_locale_id is undefined
throw "This object doesn't have locale"
App.KnowledgeBaseLocale.find object.kb_locale_id
applyOntoPath: (path) ->
path.replace /\/locale\/[\w-]{2,5}/, "/#{@urlSuffix()}"
attributesForRendering: (path, options = {}) ->
{
url: @applyOntoPath(path)
title: @systemLocale().name
}
@detect: (knowledge_base) ->
locale = App.Locale.findByAttribute('locale', App.i18n.get())
kb_locale = App.KnowledgeBaseLocale
.all()
.filter (elem) ->
elem.knowledge_base_id is knowledge_base.id and elem.system_locale_id is locale.id
.pop()
kb_locale || knowledge_base.primaryKbLocale()

View file

@ -0,0 +1,7 @@
class App.KnowledgeBaseMenuItem extends App.Model
@configure 'KnowledgeBaseMenuItem', 'kb_locale_id', 'position', 'title', 'url'
@using_kb_locale: (kb_locale) ->
items = @findAllByAttribute('kb_locale_id', kb_locale.id)
items.sort( (a, b) -> if a.position < b.position then -1 else 1)
items

View file

@ -0,0 +1,11 @@
class App.KnowledgeBaseTranslation extends App.Model
@configure 'KnowledgeBaseTranslation', 'title', 'footer_note'
@extend Spine.Model.Ajax
@extend App.KnowledgeBaseTranslationable
@url: @apiPath + '/knowledge_base/translations'
@configure_attributes = [
{ name: 'title', display: 'Title', tag: 'input' },
]
parent: ->
App.KnowledgeBase.find(@knowledge_base_id)

View file

@ -7,6 +7,10 @@
<span class="tab-badge"><%= item.count %></span> <span class="tab-badge"><%= item.count %></span>
</a> </a>
<% end %> <% end %>
<div class="tab tab-dropdown js-toggle" data-toggle="dropdown">
<%- @Icon('dropdown-list') %>
<%- @Icon('arrow-down', 'arrow') %>
</div>
<ul class="dropdown dropdown--actions dropdown--wide dropdown-menu dropdown-menu-right js-dropdown" role="menu" aria-labelledby="userAction"> <ul class="dropdown dropdown--actions dropdown--wide dropdown-menu dropdown-menu-right js-dropdown" role="menu" aria-labelledby="userAction">
<% for item in @items: %> <% for item in @items: %>
<li class="js-dropdownItem hide<%= ' active' if item.active %>" role="presentation" data-target="<%= item.target %>" role="menuitem" tabindex="-1"> <li class="js-dropdownItem hide<%= ' active' if item.active %>" role="presentation" data-target="<%= item.target %>" role="menuitem" tabindex="-1">
@ -14,10 +18,6 @@
<span class="badge badge--text"><%= item.count %></span> <span class="badge badge--text"><%= item.count %></span>
<% end %> <% end %>
</ul> </ul>
<div class="tab tab-dropdown js-toggle" data-toggle="dropdown">
<%- @Icon('dropdown-list') %>
<%- @Icon('arrow-down', 'arrow') %>
</div>
<% end %> <% end %>
</div> </div>
</div> </div>

View file

@ -0,0 +1,31 @@
<% if !_.isEmpty(@attachments): %>
<div class="attachments attachments--list">
<%- @Icon('paperclip') %>
<div class="attachments-title"><%- @attachments.length %> <%- @T('Attached Files') %></div>
<% for attachment in @attachments: %>
<% if !@C('ui_ticket_zoom_attachments_preview'): %>
<div class="attachment attachment--row">
<a class="attachment-name u-highlight" href="<%= attachment.url %>" data-type="attachment" <% if @canDownload(content_type): %>download<% else: %>target="_blank"<% end %>><%= attachment.filename %></a>
<div class="attachment-size"><%- @humanFileSize(attachment.size) %></div>
</div>
<% else: %>
<% content_type = attachment.preferences['Content-Type'] || attachment.preferences['content_type'] %>
<a class="attachment attachment--preview" href="<%= attachment.url %>" data-type="attachment"<% if @canDownload(content_type): %> download<% else: %>target="_blank"<% end %>>
<div class="attachment-icon">
<% if attachment.preferences && content_type && @ContentTypeIcon(content_type): %>
<% if @canPreview(content_type): %>
<img src="<%= attachment.preview_url %>">
<% else: %>
<%- @Icon( @ContentTypeIcon(content_type) ) %>
<% end %>
<% else: %>
<%- @Icon('file-unknown') %>
<% end %>
</div>
<span class="attachment-name u-highlight"><%= attachment.filename %></span>
<div class="attachment-size"><%- @humanFileSize(attachment.size) %></div>
</a>
<% end %>
<% end %>
</div>
<% end %>

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