'))
+ @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)
diff --git a/app/assets/javascripts/app/controllers/knowledge_base/agent_controller.coffee b/app/assets/javascripts/app/controllers/knowledge_base/agent_controller.coffee
new file mode 100644
index 000000000..f42538f82
--- /dev/null
+++ b/app/assets/javascripts/app/controllers/knowledge_base/agent_controller.coffee
@@ -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: "
Open in primary locale"
+ 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')
diff --git a/app/assets/javascripts/app/controllers/knowledge_base/content_can_be_published_dialog.coffee b/app/assets/javascripts/app/controllers/knowledge_base/content_can_be_published_dialog.coffee
new file mode 100644
index 000000000..46ae121cb
--- /dev/null
+++ b/app/assets/javascripts/app/controllers/knowledge_base/content_can_be_published_dialog.coffee
@@ -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()
diff --git a/app/assets/javascripts/app/controllers/knowledge_base/content_can_be_published_form.coffee b/app/assets/javascripts/app/controllers/knowledge_base/content_can_be_published_form.coffee
new file mode 100644
index 000000000..ac20e4d11
--- /dev/null
+++ b/app/assets/javascripts/app/controllers/knowledge_base/content_can_be_published_form.coffee
@@ -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
+ ]
diff --git a/app/assets/javascripts/app/controllers/knowledge_base/content_controller.coffee b/app/assets/javascripts/app/controllers/knowledge_base/content_controller.coffee
new file mode 100644
index 000000000..9977e77ae
--- /dev/null
+++ b/app/assets/javascripts/app/controllers/knowledge_base/content_controller.coffee
@@ -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()
diff --git a/app/assets/javascripts/app/controllers/knowledge_base/delete_action.coffee b/app/assets/javascripts/app/controllers/knowledge_base/delete_action.coffee
new file mode 100644
index 000000000..417bc6211
--- /dev/null
+++ b/app/assets/javascripts/app/controllers/knowledge_base/delete_action.coffee
@@ -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()
diff --git a/app/assets/javascripts/app/controllers/knowledge_base/editor_coordinator.coffee b/app/assets/javascripts/app/controllers/knowledge_base/editor_coordinator.coffee
new file mode 100644
index 000000000..27299274f
--- /dev/null
+++ b/app/assets/javascripts/app/controllers/knowledge_base/editor_coordinator.coffee
@@ -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.')
+ )
diff --git a/app/assets/javascripts/app/controllers/knowledge_base/form_controller.coffee b/app/assets/javascripts/app/controllers/knowledge_base/form_controller.coffee
new file mode 100644
index 000000000..cc09b2b3b
--- /dev/null
+++ b/app/assets/javascripts/app/controllers/knowledge_base/form_controller.coffee
@@ -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 || $('
diff --git a/app/assets/javascripts/app/views/generic/attachments.jst.eco b/app/assets/javascripts/app/views/generic/attachments.jst.eco
new file mode 100644
index 000000000..b1cc3907a
--- /dev/null
+++ b/app/assets/javascripts/app/views/generic/attachments.jst.eco
@@ -0,0 +1,31 @@
+<% if !_.isEmpty(@attachments): %>
+