Browse Source

Fixes #4886 - Checklists / To-do lists.

Co-authored-by: Benjamin Scharf <bs@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Florian Liebe <fl@zammad.com>
Co-authored-by: Mantas Masalskis <mm@zammad.com>
Co-authored-by: Rolf Schmidt <rolf.schmidt@zammad.com>
Co-authored-by: Tobias Schäfer <ts@zammad.com>
Florian Liebe 7 months ago
parent
commit
685a980a7a

+ 2 - 2
app/assets/javascripts/app/controllers/_application_controller/_base.coffee

@@ -369,8 +369,8 @@ class App.Controller extends Spine.Controller
     e.stopPropagation()
 
   preventDefaultAndStopPropagation: (e) ->
-    e.preventDefault()
-    e.stopPropagation()
+    e?.preventDefault()
+    e?.stopPropagation()
 
   startLoading: (el) =>
     return if @initLoadingDone && !el

+ 1 - 1
app/assets/javascripts/app/controllers/_application_controller/_modal_generic_edit.coffee

@@ -38,7 +38,7 @@ class App.ControllerGenericEdit extends App.ControllerModal
     )
 
     if @validateOnSubmit
-      errors = Object.assign({}, errors, @validateOnSubmit(params))
+      errors = _.extend({}, errors, @validateOnSubmit(params))
 
     if !_.isEmpty(errors)
       @log 'error', errors

+ 1 - 1
app/assets/javascripts/app/controllers/_application_controller/_modal_generic_new.coffee

@@ -39,7 +39,7 @@ class App.ControllerGenericNew extends App.ControllerModal
     )
 
     if @validateOnSubmit
-      errors = Object.assign({}, errors, @validateOnSubmit(params))
+      errors = _.extend({}, errors, @validateOnSubmit(params))
 
     if !_.isEmpty(errors)
       @log 'error', errors

+ 58 - 0
app/assets/javascripts/app/controllers/_ui_element/checklist_item.coffee

@@ -0,0 +1,58 @@
+# coffeelint: disable=camel_case_classes
+class App.UiElement.checklist_item extends App.UiElement.ApplicationUiElement
+  @render: (attributeConfig, params, form = {}) ->
+    attribute = $.extend(true, {}, attributeConfig)
+
+    attribute.items = []
+    if params && params.sorted_items
+      attribute.items = params.sorted_items() || []
+
+    item = $( App.view('generic/checklist_item')(attribute: attribute) )
+
+    dndOptions =
+      tolerance:            'pointer'
+      distance:             15
+      opacity:              0.6
+      forcePlaceholderSize: true
+      items:                'div.checklist-item'
+
+    item.find('div.checklist-item-container').sortable(dndOptions)
+
+    item.off('keydown', '.checklist-item-add-item-text').on('keydown', '.checklist-item-add-item-text', (e) =>
+      return if e.key isnt 'Enter'
+      @handleItemAdd(e)
+    )
+
+    item.off('click', '.checklist-item-add-button').on('click', '.checklist-item-add-button', (e) => @handleItemAdd(e))
+    item.off('click', '.checklist-item-remove-button').on('click', '.checklist-item-remove-button', (e) => @handleItemRemove(e))
+
+    item
+
+  @handleItemRemove: (e) ->
+    e.preventDefault()
+    e.stopPropagation()
+
+    $(e.target).closest('.checklist-item').remove()
+
+  @handleItemAdd: (e) ->
+    e.preventDefault()
+    e.stopPropagation()
+
+    current_item = $(e.target).closest('.checklist-item-add-container').find('.checklist-item-add-item-text')
+
+    if current_item.val().length == 0
+      return false
+
+    new_item = $('.checklist-item-template').clone()
+    new_item.find('input.checklist-item-text').val(current_item.val()).prop('required', true)
+
+    new_item
+      .appendTo('.checklist-item-container')
+      .removeClass('checklist-item-template')
+      .removeClass('hidden')
+
+    current_item.val('')
+    current_item.focus()
+
+    # Clear validation errors.
+    current_item.closest('.has-error').removeClass('has-error').find('.help-inline').html('')

+ 99 - 0
app/assets/javascripts/app/controllers/checklist.coffee

@@ -0,0 +1,99 @@
+class Checklist extends App.ControllerSubContent
+  @requiredPermission: 'admin.checklist'
+  header: __('Checklists')
+  events:
+    'change .js-checklistSetting input':  'toggleChecklistSetting'
+    'click .js-description':              'description'
+    'click .js-checklistNew':             'newChecklistTemplate'
+
+  elements:
+    '.js-checklistSetting input': 'checklistSetting'
+    '#no-templates-hint':         'noTemplatesHint'
+    '.js-description':            'descriptionButton'
+
+  constructor: ->
+    super
+    App.ChecklistTemplate.fetchFull(
+      =>
+        App.Setting.fetchFull(
+          @render,
+          force: true,
+        )
+      force: true,
+    )
+    @templateSubscribeId = App.ChecklistTemplate.subscribe(@render)
+    @settingSubscribeId  = App.Setting.subscribe(@render)
+
+  release: =>
+    super
+    App.ChecklistTemplate.unsubscribe(@templateSubscribeId)
+    App.Setting.unsubscribe(@settingSubscribeId)
+
+  render: =>
+    @html App.view('checklist/index')()
+
+    templates = App.ChecklistTemplate.all()
+
+    if !templates || templates.length is 0
+      @noTemplatesHint.removeClass('hidden')
+      @descriptionButton.addClass('hidden')
+    else
+      @noTemplatesHint.addClass('hidden')
+      @descriptionButton.removeClass('hidden')
+
+      new App.ControllerTable({
+        el: @$('.js-checklistTemplatesTable')
+        model: App.ChecklistTemplate
+        objects: templates,
+        bindRow:
+          events:
+            'click': @editChecklistTemplate
+      })
+
+  newChecklistTemplate: (e) =>
+    e.preventDefault()
+
+    new App.ControllerGenericNew(
+      pageData:
+        title: @header
+        object: __('Checklist Template')
+        objects: __('Checklist Templates')
+      genericObject: 'ChecklistTemplate'
+      container:     @el
+      large:         true
+      validateOnSubmit: @validateOnSubmit
+    )
+
+  editChecklistTemplate: (id, e) =>
+    e.preventDefault()
+
+    new App.ControllerGenericEdit(
+      id: id
+      pageData:
+        title: @header
+        object: __('Checklist Template')
+        objects: __('Checklist Templates')
+      genericObject: 'ChecklistTemplate'
+      container:     @el
+      large:         true
+      validateOnSubmit: @validateOnSubmit
+    )
+
+  validateOnSubmit: (params) ->
+    errors = {}
+    if !params.items || params.items.length is 0
+      errors['items'] = __('Please add at least one item to the checklist.')
+
+    errors
+
+  toggleChecklistSetting: (e) =>
+    value = @checklistSetting.prop('checked')
+    App.Setting.set('checklist', value)
+
+  description: (e) =>
+    new App.ControllerGenericDescription(
+      description: App.ChecklistTemplate.description
+      container:   @el
+    )
+
+App.Config.set('Checklists', { prio: 2340, name: __('Checklists'), parent: '#manage', target: '#manage/checklists', controller: Checklist, permission: ['admin.checklist'] }, 'NavBarAdmin')

+ 5 - 0
app/assets/javascripts/app/controllers/ticket_zoom.coffee

@@ -66,6 +66,11 @@ class App.TicketZoom extends App.Controller
       return if !@sidebarWidget
       @sidebarWidget.render(@formCurrent())
     )
+    @controllerBind('config_update', (data) =>
+      return if data.name isnt 'checklist'
+      @renderDone = false
+      @render()
+    )
 
   fetchMayBe: (data) =>
     return if @ticketUpdatedAtLastCall && new Date(data.updated_at).getTime() <= new Date(@ticketUpdatedAtLastCall).getTime()

+ 130 - 0
app/assets/javascripts/app/controllers/ticket_zoom/sidebar_checklist.coffee

@@ -0,0 +1,130 @@
+class SidebarChecklist extends App.Controller
+  constructor: ->
+    super
+
+    @changeable = @ticket.userGroupAccess('change')
+
+  release: =>
+    super
+    @clearWidget()
+
+  sidebarActions: =>
+    result = []
+
+    if @checklist and @changeable
+      result.push({
+        title:    __('Rename checklist')
+        name:     'checklist-rename'
+        callback: @renameChecklist
+      },
+      {
+        title:    __('Remove checklist')
+        name:     'checklist-remove'
+        callback: @removeChecklist
+      })
+
+    result
+
+  renameChecklist: =>
+    @widget?.onTitleChange()
+
+  removeChecklist: =>
+    new ChecklistRemoveModal(
+      container: @elSidebar.closest('.content')
+      callback:  =>
+        @ajax(
+          id:   'checklist_ticket_remove_checklist'
+          type: 'DELETE'
+          url:  "#{@apiPath}/tickets/#{@ticket.id}/checklist"
+          processData: true
+          success: (data, status, xhr) =>
+            @clearWidget()
+
+            @widget = new App.SidebarChecklistStart(el: @elSidebar, parentVC: @)
+        )
+    )
+
+  renderActions: =>
+    @parentSidebar.sidebarActionsRender('checklist', @sidebarActions())
+
+  sidebarItem: =>
+    return if !App.Config.get('checklist')
+    return if @ticket.currentView() != 'agent'
+
+    @item = {
+      name: 'checklist'
+      badgeIcon: 'checklist'
+      sidebarHead: __('Checklist')
+      sidebarCallback: @showChecklist
+      sidebarActions: @sidebarActions()
+    }
+
+  showChecklist: (el) =>
+    @elSidebar = el
+
+    @startLoading()
+
+  delayedShown: =>
+    @delay(@shown, 250, 'sidebar-checklist')
+
+  shown: =>
+    @startLoading()
+
+    @ajax(
+      id:   'checklist_ticket'
+      type: 'GET'
+      url:  "#{@apiPath}/tickets/#{@ticket.id}/checklist"
+      processData: true
+      success: (data, status, xhr) =>
+        @clearWidget()
+        @stopLoading()
+
+        if data.id
+          App.Collection.loadAssets(data.assets)
+
+          @checklist = App.Checklist.find(data.id)
+
+          @widget = new App.SidebarChecklistShow(el: @elSidebar, parentVC: @, checklist: @checklist, readOnly: !@changeable, enterEditMode: false)
+        else
+          @widget = new App.SidebarChecklistStart(el: @elSidebar, parentVC: @, readOnly: !@changeable)
+
+        @renderActions()
+    )
+
+    return if @subcribed
+    @subcribed = true
+    @controllerBind('Checklist:destroy', (data) =>
+      return if @ticket_id && @ticket_id isnt data.ticket_id
+      @delayedShown()
+    )
+    @controllerBind('Checklist:create Checklist:update Checklist:touch ChecklistItem:create ChecklistItem:update ChecklistItem:touch ChecklistItem:destroy', (data) =>
+      return if @ticket_id && @ticket_id isnt data.ticket_id
+      return if data.updated_by_id is App.Session.get().id
+      return if @widget?.itemEditInProgress
+      return if @widget?.titleChangeInProgress
+      @delayedShown()
+    )
+
+  clearWidget: =>
+    @widget?.el.empty()
+    @widget?.releaseController()
+
+    @checklist = undefined
+    @renderActions()
+
+  switchToChecklist: (id, enterEditMode = false) =>
+    @clearWidget()
+
+    @checklist = App.Checklist.find(id)
+
+    @renderActions()
+
+    @widget = new App.SidebarChecklistShow(el: @elSidebar, parentVC: @, checklist: @checklist, enterEditMode: enterEditMode)
+
+class ChecklistRemoveModal extends App.ControllerGenericDestroyConfirm
+  onSubmit: =>
+    @close()
+    @callback()
+
+
+App.Config.set('600-Checklist', SidebarChecklist, 'TicketZoomSidebar')

+ 419 - 0
app/assets/javascripts/app/controllers/ticket_zoom/sidebar_checklist_show.coffee

@@ -0,0 +1,419 @@
+class App.SidebarChecklistShow extends App.Controller
+  events:
+    'click .js-reorder':              'onReorder'
+    'click .js-add':                  'onAdd'
+    'click .js-save-order':           'onSaveOrder'
+    'click .js-reset-order':          'onResetOrder'
+    'click .js-action':               'onAction'
+    'change .js-checkbox':            'onCheckboxClick'
+    'click .js-title':                'onTitleChange'
+    'click .js-checklist-item-edit':  'onActionButtonClicked'
+
+  elements:
+    '.js-reorder':      'reorderButton'
+    '.js-add':          'addButton'
+    '.js-save-order':   'saveOrderButton'
+    '.js-reset-order':  'resetOrderButton'
+    'table':            'table'
+
+  constructor: ->
+    super
+
+    @objects = @checklist.sorted_items()
+    @render()
+
+  render: ->
+    @html App.view('ticket_zoom/sidebar_checklist_show')(
+      checklistTitle: @checklistTitle()
+      readOnly: @readOnly
+    )
+
+    @el.parent().off('click').on('click', (e) =>
+      if @itemEditInProgress
+        @clearEditWidget()
+        @itemEditInProgress = false
+
+      if @titleChangeInProgress
+        @clearRenameWidget()
+        @titleChangeInProgress = false
+    )
+
+    @renderTable()
+
+    if @enterEditMode
+      cell = @table.find('tr:last-of-type').find('.checklistItemValue')[0]
+      row  = $(cell).closest('tr')
+
+      @activateItemEditMode(cell, row, row.data('id'))
+
+  checklistTitle: =>
+    @checklist.name || App.i18n.translateInline('%s Checklist', App.Config.get('ticket_hook') + @parentVC.ticket.number)
+
+  onReorder: (e) =>
+    @clearEditWidget()
+    @toggleReorder(true)
+
+  onAdd: (e) =>
+    @addButton.attr('disabled', true)
+    @itemEditInProgress = true
+
+    @ajax(
+      id:   'checklist_item_create'
+      type: 'POST'
+      url:  "#{@apiPath}/tickets/#{@parentVC.ticket.id}/checklist/items"
+      data: JSON.stringify({text: ''})
+      processData: true
+      success: (data, status, xhr) =>
+        App.Collection.loadAssets(data.assets)
+        @objects = @checklist.sorted_items()
+
+        @clearEditWidget()
+        @renderTable()
+        cell = @table.find('tr:last-of-type').find('.checklistItemValue')[0]
+        row  = $(cell).closest('tr')
+
+        @activateItemEditMode(cell, row, data.id)
+
+        @addButton.attr('disabled', false)
+      error: =>
+        @addButton.attr('disabled', false)
+    )
+
+  onCheckboxClick: (e) =>
+    @clearEditWidget()
+    upcomingState = e.currentTarget.checked
+    id = parseInt(e.currentTarget.value)
+
+    e.currentTarget.disabled = true
+
+    @updateChecklistItem(id, upcomingState, e.currentTarget)
+
+  onCheckOrUncheck: (id, e) =>
+    row  = $(e.currentTarget).closest('tr')
+    checkbox = row.find('.js-checkbox')[0]
+
+    @clearEditWidget()
+    upcomingState = !checkbox.checked
+
+    checkbox.disabled = true
+
+    @updateChecklistItem(id, upcomingState, checkbox)
+
+  updateChecklistItem: (id, upcomingState, checkboxElem) =>
+    @ajax(
+      id:   'checklist_item_update_checked'
+      type: 'PATCH'
+      url:  "#{@apiPath}/tickets/#{@parentVC.ticket.id}/checklist/items/#{id}"
+      data: JSON.stringify({checked: upcomingState})
+      success: (data, status, xhr) =>
+        object = _.find @objects, (elem) -> elem.id == id
+
+        object.load(checked: upcomingState)
+
+        checkboxElem.disabled = false
+      error: ->
+        checkboxElem.checked = !upcomingState
+        checkboxElem.disabled = false
+    )
+
+  onSaveOrder: (e) =>
+    @saveOrderButton.attr('disabled', true)
+
+    sorted_item_ids = @table.find('tbody tr').toArray().map (elem) -> elem.dataset.id
+
+    @ajax(
+      id:   'checklist_update'
+      type: 'PATCH'
+      url:  "#{@apiPath}/tickets/#{@parentVC.ticket.id}/checklist"
+      data: JSON.stringify({sorted_item_ids: sorted_item_ids})
+      processData: true
+      success: (data, status, xhr) =>
+        App.Collection.loadAssets(data.assets)
+        @toggleReorder(false)
+        @saveOrderButton.attr('disabled', false)
+      error: =>
+        @saveOrderButton.attr('disabled', false)
+    )
+
+  onResetOrder: (e) =>
+    @toggleReorder(false, 'cancel')
+
+  onAction: (e) =>
+    e.stopPropagation()
+
+    if @itemEditInProgress
+      @clearEditWidget()
+      @itemEditInProgress = false
+
+    if @titleChangeInProgress
+      @clearRenameWidget()
+      @titleChangeInProgress = false
+
+    dropdown = $(e.currentTarget).closest('td').find('.js-table-action-menu')
+    dropdown.dropdown('toggle')
+    dropdown.on('click.dropdown', '[data-table-action]', @onActionButtonClicked)
+
+  onTitleChange: (e) =>
+    e?.stopPropagation()
+
+    # Close any open dropdowns
+    @el.find('.dropdown--actions.open').dropdown('toggle')
+
+    if @itemEditInProgress
+      @clearEditWidget()
+      @itemEditInProgress = false
+
+    return if @titleChangeInProgress
+
+    if e
+      elem = e.currentTarget
+    else
+      elem = @el.find('.js-title')[0]
+
+    @clearRenameWidget()
+    @renameWidget = new ChecklistRenameEdit(el: elem, parentVC: @, originalValue: @checklistTitle())
+
+  onActionButtonClicked: (e) =>
+    e?.stopPropagation()
+
+    id = $(e.currentTarget).parents('tr').data('id')
+    name = e.currentTarget.getAttribute('data-table-action')
+
+    if name is 'edit'
+
+      # skip on link openings
+      return if e.target.tagName is 'A' && $(e.target).parent().hasClass('checklistItemValue')
+      @onEditChecklistItem(id, e)
+
+    else if name is 'delete'
+      @onDeleteChecklistItem(id, e)
+
+    else if _.contains(['check', 'uncheck'], name)
+      @onCheckOrUncheck(id, e)
+
+  toggleReorder: (isReordering, disablingCommand = 'disable') =>
+    @table.find('tbody').sortable(if isReordering then 'enable' else disablingCommand)
+
+    @table.find('.draggable').toggleClass('hide', !isReordering)
+    @table.find('.checkbox-replacement').toggleClass('hide', isReordering)
+    @table.find('.dropdown').toggleClass('hide', isReordering)
+
+    @reorderButton.toggleClass('hide', isReordering)
+    @addButton.toggleClass('hide', isReordering)
+    @saveOrderButton.toggleClass('hide', !isReordering)
+    @resetOrderButton.toggleClass('hide', !isReordering)
+
+  onEditChecklistItem: (id, e) =>
+    @preventDefaultAndStopPropagation(e)
+
+    if @titleChangeInProgress
+      @clearRenameWidget()
+      @titleChangeInProgress = false
+
+    return if @itemEditInProgress && @itemEditInProgress == id
+
+    row  = $(e.currentTarget).closest('tr')
+    cell = row.find('.checklistItemValue')[0]
+
+    @clearEditWidget()
+
+    @activateItemEditMode(cell, row, id)
+
+  onDeleteChecklistItem: (id, e) =>
+    @preventDefaultAndStopPropagation(e)
+
+    row = $(e.currentTarget).closest('tr')
+
+    dropdown = $(e.currentTarget).closest('td').find('.js-table-action-menu')
+    dropdown.dropdown('toggle')
+
+    item = App.ChecklistItem.find(id)
+
+    deleteCallback = =>
+      @ajax(
+        id:   'checklist_item_delete'
+        type: 'DELETE'
+        url:  "#{@apiPath}/tickets/#{@parentVC.ticket.id}/checklist/items/#{id}"
+        processData: true
+        success: (data, status, xhr) =>
+          App.ChecklistItem.find(id).remove(clear: true)
+
+          @objects = @checklist.sorted_items()
+
+          @clearEditWidget()
+          @renderTable()
+      )
+
+    # Skip confirmation dialog if the item has no text.
+    if _.isEmpty(item.text)
+      deleteCallback()
+      return
+
+    new ChecklistItemRemoveModal(
+      item:      item
+      container: @el.closest('.content')
+      callback:  deleteCallback
+    )
+
+  activateItemEditMode: (cell, row, id) =>
+    $(cell).addClass('edit-widget-active')
+    $(row).find('.dropdown').addClass('hide')
+
+    @editWidget = new ChecklistItemEdit(
+      el: cell
+      parentVC: @
+      originalValue: cell.textContent.trim()
+      id: id
+      cancelCallback: ->
+        $(cell).removeClass('edit-widget-active')
+        $(row).find('.dropdown').removeClass('hide')
+
+        if $(row).find('.dropdown--actions').hasClass('open')
+          $(row).find('.js-table-action-menu').dropdown('toggle')
+    )
+
+  clearEditWidget: =>
+    @editWidget?.onCancel()
+    @editWidget = undefined
+
+  clearRenameWidget: =>
+    @renameWidget?.onCancel()
+    @renameWidget = undefined
+
+  renderTable: ->
+    @table.find('tbody').empty()
+
+    for object in @objects
+      html = App.view('ticket_zoom/sidebar_checklist_show_row')(
+        object: object
+        readOnly: @readOnly
+      )
+
+      @table.find('tbody').append(html)
+
+    if !@objects.length
+      html = App.view('ticket_zoom/sidebar_checklist_show_no_items')()
+      @table.find('tbody').append(html)
+
+    dndOptions =
+      tolerance:            'pointer'
+      distance:             15
+      opacity:              0.6
+      forcePlaceholderSize: true
+      items:                'tr'
+    @table.find('tbody').sortable(dndOptions)
+    @table.find('tbody').sortable('disable')
+
+    @reorderButton.toggleClass('hide', !@objects.length || @objects.length < 2)
+
+class ChecklistItemEdit extends App.Controller
+  elements:
+    '.js-input': 'input'
+  events:
+    'click .js-cancel':             'onCancel'
+    'click .js-confirm':            'onConfirm'
+    'keyup #checklistItemEditText': 'onKeyUp'
+
+  constructor: ->
+    super
+
+    @parentVC.itemEditInProgress = @id
+
+    @render()
+
+  render: =>
+    @html App.view('ticket_zoom/sidebar_checklist_item_edit')(value: @object()?.text)
+    @input.focus().val('').val(@object()?.text)
+
+  object: =>
+    _.find @parentVC.objects, (elem) => elem.id == @id
+
+  onCancel: (e) =>
+    @preventDefaultAndStopPropagation(e)
+
+    @release()
+    @el.html(App.Utils.linkify(@originalValue))
+    @parentVC.itemEditInProgress = null
+
+    @cancelCallback() if @cancelCallback
+
+  onConfirm: (e) =>
+    @preventDefaultAndStopPropagation(e)
+
+    @ajax(
+      id:   'checklist_item_update_text'
+      type: 'PATCH'
+      url:  "#{@apiPath}/tickets/#{@parentVC.parentVC.ticket.id}/checklist/items/#{@id}"
+      data: JSON.stringify({text: @input.val()})
+      processData: true
+      success: (data, status, xhr) =>
+        @object().load(text: @input.val())
+
+        @parentVC.clearEditWidget()
+        @parentVC.renderTable()
+        @parentVC.itemEditInProgress = null
+    )
+
+  onKeyUp: (e) =>
+    switch e.key
+      when 'Enter' then @onConfirm()
+      when 'Escape' then @onCancel()
+
+class ChecklistRenameEdit extends App.Controller
+  elements:
+    '.js-input': 'input'
+  events:
+    'click .js-cancel':               'onCancel'
+    'click .js-confirm':              'onConfirm'
+    'keyup #checklistTitleEditText':  'onKeyUp'
+
+  constructor: ->
+    super
+
+    @parentVC.titleChangeInProgress = true
+
+    @render()
+
+  render: =>
+    @html App.view('ticket_zoom/sidebar_checklist_title_edit')(
+      value: @object()?.name
+      ticketNumber: @parentVC.parentVC.ticket.number
+    )
+    @input.focus().val('').val(@object()?.name)
+
+  object: =>
+    @parentVC.checklist
+
+  onCancel: (e) =>
+    @preventDefaultAndStopPropagation(e)
+
+    @release()
+    @el.text(@originalValue)
+    @parentVC.titleChangeInProgress = false
+
+  onConfirm: (e) =>
+    @preventDefaultAndStopPropagation(e)
+
+    @ajax(
+      id:   'checklist_title_update'
+      type: 'PATCH'
+      url:  "#{@apiPath}/tickets/#{@parentVC.parentVC.ticket.id}/checklist"
+      data: JSON.stringify({name: @input.val()})
+      processData: true
+      success: (data, status, xhr) =>
+        @object().load(name: @input.val())
+
+        @parentVC.clearRenameWidget()
+        @parentVC.render()
+        @parentVC.titleChangeInProgress = false
+    )
+
+  onKeyUp: (e) =>
+    switch e.key
+      when 'Enter' then @onConfirm()
+      when 'Escape' then @onCancel()
+
+class ChecklistItemRemoveModal extends App.ControllerGenericDestroyConfirm
+  onSubmit: =>
+    @close()
+    @callback()

+ 78 - 0
app/assets/javascripts/app/controllers/ticket_zoom/sidebar_checklist_start.coffee

@@ -0,0 +1,78 @@
+class App.SidebarChecklistStart extends App.Controller
+  events:
+    'click .js-add-empty':                    'onAddEmpty'
+    'click .js-add-from-template':            'onAddFromTemplate'
+    'change [name="checklist_template_id"]':  'onTemplateChange'
+
+  constructor: ->
+    super
+    @subscribeId = App.ChecklistTemplate.subscribe(@render, initFetch: true)
+
+  release: =>
+    App.ChecklistTemplate.unsubscribe(@subscribeId)
+
+  render: =>
+    @configure_attributes = [
+      { name: 'checklist_template_id', display: __('Select Template'), tag: 'select', multiple: false, null: true, nulloption: true, relation: 'ChecklistTemplate', default: '' },
+    ]
+
+    @html App.view('ticket_zoom/sidebar_checklist_start')(
+      showManageLink: App.User.current()?.permission('admin.checklist')
+      readOnly: @readOnly
+      activeTemplateCount: App.ChecklistTemplate.search(filter: { active: true })?.length
+    )
+
+    @controller = new App.ControllerForm(
+      el:        @el.find('#form-checklist-template')
+      model:
+        configure_attributes: @configure_attributes
+      autofocus: false
+    )
+
+  onAddEmpty: (e) =>
+    @ajax(
+      id:   'checklist_ticket_add_empty'
+      type: 'POST'
+      url:  "#{@apiPath}/tickets/#{@parentVC.ticket.id}/checklist"
+      processData: true
+      success: (data, status, xhr) =>
+        App.Collection.loadAssets(data.assets)
+        @parentVC.switchToChecklist(data.id, true)
+    )
+
+  onAddFromTemplate: (e) =>
+    @preventDefaultAndStopPropagation(e)
+
+    params = @formParam(e.target)
+    if !params.checklist_template_id
+      @showTemplateFieldError()
+      return
+    else
+      @clearErrors()
+
+    @ajax(
+      id:   'checklist_ticket_add_from_template'
+      type: 'POST'
+      url:  "#{@apiPath}/tickets/#{@parentVC.ticket.id}/checklist"
+      data: JSON.stringify({ ticket_id: @parentVC.ticket.id, template_id: params.checklist_template_id })
+      success: (data, status, xhr) =>
+        App.Collection.loadAssets(data.assets)
+        @parentVC.switchToChecklist(data.id)
+    )
+
+  onTemplateChange: (e) =>
+    @preventDefaultAndStopPropagation(e)
+
+    params = @formParam(e.target)
+    return if !params.checklist_template_id
+
+    @clearErrors()
+
+  showTemplateFieldError: =>
+    templateEl = @el.find('[name="checklist_template_id"]').closest('.form-group')
+    templateEl.addClass('has-error')
+    templateEl.find('.help-inline').html(App.i18n.translatePlain('Please select a checklist template.'))
+
+  clearErrors: =>
+    @el.find('form').find('.has-error').removeClass('has-error')
+    @el.find('form').find('.help-inline').html('')

+ 20 - 8
app/assets/javascripts/app/controllers/widget/sidebar.coffee

@@ -11,6 +11,9 @@ class App.Sidebar extends App.Controller
   constructor: ->
     super
 
+    for item in @items
+      item.parentSidebar = @
+
     @render()
 
     # get active tab by name
@@ -55,15 +58,24 @@ class App.Sidebar extends App.Controller
       if item.sidebarCallback
         el = localEl.filter('.sidebar[data-tab="' + item.name + '"]')
         item.sidebarCallback(el.find('.sidebar-content'))
-        if !_.isEmpty(item.sidebarActions)
-          new ActionRow(
-            el:    el.find('.js-actions')
-            items: item.sidebarActions
-            type:  'small'
-          )
+        @sidebarActionsRender(item.name, item.sidebarActions, el.find('.js-actions'))
 
     @html(localEl)
 
+  sidebarActionsRender: (name, sidebarActions, el = undefined) =>
+    if !el
+      el = @el.find('.sidebar[data-tab="' + name + '"] .js-actions')
+
+    @actionsRows ||= {}
+    @actionsRows[name]?.releaseController()
+    return if _.isEmpty(sidebarActions)
+
+    @actionsRows[name] = new SidebarActionRow(
+      el:    el
+      items: sidebarActions
+      type:  'small'
+    )
+
   badgeRender: (el, item) =>
     @badgeEl = el
     @badgeRenderLocal(item)
@@ -140,7 +152,7 @@ class App.Sidebar extends App.Controller
     # show sidebar if not shown
     @showSidebar()
 
-class ActionRow extends App.Controller
+class SidebarActionRow extends App.Controller
   constructor: ->
     super
     @render()
@@ -153,7 +165,7 @@ class ActionRow extends App.Controller
 
     for item in @items
       do (item) =>
-        @$('[data-type="' + item.name + '"]').on(
+        @$('[data-type="' + item.name + '"]').off('click').on(
           'click'
           (e) ->
             e.preventDefault()

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