Initial knowledge base support.
This commit is contained in:
parent
5d2398a3aa
commit
97d14a93b3
308 changed files with 37506 additions and 505 deletions
|
@ -57,7 +57,7 @@ rules:
|
|||
no-case-declarations: 2
|
||||
no-div-regex: 2
|
||||
no-else-return: 0
|
||||
no-empty-label: 2
|
||||
no-labels: 2
|
||||
no-empty-pattern: 2
|
||||
no-eq-null: 2
|
||||
no-eval: 2
|
||||
|
@ -69,7 +69,6 @@ rules:
|
|||
no-implied-eval: 2
|
||||
no-invalid-this: 0
|
||||
no-iterator: 2
|
||||
no-labels: 0
|
||||
no-lone-blocks: 2
|
||||
no-loop-func: 2
|
||||
no-magic-number: 0
|
||||
|
|
8
Gemfile
8
Gemfile
|
@ -31,6 +31,9 @@ gem 'eventmachine'
|
|||
# core - password security
|
||||
gem 'argon2', '1.1.5'
|
||||
|
||||
# core - state machine
|
||||
gem 'aasm'
|
||||
|
||||
# performance - Memcached
|
||||
gem 'dalli'
|
||||
|
||||
|
@ -105,6 +108,9 @@ gem 'telephone_number'
|
|||
# feature - SMS
|
||||
gem 'twilio-ruby'
|
||||
|
||||
# feature - ordering
|
||||
gem 'acts_as_list'
|
||||
|
||||
# integrations
|
||||
gem 'clearbit'
|
||||
gem 'net-ldap'
|
||||
|
@ -136,7 +142,9 @@ group :development, :test do
|
|||
gem 'pry-stack_explorer'
|
||||
|
||||
# test frameworks
|
||||
gem 'rails-controller-testing'
|
||||
gem 'rspec-rails'
|
||||
gem 'shoulda-matchers'
|
||||
gem 'test-unit'
|
||||
|
||||
# test DB
|
||||
|
|
14
Gemfile.lock
14
Gemfile.lock
|
@ -47,6 +47,8 @@ GIT
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
aasm (5.0.0)
|
||||
concurrent-ruby (~> 1.0)
|
||||
actioncable (5.1.7)
|
||||
actionpack (= 5.1.7)
|
||||
nio4r (~> 2.0)
|
||||
|
@ -94,6 +96,8 @@ GEM
|
|||
i18n (>= 0.7, < 2)
|
||||
minitest (~> 5.1)
|
||||
tzinfo (~> 1.1)
|
||||
acts_as_list (0.9.16)
|
||||
activerecord (>= 3.0)
|
||||
addressable (2.5.2)
|
||||
public_suffix (>= 2.0.2, < 4.0)
|
||||
arel (8.0.0)
|
||||
|
@ -374,6 +378,10 @@ GEM
|
|||
bundler (>= 1.3.0)
|
||||
railties (= 5.1.7)
|
||||
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)
|
||||
activesupport (>= 4.2.0)
|
||||
nokogiri (>= 1.6)
|
||||
|
@ -450,6 +458,8 @@ GEM
|
|||
childprocess (>= 0.5, < 2.0)
|
||||
rubyzip (~> 1.2, >= 1.2.2)
|
||||
shellany (0.0.1)
|
||||
shoulda-matchers (4.0.1)
|
||||
activesupport (>= 4.2.0)
|
||||
simple_oauth (0.3.1)
|
||||
simplecov (0.16.1)
|
||||
docile (~> 1.1)
|
||||
|
@ -530,9 +540,11 @@ PLATFORMS
|
|||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
aasm
|
||||
activerecord-import
|
||||
activerecord-nulldb-adapter
|
||||
activerecord-session_store
|
||||
acts_as_list
|
||||
argon2 (= 1.1.5)
|
||||
autodiscover!
|
||||
autoprefixer-rails
|
||||
|
@ -592,6 +604,7 @@ DEPENDENCIES
|
|||
puma
|
||||
rack-livereload
|
||||
rails (= 5.1.7)
|
||||
rails-controller-testing
|
||||
rails-observers
|
||||
rb-fsevent
|
||||
rchardet (>= 1.8.0)
|
||||
|
@ -603,6 +616,7 @@ DEPENDENCIES
|
|||
rubyntlm!
|
||||
sassc-rails
|
||||
selenium-webdriver
|
||||
shoulda-matchers
|
||||
simplecov
|
||||
simplecov-rcov
|
||||
slack-notifier
|
||||
|
|
|
@ -163,3 +163,28 @@ Source: https://gist.github.com/sbrin/6801034
|
|||
Copyright: 2015, sbrin - https://github.com/sbrin
|
||||
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
|
||||
-----------------------------------------------------------------------------
|
||||
|
|
|
@ -29,8 +29,13 @@
|
|||
"url": "",
|
||||
"license": "MIT"
|
||||
},
|
||||
"reply.svg": {
|
||||
"author": "Felix Niklas",
|
||||
"url": "",
|
||||
"license": "MIT"
|
||||
},
|
||||
"weibo-button.svg": {
|
||||
"author": "",
|
||||
"author": "Weibo",
|
||||
"url": "",
|
||||
"license": ""
|
||||
},
|
||||
|
@ -159,11 +164,46 @@
|
|||
"url": "",
|
||||
"license": "MIT"
|
||||
},
|
||||
"rearange.svg": {
|
||||
"author": "Felix Niklas",
|
||||
"url": "",
|
||||
"license": "MIT"
|
||||
},
|
||||
"external.svg": {
|
||||
"author": "Felix Niklas",
|
||||
"url": "",
|
||||
"license": "MIT"
|
||||
},
|
||||
"mood-sad.svg": {
|
||||
"author": "Felix Niklas",
|
||||
"url": "",
|
||||
"license": "MIT"
|
||||
},
|
||||
"radio.svg": {
|
||||
"author": "Zammad",
|
||||
"url": "",
|
||||
"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": {
|
||||
"author": "Felix Niklas",
|
||||
"url": "",
|
||||
|
@ -180,9 +220,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"inactive-user.svg": {
|
||||
"author": "R\u00e9my M\u00e9dard",
|
||||
"url": "https:\/\/thenounproject.com\/search\/?q=user&i=10314",
|
||||
"license": "CC 3.0 Attribution"
|
||||
"author": "Felix Niklas",
|
||||
"url": "",
|
||||
"license": "MIT"
|
||||
},
|
||||
"inactive-organization.svg": {
|
||||
"author": "Felix Niklas",
|
||||
|
@ -194,6 +234,16 @@
|
|||
"url": "",
|
||||
"license": "MIT"
|
||||
},
|
||||
"important.svg": {
|
||||
"author": "Felix Niklas",
|
||||
"url": "",
|
||||
"license": "MIT"
|
||||
},
|
||||
"reply-all.svg": {
|
||||
"author": "Felix Niklas",
|
||||
"url": "",
|
||||
"license": "MIT"
|
||||
},
|
||||
"paperclip.svg": {
|
||||
"author": "Cheesefork",
|
||||
"url": "https:\/\/thenounproject.com\/search\/?q=attachment&i=197956",
|
||||
|
@ -204,6 +254,21 @@
|
|||
"url": "",
|
||||
"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": {
|
||||
"author": "Felix Niklas",
|
||||
"url": "",
|
||||
|
@ -220,9 +285,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"file-pdf.svg": {
|
||||
"author": "Felix Niklas",
|
||||
"author": "Adobe",
|
||||
"url": "",
|
||||
"license": "MIT"
|
||||
"license": ""
|
||||
},
|
||||
"file-excel.svg": {
|
||||
"author": "Felix Niklas",
|
||||
|
@ -244,28 +309,8 @@
|
|||
"url": "",
|
||||
"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": {
|
||||
"author": "",
|
||||
"author": "Office 365",
|
||||
"url": "",
|
||||
"license": ""
|
||||
},
|
||||
|
@ -589,6 +634,31 @@
|
|||
"url": "",
|
||||
"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": {
|
||||
"author": "Zammad",
|
||||
"url": "",
|
||||
|
@ -614,11 +684,6 @@
|
|||
"url": "",
|
||||
"license": "MIT"
|
||||
},
|
||||
"checkmark.svg": {
|
||||
"author": "Zammad",
|
||||
"url": "",
|
||||
"license": "MIT"
|
||||
},
|
||||
"chat.svg": {
|
||||
"author": "Felix Niklas",
|
||||
"url": "",
|
||||
|
|
|
@ -88,8 +88,10 @@ class App.Controller extends Spine.Controller
|
|||
for callId in idsToCancel
|
||||
App.Ajax.abort(callId)
|
||||
|
||||
# release Spine's event handling
|
||||
release: ->
|
||||
# release custom bindings after it got removed from dom
|
||||
@off()
|
||||
@stopListening()
|
||||
|
||||
# add @title methode to set title
|
||||
title: (name, translate = false) ->
|
||||
|
@ -452,6 +454,7 @@ class App.ControllerModal extends App.Controller
|
|||
buttonCancel: false
|
||||
buttonCancelClass: 'btn--text btn--subtle'
|
||||
buttonSubmit: true
|
||||
includeForm: true
|
||||
headPrefix: ''
|
||||
shown: true
|
||||
closeOnAnyClick: false
|
||||
|
@ -516,6 +519,7 @@ class App.ControllerModal extends App.Controller
|
|||
buttonClass: @buttonClass
|
||||
centerButtons: @centerButtons
|
||||
leftButtons: @leftButtons
|
||||
includeForm: @includeForm
|
||||
))
|
||||
modal.find('.modal-body').html(content)
|
||||
if !@initRenderingDone
|
||||
|
@ -554,17 +558,18 @@ class App.ControllerModal extends App.Controller
|
|||
if @small
|
||||
@el.addClass('modal--small')
|
||||
|
||||
@el.modal(
|
||||
keyboard: @keyboard
|
||||
show: true
|
||||
backdrop: @backdrop
|
||||
container: @container
|
||||
).on(
|
||||
@el
|
||||
.on(
|
||||
'show.bs.modal': @localOnShow
|
||||
'shown.bs.modal': @localOnShown
|
||||
'hide.bs.modal': @localOnClose
|
||||
'hidden.bs.modal': @localOnClosed
|
||||
'dismiss.bs.modal': @localOnCancel
|
||||
).modal(
|
||||
keyboard: @keyboard
|
||||
show: true
|
||||
backdrop: @backdrop
|
||||
container: @container
|
||||
)
|
||||
|
||||
if @closeOnAnyClick
|
||||
|
@ -604,7 +609,7 @@ class App.ControllerModal extends App.Controller
|
|||
|
||||
onShown: (e) =>
|
||||
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()
|
||||
|
||||
localOnClose: (e) =>
|
||||
|
|
|
@ -1,4 +1,9 @@
|
|||
class App.ControllerForm extends App.Controller
|
||||
fullFormSubmitLabel: 'Submit'
|
||||
fullFormSubmitAdditionalClasses: ''
|
||||
fullFormButtonsContainerClass: ''
|
||||
fullFormAdditionalButtons: [] # [{className: 'js-class', text: 'Label'}]
|
||||
|
||||
constructor: (params) ->
|
||||
super
|
||||
for key, value of params
|
||||
|
@ -71,7 +76,9 @@ class App.ControllerForm extends App.Controller
|
|||
App.Log.debug 'ControllerForm', 'formGen', @model.configure_attributes
|
||||
|
||||
# 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
|
||||
else
|
||||
fieldset = $('<fieldset></fieldset>')
|
||||
|
@ -127,7 +134,24 @@ class App.ControllerForm extends App.Controller
|
|||
if @fullForm
|
||||
if !@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
|
||||
if @events
|
||||
|
@ -258,11 +282,15 @@ class App.ControllerForm extends App.Controller
|
|||
# set params value
|
||||
if @params
|
||||
|
||||
# check if we have a references
|
||||
parts = attribute.name.split '::'
|
||||
if parts[0] && parts[1]
|
||||
if @params[ parts[0] ] && parts[1] of @params[ parts[0] ]
|
||||
attribute.value = @params[ parts[0] ][ parts[1] ]
|
||||
|
||||
if parts.length > 1
|
||||
deepValue = parts.reduce((memo, elem) ->
|
||||
memo?[elem]
|
||||
, @params)
|
||||
|
||||
if deepValue isnt undefined
|
||||
attribute.value = deepValue
|
||||
|
||||
# set params value to default
|
||||
if attribute.name of @params
|
||||
|
@ -426,11 +454,16 @@ class App.ControllerForm extends App.Controller
|
|||
)
|
||||
|
||||
# get all params of the form
|
||||
@params: (form) ->
|
||||
# set clearAccessories to true to remove inline image resizing handles
|
||||
@params: (form, clearAccessories = false) ->
|
||||
param = {}
|
||||
|
||||
lookupForm = @findForm(form)
|
||||
|
||||
if clearAccessories
|
||||
# remove inline image resizing handles
|
||||
lookupForm.find('.richtext.form-control').trigger('click')
|
||||
|
||||
# get contenteditable
|
||||
for element in lookupForm.find('[contenteditable]')
|
||||
name = $(element).data('name')
|
||||
|
@ -656,6 +689,9 @@ class App.ControllerForm extends App.Controller
|
|||
# set forms to read only during communication with backend
|
||||
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
|
||||
lookupForm.find('button').prop('disabled', true)
|
||||
else
|
||||
|
@ -678,6 +714,9 @@ class App.ControllerForm extends App.Controller
|
|||
# enable fields again
|
||||
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
|
||||
lookupForm.find('button').prop('disabled', false)
|
||||
else
|
||||
|
|
|
@ -403,6 +403,8 @@ class App.ControllerTabs extends App.Controller
|
|||
subHeader: @subHeader
|
||||
tabs: @tabs
|
||||
addTab: @addTab
|
||||
headerSwitchName: @headerSwitchName
|
||||
headerSwitchChecked: @headerSwitchChecked
|
||||
)
|
||||
|
||||
# insert content
|
||||
|
|
|
@ -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()
|
||||
|
|
@ -116,6 +116,7 @@ class App.ControllerTable extends App.Controller
|
|||
shownPage: 0
|
||||
|
||||
destroy: false
|
||||
customActions: []
|
||||
|
||||
columnsLength: undefined
|
||||
headers: undefined
|
||||
|
@ -544,7 +545,7 @@ class App.ControllerTable extends App.Controller
|
|||
|
||||
# get header data
|
||||
@headers = []
|
||||
@actions = []
|
||||
@actions = [].concat @customActions
|
||||
availableWidth = @availableWidth
|
||||
for item in @overviewAttributes
|
||||
headerFound = false
|
||||
|
|
|
@ -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')
|
|
@ -49,8 +49,9 @@ class Index extends App.ControllerSubContent
|
|||
delete: (e) =>
|
||||
e.preventDefault()
|
||||
|
||||
id = $(e.currentTarget).data('token-id')
|
||||
|
||||
callback = =>
|
||||
id = $(e.target).closest('a').data('token-id')
|
||||
@ajax(
|
||||
id: 'user_access_token_delete'
|
||||
type: 'DELETE'
|
||||
|
|
|
@ -163,12 +163,21 @@ class App.UiElement.ApplicationUiElement
|
|||
if attribute.translate
|
||||
nameNew = App.i18n.translateInline(nameNew)
|
||||
|
||||
attribute.options.push
|
||||
row =
|
||||
value: item.id,
|
||||
note: item.note,
|
||||
name: 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
|
||||
|
||||
# execute filter
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
# coffeelint: disable=camel_case_classes
|
||||
class App.UiElement.autocompletion_ajax
|
||||
@render: (attribute, params = {}) ->
|
||||
|
||||
@render: (attribute, params = {}, form) ->
|
||||
if params[attribute.name] || attribute.value
|
||||
object = App[attribute.relation].find(params[attribute.name] || attribute.value)
|
||||
valueName = object.displayName()
|
||||
|
||||
# selectable search
|
||||
searchableAjaxSelectObject = new App.SearchableAjaxSelect(
|
||||
delegate: form
|
||||
attribute:
|
||||
value: params[attribute.name] || attribute.value
|
||||
valueName: valueName
|
||||
name: attribute.name
|
||||
id: params.organization_id || attribute.value
|
||||
id: params.organization_id || attribute.id
|
||||
placeholder: App.i18n.translateInline('Search...')
|
||||
limit: 40
|
||||
object: attribute.relation
|
||||
|
|
|
@ -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()
|
|
@ -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()
|
|
@ -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()
|
|
@ -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"]
|
|
@ -1,5 +1,7 @@
|
|||
# coffeelint: disable=camel_case_classes
|
||||
class App.UiElement.radio extends App.UiElement.ApplicationUiElement
|
||||
@template_name: 'radio'
|
||||
|
||||
@render: (attribute, params) ->
|
||||
|
||||
# build options list based on config
|
||||
|
@ -23,4 +25,4 @@ class App.UiElement.radio extends App.UiElement.ApplicationUiElement
|
|||
# filter attributes
|
||||
@filterOption(attribute, params)
|
||||
|
||||
$( App.view('generic/radio')( attribute: attribute ) )
|
||||
$( App.view("generic/#{@template_name}")( attribute: attribute ) )
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
# coffeelint: disable=camel_case_classes
|
||||
class App.UiElement.radio_graphic extends App.UiElement.radio
|
||||
@template_name: 'radio_graphic'
|
|
@ -1,12 +1,19 @@
|
|||
# coffeelint: disable=camel_case_classes
|
||||
class App.UiElement.richtext
|
||||
@render: (attribute, params) ->
|
||||
item = $( App.view('generic/richtext')(attribute: attribute) )
|
||||
item.find('[contenteditable]').ce(
|
||||
@render: (attribute, params, form) ->
|
||||
if _.isObject(attribute.value)
|
||||
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
|
||||
maxlength: attribute.maxlength
|
||||
buttons: attribute.buttons
|
||||
)
|
||||
|
||||
item.find('a.btn--action[data-action]').click (event) => @toolButtonClicked(event, form)
|
||||
|
||||
if attribute.plugins
|
||||
for plugin in attribute.plugins
|
||||
params = plugin.params || {}
|
||||
|
@ -25,6 +32,10 @@ class App.UiElement.richtext
|
|||
for file in params.attachments
|
||||
renderFile(file)
|
||||
|
||||
if attribute.attachments
|
||||
for file in attribute.attachments
|
||||
renderFile(file)
|
||||
|
||||
# remove items
|
||||
item.find('.attachments').on('click', '.js-delete', (e) =>
|
||||
id = $(e.currentTarget).data('id')
|
||||
|
@ -35,10 +46,12 @@ class App.UiElement.richtext
|
|||
item
|
||||
)
|
||||
|
||||
form_id = item.closest('form').find('[name=form_id]').val()
|
||||
|
||||
# delete attachment from storage
|
||||
App.Ajax.request(
|
||||
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
|
||||
)
|
||||
|
||||
|
@ -56,15 +69,15 @@ class App.UiElement.richtext
|
|||
@attachmentsHolder = item.find('.attachments')
|
||||
@cancelContainer = item.find('.js-cancel')
|
||||
|
||||
upload_initialize_callback = =>
|
||||
form_id = item.closest('form').find('[name=form_id]').val()
|
||||
html5Upload.initialize(
|
||||
uploadUrl: "#{App.Config.get('api_path')}/upload_caches/#{form_id}"
|
||||
u = => html5Upload.initialize(
|
||||
uploadUrl: "#{App.Config.get('api_path')}/attachments"
|
||||
dropContainer: item.closest('form').get(0)
|
||||
cancelContainer: @cancelContainer
|
||||
inputField: item.find('input').get(0)
|
||||
maxSimultaneousUploads: 1,
|
||||
key: 'File'
|
||||
data:
|
||||
form_id: item.closest('form').find('[name=form_id]').val()
|
||||
onFileAdded: (file) =>
|
||||
|
||||
file.on(
|
||||
|
@ -106,8 +119,51 @@ class App.UiElement.richtext
|
|||
if parseInt(progress) >= 90
|
||||
@cancelContainer.addClass('hide')
|
||||
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
|
||||
|
||||
@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
|
||||
|
|
|
@ -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')
|
|
@ -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')
|
|
@ -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'
|
||||
}
|
||||
]
|
|
@ -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://'
|
||||
}
|
||||
]
|
|
@ -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()
|
|
@ -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()
|
|
@ -51,6 +51,16 @@ class App.UiElement.select extends App.UiElement.ApplicationUiElement
|
|||
return if value 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
|
||||
attribute.options[value] = attribute.historical_options[value]
|
||||
else
|
||||
|
|
|
@ -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)
|
|
@ -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')
|
|
@ -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()
|
|
@ -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
|
||||
]
|
|
@ -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()
|
|
@ -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()
|
|
@ -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.')
|
||||
)
|
|
@ -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)
|
|
@ -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
|
|
@ -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]
|
|
@ -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
|
|
@ -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)
|
||||
)
|
|
@ -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
|
|
@ -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'
|
|
@ -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')
|
|
@ -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
|
||||
)
|
|
@ -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')
|
||||
|
|
@ -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
|
||||
)
|
|
@ -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()
|
|
@ -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)
|
|
@ -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')
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
|
@ -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')
|
|
@ -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)
|
|
@ -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
|
||||
)
|
|
@ -2239,4 +2239,137 @@ class ChatToTicketRef extends App.ControllerContent
|
|||
y2: y1 + @attachments.outerHeight()
|
||||
|
||||
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')
|
||||
|
|
|
@ -21,6 +21,7 @@ class App.Navigation extends App.ControllerWidgetPermanent
|
|||
'click .js-global-search-result': 'emptyAndCloseDelayed'
|
||||
'click .js-details-link': 'openExtendedSearch'
|
||||
'change .js-menu .js-switch input': 'switch'
|
||||
'click .js-onclick': 'click'
|
||||
|
||||
constructor: ->
|
||||
super
|
||||
|
@ -97,6 +98,10 @@ class App.Navigation extends App.ControllerWidgetPermanent
|
|||
item.switch = worker.switch()
|
||||
if worker.active && worker.active()
|
||||
activeTab[item.target] = true
|
||||
if worker.onclick
|
||||
item.onclick = worker.onclick()
|
||||
if worker.accessoryIcon
|
||||
item.accessoryIcon = worker.accessoryIcon()
|
||||
if worker.featureActive
|
||||
if worker.featureActive()
|
||||
shown = true
|
||||
|
@ -120,6 +125,13 @@ class App.Navigation extends App.ControllerWidgetPermanent
|
|||
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
|
||||
switch: (e) ->
|
||||
val = $(e.target).prop('checked')
|
||||
|
|
|
@ -85,9 +85,9 @@ class App.Search extends App.Controller
|
|||
|
||||
@tabs = []
|
||||
for model in App.Config.get('models_searchable')
|
||||
model = model.replace(/::/, '')
|
||||
model = model.replace(/::/g, '')
|
||||
tab =
|
||||
name: model
|
||||
name: App[model]?.display_name || model
|
||||
model: model
|
||||
count: 0
|
||||
active: false
|
||||
|
|
|
@ -131,6 +131,9 @@ class ArticleViewItem extends App.ObserverController
|
|||
attachments = App.TicketArticle.contentAttachments(article)
|
||||
if 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
|
||||
link =
|
||||
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')(
|
||||
ticket: @ticket
|
||||
article: article
|
||||
attachments: attachments
|
||||
attachments: App.view('generic/attachments')(attachments: attachments)
|
||||
links: links
|
||||
)
|
||||
|
||||
|
|
|
@ -54,6 +54,19 @@ class Edit extends App.ObserverController
|
|||
)
|
||||
|
||||
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: =>
|
||||
@item = {
|
||||
name: 'ticket'
|
||||
|
@ -96,6 +109,9 @@ class SidebarTicket extends App.Controller
|
|||
if @linkWidget && args.links
|
||||
@linkWidget.reload(args.links)
|
||||
|
||||
if @linkKbAnswerWidget && args.links
|
||||
@linkKbAnswerWidget.reload(args.links)
|
||||
|
||||
editTicket: (el) =>
|
||||
@el = el
|
||||
localEl = $(App.view('ticket_zoom/sidebar_ticket')())
|
||||
|
@ -121,6 +137,15 @@ class SidebarTicket extends App.Controller
|
|||
object: @ticket
|
||||
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(
|
||||
el: localEl.filter('.js-timeUnit')
|
||||
object_id: @ticket.id
|
||||
|
|
|
@ -61,8 +61,8 @@ class Index extends App.ControllerSubContent
|
|||
display: 'Action'
|
||||
className: 'actionCell'
|
||||
translation: true
|
||||
width: '200px'
|
||||
displayWidth: 200
|
||||
width: '250px'
|
||||
displayWidth: 250
|
||||
unresizable: true
|
||||
header.push attribute
|
||||
header
|
||||
|
@ -70,7 +70,7 @@ class Index extends App.ControllerSubContent
|
|||
callbackAttributes = (value, object, attribute, header) ->
|
||||
text = App.i18n.translateInline('View from user\'s perspective')
|
||||
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.parentClass = 'actionCell no-padding'
|
||||
attribute.link = ''
|
||||
|
|
|
@ -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()
|
|
@ -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()
|
|
@ -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
|
||||
)
|
||||
)
|
|
@ -28,7 +28,7 @@ class App.Track
|
|||
class _trackSingleton
|
||||
constructor: ->
|
||||
@trackId = "track-#{new Date().getTime()}-#{Math.floor(Math.random() * 99999)}"
|
||||
@browser = App.Browser.detection()
|
||||
@browser = App.Browser.detection() if App.Browser
|
||||
@data = []
|
||||
# @url = 'http://localhost:3005/api/v1/ui'
|
||||
@url = 'https://log.zammad.com/api/v1/ui'
|
||||
|
|
140
app/assets/javascripts/app/lib/app_post/color.coffee
Normal file
140
app/assets/javascripts/app/lib/app_post/color.coffee
Normal 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 +'%'
|
||||
|
||||
|
154
app/assets/javascripts/app/lib/app_post/icon_picker.coffee
Normal file
154
app/assets/javascripts/app/lib/app_post/icon_picker.coffee
Normal 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
|
||||
|
|
@ -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')
|
95
app/assets/javascripts/app/lib/app_post/multi_locales.coffee
Normal file
95
app/assets/javascripts/app/lib/app_post/multi_locales.coffee
Normal 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
|
|
@ -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')
|
|
@ -0,0 +1,5 @@
|
|||
class App.KbPopoverProvider extends App.SingleObjectPopoverProvider
|
||||
@templateName = 'kb_generic'
|
||||
@includeData = false
|
||||
displayTitleUsing: (object) ->
|
||||
object.title
|
|
@ -0,0 +1,5 @@
|
|||
class KnowledgeBaseAnswer extends App.KbPopoverProvider
|
||||
@klass = App.KnowledgeBaseAnswerTranslation
|
||||
@selectorCssClassPrefix = 'kb-answer'
|
||||
|
||||
App.PopoverProvider.registerProvider('KnowledgeBaseAnswer', KnowledgeBaseAnswer)
|
|
@ -0,0 +1,5 @@
|
|||
class KnowledgeBaseCategory extends App.KbPopoverProvider
|
||||
@klass = App.KnowledgeBaseCategoryTranslation
|
||||
@selectorCssClassPrefix = 'kb-category'
|
||||
|
||||
App.PopoverProvider.registerProvider('KnowledgeBaseCategory', KnowledgeBaseCategory)
|
|
@ -0,0 +1,5 @@
|
|||
class KnowledgeBase extends App.KbPopoverProvider
|
||||
@klass = App.KnowledgeBaseTranslation
|
||||
@selectorCssClassPrefix = 'kb'
|
||||
|
||||
App.PopoverProvider.registerProvider('KnowledgeBase', KnowledgeBase)
|
|
@ -115,7 +115,6 @@ class App.SearchableSelect extends Spine.Controller
|
|||
onDropdownShown: =>
|
||||
@input.on 'click', @stopPropagation
|
||||
@highlightFirst()
|
||||
$(document).on 'keydown.searchable_select', @navigate
|
||||
if @level > 0
|
||||
@showSubmenu(@currentMenu)
|
||||
@isOpen = true
|
||||
|
@ -123,7 +122,6 @@ class App.SearchableSelect extends Spine.Controller
|
|||
onDropdownHidden: =>
|
||||
@input.off 'click', @stopPropagation
|
||||
@unhighlightCurrentItem()
|
||||
$(document).off 'keydown.searchable_select'
|
||||
@isOpen = false
|
||||
|
||||
if !@input.val()
|
||||
|
@ -359,8 +357,10 @@ class App.SearchableSelect extends Spine.Controller
|
|||
|
||||
onBlur: ->
|
||||
@clearAutocomplete()
|
||||
@input.off 'keydown.searchable_select'
|
||||
|
||||
onFocus: ->
|
||||
@input.on 'keydown.searchable_select', @navigate
|
||||
textEnd = @input.val().length
|
||||
@input.prop('selectionStart', textEnd)
|
||||
@input.prop('selectionEnd', textEnd)
|
||||
|
@ -372,6 +372,7 @@ class App.SearchableSelect extends Spine.Controller
|
|||
onShadowChange: ->
|
||||
value = @shadowInput.val()
|
||||
|
||||
if Array.isArray(@attribute.options)
|
||||
for option in @attribute.options
|
||||
option.selected = (option.value + '') == value # makes sure option value is always a string
|
||||
|
||||
|
|
|
@ -1213,6 +1213,37 @@ class App.Utils
|
|||
ctx.drawImage(img, 0, 0)
|
||||
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: ->
|
||||
fqdn = App.Config.get('fqdn')
|
||||
http_type = App.Config.get('http_type')
|
||||
|
|
|
@ -1,4 +1,9 @@
|
|||
class App.SearchableAjaxSelect extends App.SearchableSelect
|
||||
constructor: ->
|
||||
super
|
||||
|
||||
# create cache
|
||||
@searchResultCache = {}
|
||||
|
||||
onInput: (event) =>
|
||||
super
|
||||
|
@ -7,67 +12,62 @@ class App.SearchableAjaxSelect extends App.SearchableSelect
|
|||
# e.g. Ticket to ticket or AnotherObject to another_object
|
||||
objectString = underscored(@options.attribute.object)
|
||||
|
||||
# create common accessors
|
||||
@apiPath = App.Config.get('api_path')
|
||||
query = @input.val()
|
||||
|
||||
# create cache and cache key
|
||||
@searchResultCache = @searchResultCache || {}
|
||||
|
||||
cacheKey = "#{objectString}+#{@query}"
|
||||
# create cache key
|
||||
cacheKey = "#{objectString}+#{query}"
|
||||
|
||||
# use cache for search result
|
||||
if @searchResultCache[cacheKey]
|
||||
return @renderResponse( @searchResultCache[cacheKey] )
|
||||
App.Ajax.abort @options.attribute.id
|
||||
@renderResponse @searchResultCache[cacheKey], query
|
||||
return
|
||||
|
||||
# add timeout for loader icon
|
||||
clearTimeout @loaderTimeoutId
|
||||
if !@loaderTimeoutId
|
||||
@loaderTimeoutId = setTimeout @showLoader, 1000
|
||||
|
||||
# start search request and update options
|
||||
App.Ajax.request(
|
||||
attributes =
|
||||
id: @options.attribute.id
|
||||
type: 'GET'
|
||||
url: "#{@apiPath}/search/#{objectString}"
|
||||
url: "#{App.Config.get('api_path')}/search/#{objectString}"
|
||||
data:
|
||||
query: @query
|
||||
query: query
|
||||
limit: @options.attribute.limit
|
||||
processData: true
|
||||
success: (data, status, xhr) =>
|
||||
# cache search result
|
||||
@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
|
||||
clearTimeout @loaderTimeoutId
|
||||
@loaderTimeoutId = undefined
|
||||
@el.removeClass('is-loading')
|
||||
|
||||
# load assets
|
||||
App.Collection.loadAssets(data.assets)
|
||||
|
||||
# get options from search result
|
||||
options = []
|
||||
for object in data.result
|
||||
if object.type is 'Ticket'
|
||||
ticket = App.Ticket.find(object.id)
|
||||
data =
|
||||
name: "##{ticket.number} - #{ticket.title}"
|
||||
value: ticket.id
|
||||
options.push data
|
||||
else if object.type is 'User'
|
||||
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
|
||||
options = data
|
||||
.result
|
||||
.map (elem) =>
|
||||
# use search results directly to avoid loading KB assets in Ticket view
|
||||
if @useAjaxDetails
|
||||
@renderResponseItemAjax(elem, data)
|
||||
else
|
||||
@renderResponseItem(elem)
|
||||
.filter (elem) -> elem?
|
||||
|
||||
# fill template with gathered options
|
||||
@optionsList.html @renderOptions options
|
||||
|
@ -76,7 +76,32 @@ class App.SearchableAjaxSelect extends App.SearchableSelect
|
|||
@refreshElements()
|
||||
|
||||
# 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: =>
|
||||
@el.addClass('is-loading')
|
||||
|
|
|
@ -97,11 +97,15 @@
|
|||
}
|
||||
};
|
||||
showDropZone = function(dropContainer) {
|
||||
$(dropContainer).trigger('html5Upload.dropZone.show')
|
||||
|
||||
if ( !$(dropContainer).find('.article-content, .richtext').hasClass('is-dropTarget') ) {
|
||||
$(dropContainer).find('.article-content, .richtext').addClass('is-dropTarget')
|
||||
}
|
||||
}
|
||||
hideDropZone = function(dropContainer) {
|
||||
$(dropContainer).trigger('html5Upload.dropZone.hide')
|
||||
|
||||
if ( $(dropContainer).find('.article-content, .richtext').hasClass('is-dropTarget') ) {
|
||||
$(dropContainer).find('.article-content, .richtext').removeClass('is-dropTarget')
|
||||
}
|
||||
|
|
|
@ -68,18 +68,18 @@
|
|||
if (e.keyCode === 13) {
|
||||
e.preventDefault()
|
||||
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
|
||||
if (!id) {
|
||||
id = this.$widget.find('.dropdown-menu li:hover').data('id')
|
||||
if (!elem) {
|
||||
elem = this.$widget.find('.dropdown-menu li:hover')[0]
|
||||
}
|
||||
|
||||
// as fallback first element
|
||||
if (!id) {
|
||||
id = this.$widget.find('.dropdown-menu li:first-child').data('id')
|
||||
if (!elem) {
|
||||
elem = this.$widget.find('.dropdown-menu li:first-child')[0]
|
||||
}
|
||||
this.take(id)
|
||||
this.take(elem)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -132,8 +132,9 @@
|
|||
// backspace
|
||||
if (e.keyCode === 8 && this.buffer) {
|
||||
|
||||
var trigger = this.findTrigger(this.buffer)
|
||||
// backspace + buffer === :: -> close textmodule
|
||||
if (this.buffer === '::') {
|
||||
if (trigger && trigger.trigger === this.buffer) {
|
||||
this.close(true)
|
||||
e.preventDefault()
|
||||
return
|
||||
|
@ -143,7 +144,7 @@
|
|||
var length = this.buffer.length
|
||||
this.buffer = this.buffer.substr(0, length-1)
|
||||
this.log('BS backspace', this.buffer)
|
||||
this.result(this.buffer.substr(2, length-1))
|
||||
this.result(trigger)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -159,41 +160,53 @@
|
|||
// arrow keys
|
||||
if (e.keyCode === 37 || e.keyCode === 38 || e.keyCode === 39 || e.keyCode === 40) return
|
||||
|
||||
// observer other second key
|
||||
if (this.buffer === ':' && String.fromCharCode(e.which) !== ':') {
|
||||
var newChar = String.fromCharCode(e.which)
|
||||
|
||||
// observe other keys
|
||||
if (this.hasAvailableTriggers(this.buffer)) {
|
||||
if(this.hasAvailableTriggers(this.buffer + newChar)) {
|
||||
this.buffer = this.buffer + newChar
|
||||
} else if (!this.findTrigger(this.buffer)) {
|
||||
this.buffer = ''
|
||||
}
|
||||
|
||||
// oberserve second :
|
||||
if (this.buffer === ':' && String.fromCharCode(e.which) === ':') {
|
||||
this.buffer = this.buffer + ':'
|
||||
}
|
||||
|
||||
// oberserve first :
|
||||
if (!this.buffer && String.fromCharCode(e.which) === ':') {
|
||||
this.buffer = this.buffer + ':'
|
||||
if (!this.buffer && this.hasAvailableTriggers(newChar)) {
|
||||
this.buffer = this.buffer + newChar
|
||||
}
|
||||
|
||||
if (this.buffer && this.buffer.substr(0,2) === '::') {
|
||||
|
||||
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)
|
||||
}
|
||||
var trigger = this.findTrigger(this.buffer)
|
||||
if (trigger) {
|
||||
this.log('BUFF HINT', this.buffer, this.buffer.length, e.which, String.fromCharCode(e.which))
|
||||
|
||||
if (!this.isActive()) {
|
||||
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
|
||||
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.$widget = this.$element.next()
|
||||
this.$widget.on('mousedown', 'li', $.proxy(this.onEntryClick, this))
|
||||
|
@ -344,28 +357,28 @@
|
|||
|
||||
Plugin.prototype.onEntryClick = function(event) {
|
||||
event.preventDefault()
|
||||
var id = $(event.currentTarget).data('id')
|
||||
this.take(id)
|
||||
this.take(event.currentTarget)
|
||||
}
|
||||
|
||||
// select text module and insert into text
|
||||
Plugin.prototype.take = function(id) {
|
||||
if (!id) {
|
||||
Plugin.prototype.take = function(elem) {
|
||||
if (!elem) {
|
||||
this.close(true)
|
||||
return
|
||||
}
|
||||
for (var i = 0; i < this.collection.length; i++) {
|
||||
var item = this.collection[i]
|
||||
if (item.id == id) {
|
||||
var content = item.content
|
||||
this.cutInput()
|
||||
this.paste(content)
|
||||
this.close(true)
|
||||
return
|
||||
|
||||
var trigger = this.findTrigger(this.buffer)
|
||||
|
||||
if (trigger) {
|
||||
var _this = this;
|
||||
|
||||
trigger.renderValue(this, elem, function(text) {
|
||||
_this.cutInput()
|
||||
_this.paste(text)
|
||||
_this.close(true)
|
||||
})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Plugin.prototype.getFirstRange = function() {
|
||||
var sel = rangy.getSelection();
|
||||
|
@ -381,47 +394,33 @@
|
|||
}
|
||||
|
||||
// render result
|
||||
Plugin.prototype.result = function(term) {
|
||||
var _this = this
|
||||
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
|
||||
})
|
||||
Plugin.prototype.result = function(trigger) {
|
||||
if (!trigger) return
|
||||
|
||||
result.reverse()
|
||||
|
||||
this.$widget.find('ul').html('')
|
||||
this.log('result', term, result)
|
||||
|
||||
var elements = $()
|
||||
|
||||
for (var i = 0; i < result.length; i++) {
|
||||
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)
|
||||
var term = this.buffer.substr(trigger.trigger.length, this.buffer.length)
|
||||
trigger.renderResults(this, term)
|
||||
}
|
||||
|
||||
this.$widget.find('ul').append(elements).scrollTop(9999)
|
||||
Plugin.prototype.emptyResultsContainer = function() {
|
||||
this.$widget.find('ul').empty()
|
||||
}
|
||||
|
||||
Plugin.prototype.appendResults = function(collection) {
|
||||
this.$widget.find('ul').append(collection).scrollTop(9999)
|
||||
this.afterResultRendering()
|
||||
}
|
||||
|
||||
Plugin.prototype.afterResultRendering = function() {
|
||||
// keep the width of the dropdown the same even when longer items got filtered out
|
||||
if(this._fixedWidth && this.untouched){
|
||||
this.$widget.find('ul').css('width', this.$widget.find('ul').width());
|
||||
this.untouched = false;
|
||||
if(this._fixedWidth){
|
||||
var elem = this.$widget.find('ul')
|
||||
|
||||
var currentMinWidth = parseInt(elem.css('min-width'))
|
||||
var realWidth = elem.width()
|
||||
|
||||
if(!currentMinWidth || realWidth > currentMinWidth) {
|
||||
elem.css('min-width', realWidth + 'px')
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
|
@ -83,8 +83,8 @@
|
|||
.show()
|
||||
.scrollTop(0)
|
||||
|
||||
if (that.options.backdrop) that.adjustBackdrop()
|
||||
that.adjustDialog()
|
||||
if (that.options.backdrop) that.adjustBackdrop()
|
||||
|
||||
if (transition) {
|
||||
that.$element[0].offsetWidth // force reflow
|
||||
|
@ -241,8 +241,8 @@
|
|||
// these following methods are used to handle overflowing modals
|
||||
|
||||
Modal.prototype.handleUpdate = function () {
|
||||
if (this.options.backdrop) this.adjustBackdrop()
|
||||
this.adjustDialog()
|
||||
if (this.options.backdrop) this.adjustBackdrop()
|
||||
}
|
||||
|
||||
Modal.prototype.adjustBackdrop = function () {
|
||||
|
|
|
@ -33,12 +33,14 @@
|
|||
animation: true,
|
||||
placement: 'top',
|
||||
selector: false,
|
||||
backdrop: false,
|
||||
template: '<div class="tooltip" role="tooltip"><div class="tooltip-arrow"></div><div class="tooltip-inner"></div></div>',
|
||||
trigger: 'hover focus',
|
||||
title: '',
|
||||
delay: 0,
|
||||
html: false,
|
||||
container: false,
|
||||
theme: 'light',
|
||||
viewport: {
|
||||
selector: 'body',
|
||||
padding: 0
|
||||
|
@ -161,9 +163,12 @@
|
|||
var tipId = this.getUID(this.type)
|
||||
|
||||
this.setContent()
|
||||
$tip.attr('id', tipId)
|
||||
$tip.attr('id', tipId).attr('data-theme', this.options.theme)
|
||||
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')
|
||||
|
||||
var placement = typeof this.options.placement == 'function' ?
|
||||
|
@ -210,6 +215,8 @@
|
|||
var prevHoverState = that.hoverState
|
||||
that.$element.trigger('shown.bs.' + that.type)
|
||||
that.hoverState = null
|
||||
if(that.options.backdrop)
|
||||
$(document).one('click.bs.tooltip', function(){ that.hide() })
|
||||
|
||||
if (prevHoverState == 'out') that.leave(that)
|
||||
}
|
||||
|
@ -436,6 +443,9 @@
|
|||
clearTimeout(this.timeout)
|
||||
this.hide(function () {
|
||||
that.$element.off('.' + that.type).removeData('bs.' + that.type)
|
||||
|
||||
if(that.options.backdrop)
|
||||
that.$tip.off('click.bs.tooltip.stopPropagation')
|
||||
})
|
||||
}
|
||||
|
||||
|
|
1518
app/assets/javascripts/app/lib/core/jquery-ui-1.11.4.js
vendored
1518
app/assets/javascripts/app/lib/core/jquery-ui-1.11.4.js
vendored
File diff suppressed because it is too large
Load diff
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
230
app/assets/javascripts/app/models/knowledge_base.coffee
Normal file
230
app/assets/javascripts/app/models/knowledge_base.coffee
Normal 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
|
||||
}
|
||||
]
|
|
@ -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)
|
|
@ -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
|
|
@ -0,0 +1,4 @@
|
|||
class App.KnowledgeBaseAnswerTranslationState extends App.Model
|
||||
@configure 'KnowledgeBaseAnswerTranslationState', 'name'
|
||||
@extend Spine.Model.Ajax
|
||||
@url: @apiPath + '/knowledge_base/answer/translation/states'
|
168
app/assets/javascripts/app/models/knowledge_base_category.coffee
Normal file
168
app/assets/javascripts/app/models/knowledge_base_category.coffee
Normal 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'
|
|
@ -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)
|
|
@ -0,0 +1,4 @@
|
|||
class App.KnowledgeBaseLayout extends App.Model
|
||||
@configure 'KnowledgeBaseLayout', 'name'
|
||||
@extend Spine.Model.Ajax
|
||||
@url: @apiPath + '/knowledge_base/layouts'
|
|
@ -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()
|
|
@ -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
|
|
@ -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)
|
|
@ -7,6 +7,10 @@
|
|||
<span class="tab-badge"><%= item.count %></span>
|
||||
</a>
|
||||
<% 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">
|
||||
<% for item in @items: %>
|
||||
<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>
|
||||
<% end %>
|
||||
</ul>
|
||||
<div class="tab tab-dropdown js-toggle" data-toggle="dropdown">
|
||||
<%- @Icon('dropdown-list') %>
|
||||
<%- @Icon('arrow-down', 'arrow') %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
31
app/assets/javascripts/app/views/generic/attachments.jst.eco
Normal file
31
app/assets/javascripts/app/views/generic/attachments.jst.eco
Normal 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
Loading…
Reference in a new issue