diff --git a/app/assets/javascripts/app/controllers/ticket_zoom.js.coffee b/app/assets/javascripts/app/controllers/ticket_zoom.js.coffee index 2d15927b5..becd1977f 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom.js.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom.js.coffee @@ -91,8 +91,7 @@ class App.TicketZoom extends App.Controller return if @activeState @activeState = true - # set see more options - @setSeeMore() + App.Event.trigger('ui::ticket::shown', { ticket_id: @ticket.id } ) App.OnlineNotification.seen( 'Ticket', @ticket_id ) @navupdate '#' @@ -258,33 +257,39 @@ class App.TicketZoom extends App.Controller isCustomer: @isRole('Customer') ) - new OverviewNavigator( + new App.TicketZoomOverviewNavigator( el: @$('.overview-navigator') ticket_id: @ticket.id overview_id: @overview_id ) - new TicketTitle( + new App.TicketZoomTitle( ticket: @ticket overview_id: @overview_id el: @el.find('.ticket-title') task_key: @task_key ) - new TicketMeta( + new App.TicketZoomMeta( ticket: @ticket el: @el.find('.ticket-meta') ) @form_id = App.ControllerForm.formId() - new ArticleNew( - ticket: @ticket - el: @el.find('.article-new') - form_meta: @form_meta - form_id: @form_id - defaults: @taskGet('article') - ui: @ + new App.TicketZoomArticleNew( + ticket: @ticket + el: @el.find('.article-new') + form_meta: @form_meta + form_id: @form_id + defaults: @taskGet('article') + ui: @ + ) + + @article_view = new App.TicketZoomArticleView( + ticket: @ticket + el: @el.find('.ticket-article') + ui: @ ) # rerender whole sidebar if customer or organization has changed @@ -294,7 +299,7 @@ class App.TicketZoom extends App.Controller user_id: @ticket.customer_id size: 50 ) - new TicketSidebar( + new App.TicketZoomSidebar( el: @el.find('.tabsSidebar') sidebarState: @sidebarState ticket: @ticket @@ -306,15 +311,16 @@ class App.TicketZoom extends App.Controller ) # show article - new ArticleView( - ticket: @ticket - ticket_article_ids: @ticket_article_ids - el: @el.find('.ticket-article') - ui: @ - ) + if !@article_view + @article_view = new App.TicketZoomArticleView( + ticket: @ticket + el: @el.find('.ticket-article') + ui: @ + ) - # set see more options - @setSeeMore() + @article_view.execute( + ticket_article_ids: @ticket_article_ids + ) # scroll to article if given if @article_id && document.getElementById( 'article-' + @article_id ) @@ -332,32 +338,9 @@ class App.TicketZoom extends App.Controller @ticketLastAttributes = @ticket.attributes() - # set see more options - setSeeMore: => - maxHeight = 560 - @$('.textBubble-content').each( (index) -> - bubble = $( @ ) - - # reset bubble heigth and "see more" opacity - bubble.css('height', '') - bubble.parent().find('.textBubble-overflowContainer').css('opacity', '') - - # remember offset of "see more" - offsetTop = bubble.find('.js-signatureMarker').position() - - # remember bubble heigth - heigth = bubble.height() - if offsetTop - bubble.attr('data-height', heigth) - bubble.css('height', "#{offsetTop.top + 30}px") - bubble.parent().find('.textBubble-overflowContainer').removeClass('hide') - else if heigth > maxHeight - bubble.attr('data-height', heigth) - bubble.css('height', "#{maxHeight}px") - bubble.parent().find('.textBubble-overflowContainer').removeClass('hide') - else - bubble.parent().find('.textBubble-overflowContainer').addClass('hide') - ) + # trigger shown + if @activeState + App.Event.trigger('ui::ticket::shown', { ticket_id: @ticket.id } ) scrollToBottom: => @main.scrollTop( @main.prop('scrollHeight') ) @@ -442,7 +425,6 @@ class App.TicketZoom extends App.Controller resetButton.removeClass('hide') - submit: (e) => e.stopPropagation() e.preventDefault() @@ -623,1254 +605,6 @@ class App.TicketZoom extends App.Controller article: {} App.TaskManager.update( @task_key, { 'state': @localTaskData }) -class TicketSidebar extends App.Controller - constructor: -> - super - ticket = App.Ticket.fullLocal( @ticket.id ) - @subscribeId = ticket.subscribe(@render) - @render(ticket) - - release: => - App.Ticket.unsubscribe( @subscribeId ) - - render: (ticket) => - - editTicket = (el) => - el.append('
') - @editEl = el - - show = (ticket) => - el.find('.edit').html('') - - defaults = ticket.attributes() - task_state = @taskGet('ticket') - modelDiff = App.Utils.formDiff( task_state, defaults ) - #if @isRole('Customer') - # delete defaults['state_id'] - # delete defaults['state'] - if !_.isEmpty( task_state ) - defaults = _.extend( defaults, task_state ) - - new App.ControllerForm( - el: el.find('.edit') - model: App.Ticket - screen: 'edit' - params: App.Ticket.find(ticket.id) - handlers: [ - @ticketFormChanges - ] - filter: @form_meta.filter - params: defaults - #bookmarkable: true - ) - #console.log('Ichanges', modelDiff, task_state, ticket.attributes()) - #@markFormDiff( modelDiff ) - - show(ticket) - @bind( - 'ui::ticket::taskReset' - (data) => - if data.ticket_id is ticket.id - show(ticket) - ) - - if !@isRole('Customer') - el.append('
') - new App.WidgetTag( - el: el.find('.tags') - object_type: 'Ticket' - object: ticket - tags: @tags - ) - el.append('') - new App.WidgetLink( - el: el.find('.links') - object_type: 'Ticket' - object: ticket - links: @links - container: @el.closest('.content') - ) - - showTicketHistory = => - new App.TicketHistory( - ticket_id: ticket.id - container: @el.closest('.content') - ) - showTicketMerge = => - new App.TicketMerge( - ticket: ticket - task_key: @task_key - container: @el.closest('.content') - ) - changeCustomer = (e, el) => - new App.TicketCustomer( - ticket: ticket - container: @el.closest('.content') - ) - @sidebarItems = [ - { - head: 'Ticket' - name: 'ticket' - icon: 'message' - callback: editTicket - } - ] - if !@isRole('Customer') - @sidebarItems[0]['actions'] = [ - { - name: 'ticket-history' - title: 'History' - callback: showTicketHistory - }, - { - name: 'ticket-merge' - title: 'Merge' - callback: showTicketMerge - }, - { - title: 'Change Customer' - name: 'customer-change' - callback: changeCustomer - }, - ] - if !@isRole('Customer') - editCustomer = (e, el) => - new App.ControllerGenericEdit( - id: ticket.customer_id - genericObject: 'User' - screen: 'edit' - pageData: - title: 'Users' - object: 'User' - objects: 'Users' - container: @el.closest('.content') - ) - showCustomer = (el) => - new App.WidgetUser( - el: el - user_id: ticket.customer_id - ) - @sidebarItems.push { - head: 'Customer' - name: 'customer' - icon: 'person' - actions: [ - { - title: 'Change Customer' - name: 'customer-change' - callback: changeCustomer - }, - { - title: 'Edit Customer' - name: 'customer-edit' - callback: editCustomer - }, - ] - callback: showCustomer - } - if ticket.organization_id - editOrganization = (e, el) => - new App.ControllerGenericEdit( - id: ticket.organization_id, - genericObject: 'Organization' - pageData: - title: 'Organizations' - object: 'Organization' - objects: 'Organizations' - container: @el.closest('.content') - ) - showOrganization = (el) => - new App.WidgetOrganization( - el: el - organization_id: ticket.organization_id - ) - @sidebarItems.push { - head: 'Organization' - name: 'organization' - icon: 'group' - actions: [ - { - title: 'Edit Organization' - name: 'organization-edit' - callback: editOrganization - }, - ] - callback: showOrganization - } - new App.Sidebar( - el: @el - sidebarState: @sidebarState - items: @sidebarItems - ) - -class TicketTitle extends App.Controller - events: - 'blur .ticket-title-update': 'update' - - constructor: -> - super - - @ticket = App.Ticket.fullLocal( @ticket.id ) - @subscribeId = @ticket.subscribe(@render) - @render(@ticket) - - render: (ticket) => - - # check if render is needed - if @lastTitle && @lastTitle is ticket.title - return - @lastTitle = ticket.title - - @html App.view('ticket_zoom/title')( - ticket: ticket - ) - - @$('.ticket-title-update').ce({ - mode: 'textonly' - multiline: false - maxlength: 250 - }) - - update: (e) => - title = $(e.target).ceg() || '' - - # update title - if title isnt @ticket.title - @ticket.title = title - - # reset article - should not be resubmited on next ticket update - @ticket.article = undefined - - @ticket.save() - - App.TaskManager.mute( @task_key ) - - # update taskbar with new meta data - App.Event.trigger 'task:render' - - release: => - App.Ticket.unsubscribe( @subscribeId ) - -class OverviewNavigator extends App.Controller - events: - 'click a': 'open' - - constructor: -> - super - - # rebuild overview navigator if overview has changed - @bind 'ticket_overview_rebuild', (data) => - execute = => - @render() - @delay(execute, 600, 'overview-navigator') - - @render() - - render: (overview) => - if !@overview_id - @html('') - return - - # get overview data - worker = App.TaskManager.worker( 'TicketOverview' ) - return if !worker - overview = worker.overview(@overview_id) - return if !overview - current_position = 0 - next = false - previous = false - for ticket_id in overview.ticket_ids - current_position += 1 - next = overview.ticket_ids[current_position] - previous = overview.ticket_ids[current_position-2] - break if ticket_id is @ticket_id - - # get next/previous ticket - if next - next = App.Ticket.find(next) - if previous - previous = App.Ticket.find(previous) - - @html App.view('ticket_zoom/overview_navigator')( - title: overview.overview.name - total_count: overview.tickets_count - current_position: current_position - next: next - previous: previous - ) - - open: (e) => - e.preventDefault() - - # get requested object and location - id = $(e.target).data('id') - url = $(e.target).attr('href') - if !id - id = $(e.target).closest('a').data('id') - url = $(e.target).closest('a').attr('href') - - # return if we are unable to get id - return if !id - - # open task via task manager to get overview information - App.TaskManager.execute( - key: 'Ticket-' + id - controller: 'TicketZoom' - params: - ticket_id: id - overview_id: @overview_id - show: true - ) - @navigate url - -class TicketMeta extends App.Controller - constructor: -> - super - - @ticket = App.Ticket.fullLocal( @ticket.id ) - @subscribeId = @ticket.subscribe(@render) - @render(@ticket) - - render: (ticket) => - @html App.view('ticket_zoom/meta')( - ticket: ticket - isCustomer: @isRole('Customer') - ) - - # show frontend times - @frontendTimeUpdate() - - release: => - App.Ticket.unsubscribe( @subscribeId ) - -class ArticleNew extends App.Controller - elements: - '.js-textarea': 'textarea' - '.attachmentPlaceholder': 'attachmentPlaceholder' - '.attachmentPlaceholder-inputHolder': 'attachmentInputHolder' - '.attachmentPlaceholder-hint': 'attachmentHint' - '.article-add': 'articleNewEdit' - '.attachments': 'attachmentsHolder' - '.attachmentUpload': 'attachmentUpload' - '.attachmentUpload-progressBar': 'progressBar' - '.js-percentage': 'progressText' - '.js-cancel': 'cancelContainer' - '.textBubble': 'textBubble' - '.editControls-item': 'editControlItem' - #'.editControls': 'editControls' - #'.recipient-picker': 'recipientPicker' - #'.recipient-list': 'recipientList' - #'.recipient-list .list-arrow': 'recipientListArrow' - - events: - 'click .js-toggleVisibility': 'toggleVisibility' - 'click .js-articleTypeItem': 'selectArticleType' - 'click .js-selectedArticleType': 'showSelectableArticleType' - 'click .recipient-picker': 'toggle_recipients' - 'click .recipient-list': 'stopPropagation' - 'click .list-entry-type div': 'change_type' - 'submit .recipient-list form': 'add_recipient' - 'focus .js-textarea': 'openTextarea' - 'input .js-textarea': 'detectEmptyTextarea' - #'dragenter': 'onDragenter' - #'dragleave': 'onDragleave' - #'drop': 'onFileDrop' - #'change input[type=file]': 'onFilePick' - - constructor: -> - super - - # gets referenced in @setArticleType - @type = @defaults['type'] || 'note' - @articleTypes = [ - { - name: 'note' - icon: 'note' - attributes: [] - }, - { - name: 'email' - icon: 'email' - attributes: ['to','cc'] - }, - { - name: 'facebook' - icon: 'facebook' - attributes: [] - }, - { - name: 'twitter' - icon: 'twitter' - attributes: [] - }, - { - name: 'phone' - icon: 'phone' - attributes: [] - }, - ] - if @isRole('Customer') - @type = 'note' - @articleTypes = [ - { - name: 'note' - icon: 'note' - attributes: [] - }, - ] - - @textareaHeight = - open: 148 - closed: 20 - - @dragEventCounter = 0 - @attachments = [] - - @render() - - if @defaults.body or @isIE10() - @openTextarea(null, true) - - # set article type and expand text area - @bind( - 'ui::ticket::setArticleType' - (data) => - if data.ticket.id is @ticket.id - #@setArticleType(data.type.name) - - @openTextarea(null, true) - for key, value of data.article - if key is 'body' - @$('[data-name="' + key + '"]').html(value) - else - @$('[name="' + key + '"]').val(value) - - # preselect article type - @setArticleType( 'email' ) - ) - - # reset new article screen - @bind( - 'ui::ticket::taskReset' - (data) => - if data.ticket_id is @ticket.id - @type = 'note' - @defaults = {} - @render() - ) - - isIE10: -> - Function('/*@cc_on return document.documentMode===10@*/')() - - stopPropagation: (e) -> - e.stopPropagation() - - release: => - if @subscribeIdTextModule - App.Ticket.unsubscribe(@subscribeIdTextModule) - - render: -> - - ticket = App.Ticket.fullLocal( @ticket.id ) - - @html App.view('ticket_zoom/article_new')( - ticket: ticket - articleTypes: @articleTypes - article: @defaults - isCustomer: @isRole('Customer') - ) - @setArticleType(@type) - - new App.WidgetAvatar( - el: @$('.js-avatar') - user_id: App.Session.get('id') - size: 40 - position: 'right' - class: 'zIndex-5' - ) - - configure_attributes = [ - { name: 'customer_id', display: 'Recipients', tag: 'user_autocompletion', null: false, placeholder: 'Enter Person or Organization/Company', minLengt: 2, disableCreateUser: false }, - ] - - controller = new App.ControllerForm( - el: @$('.recipients') - model: - configure_attributes: configure_attributes, - ) - - @$('[data-name="body"]').ce({ - mode: 'richtext' - multiline: true - maxlength: 5000 - }) - - html5Upload.initialize( - uploadUrl: App.Config.get('api_path') + '/ticket_attachment_upload', - dropContainer: @el.get(0), - cancelContainer: @cancelContainer, - inputField: @$('.article-attachment input').get(0), - key: 'File', - data: { form_id: @form_id }, - maxSimultaneousUploads: 1, - onFileAdded: (file) => - - file.on( - - onStart: => - @attachmentPlaceholder.addClass('hide') - @attachmentUpload.removeClass('hide') - @cancelContainer.removeClass('hide') - console.log('upload start') - - onAborted: => - @attachmentPlaceholder.removeClass('hide') - @attachmentUpload.addClass('hide') - - # Called after received response from the server - onCompleted: (response) => - - response = JSON.parse(response) - @attachments.push response.data - - @attachmentPlaceholder.removeClass('hide') - @attachmentUpload.addClass('hide') - - @renderAttachment(response.data) - console.log('upload complete', response.data ) - - # Called during upload progress, first parameter - # is decimal value from 0 to 100. - onProgress: (progress, fileSize, uploadedBytes) => - @progressBar.width(parseInt(progress) + "%") - @progressText.text(parseInt(progress)) - # hide cancel on 90% - if parseInt(progress) >= 90 - @cancelContainer.addClass('hide') - console.log('uploadProgress ', parseInt(progress)) - ) - ) - - # show text module UI - if !@isRole('Customer') - textModule = new App.WidgetTextModule( - el: @$('.js-textarea').parent() - data: - ticket: ticket - ) - callback = (ticket) => - textModule.reload( - ticket: ticket - ) - @subscribeIdTextModule = ticket.subscribe( callback ) - - toggle_recipients: => - if !@pickRecipientsCatcher - @show_recipients() - else - @hide_recipients() - - show_recipients: -> - padding = 15 - - @recipientPicker.addClass('is-open') - @recipientList.removeClass('hide') - - pickerDimensions = @recipientPicker.get(0).getBoundingClientRect() - availableHeight = @recipientPicker.scrollParent().outerHeight() - - top = pickerDimensions.height/2 - @recipientList.height()/2 - bottomDistance = availableHeight - padding - (pickerDimensions.top + top + @recipientList.height()) - - if bottomDistance < 0 - top += bottomDistance - - arrowCenter = -top + pickerDimensions.height/2 - - @recipientListArrow.css('top', arrowCenter) - @recipientList.css('top', top) - - $.Velocity.hook(@recipientList, 'transformOriginX', "0") - $.Velocity.hook(@recipientList, 'transformOriginY', "#{ arrowCenter }px") - - @recipientList.velocity - properties: - scale: [ 1, 0 ] - opacity: [ 1, 0 ] - options: - speed: 300 - easing: [ 0.34, 1.61, 0.7, 1 ] - - @pickRecipientsCatcher = new App.clickCatcher - holder: @el.offsetParent() - callback: @hide_recipients - zIndexScale: 6 - - hide_recipients: => - @pickRecipientsCatcher.remove() - @pickRecipientsCatcher = null - - @recipientPicker.removeClass('is-open') - - @recipientList.velocity - properties: - scale: [ 0, 1 ] - opacity: [ 0, 1 ] - options: - speed: 300 - easing: [ 500, 20 ] - complete: -> @recipientList.addClass('hide') - - change_type: (e) -> - $(e.target).addClass('active').siblings('.active').removeClass('active') - # store $(this).data('value') - - add_recipient: (e) -> - e.stopPropagation() - e.preventDefault() - console.log "add recipient", e - # store recipient - - toggleVisibility: -> - if @articleNewEdit.hasClass 'is-public' - @articleNewEdit - .removeClass 'is-public' - .addClass 'is-internal' - - @$('[name="internal"]').val 'true' - else - @articleNewEdit - .addClass 'is-public' - .removeClass 'is-internal' - - - @$('[name="internal"]').val '' - - showSelectableArticleType: => - @el.find('.js-articleTypes').removeClass('is-hidden') - - @selectTypeCatcher = new App.clickCatcher - holder: @el.offsetParent() - callback: @hideSelectableArticleType - zIndexScale: 6 - - selectArticleType: (e) => - articleTypeToSet = $(e.target).closest('.pop-selectable').data('value') - @setArticleType( articleTypeToSet ) - @hideSelectableArticleType() - - @selectTypeCatcher.remove() - @selectTypeCatcher = null - - hideSelectableArticleType: => - @el.find('.js-articleTypes').addClass('is-hidden') - - setArticleType: (type) -> - typeIcon = @$('.js-selectedType') - @type = type - @$('[name="type"]').val(type) - @articleNewEdit.attr('data-type', type) - typeIcon.find('use').attr 'xlink:href', '#icon-'+ @type - - # show/hide attributes - for articleType in @articleTypes - if articleType.name is type - @$('.form-group').addClass('hide') - for name in articleType.attributes - @$("[name=#{name}]").closest('.form-group').removeClass('hide') - - # check if signature need to be added - body = @$('[data-name="body"]').html() || '' - signature = undefined - if @ticket.group.signature_id - signature = App.Signature.find( @ticket.group.signature_id ) - if signature && signature.body && @type is 'email' - signatureFinished = App.Utils.text2html( - App.Utils.replaceTags( signature.body, { user: App.Session.get(), ticket: @ticket } ) - ) - if App.Utils.signatureCheck( body, signatureFinished ) - if !App.Utils.lastLineEmpty(body) - body = body + '
' - body = body + "
#{signatureFinished}
" - @$('[data-name="body"]').html(body) - - # remove old signature - else - @$('[data-name="body"]').find("[data-signature=true]").remove() - - detectEmptyTextarea: => - if !@textarea.text().trim() - @addTextareaCatcher() - else - @removeTextareaCatcher() - - openTextarea: (event, withoutAnimation) => - if !@articleNewEdit.hasClass('is-open') - duration = 300 - - if withoutAnimation - duration = 0 - - @articleNewEdit.addClass('is-open') - - @textarea.velocity - properties: - minHeight: "#{ @textareaHeight.open - 38 }px" - options: - duration: duration - easing: 'easeOutQuad' - complete: => @addTextareaCatcher() - - @textBubble.velocity - properties: - paddingBottom: 28 - options: - duration: duration - easing: 'easeOutQuad' - - # scroll to bottom - @textarea.velocity "scroll", - container: @textarea.scrollParent() - offset: 99999 - duration: 300 - easing: 'easeOutQuad' - queue: false - - @editControlItem - .removeClass('is-hidden') - .velocity - properties: - opacity: [ 1, 0 ] - translateX: [ 0, 20 ] - translateZ: 0 - options: - duration: 300 - stagger: 50 - drag: true - - # move attachment text to the left bottom (bottom happens automatically) - @attachmentPlaceholder.velocity - properties: - translateX: -@attachmentInputHolder.position().left + "px" - options: - duration: duration - easing: 'easeOutQuad' - - @attachmentHint.velocity - properties: - opacity: 0 - options: - duration: duration - - addTextareaCatcher: => - if @articleNewEdit.is(':visible') - @textareaCatcher = new App.clickCatcher - holder: @articleNewEdit.offsetParent() - callback: @closeTextarea - zIndexScale: 4 - - removeTextareaCatcher: -> - return if !@textareaCatcher - @textareaCatcher.remove() - @textareaCatcher = null - - closeTextarea: => - @removeTextareaCatcher() - if !@textarea.text().trim() && !@attachments.length && not @isIE10() - - @textarea.velocity - properties: - minHeight: "#{ @textareaHeight.closed }px" - options: - duration: 300 - easing: 'easeOutQuad' - complete: => @articleNewEdit.removeClass('is-open') - - @textBubble.velocity - properties: - paddingBottom: 10 - options: - duration: 300 - easing: 'easeOutQuad' - - @attachmentPlaceholder.velocity - properties: - translateX: 0 - options: - duration: 300 - easing: 'easeOutQuad' - - @attachmentHint.velocity - properties: - opacity: 1 - options: - duration: 300 - - @editControlItem - .velocity - properties: - opacity: [ 0, 1 ] - translateX: [ 20, 0 ] - translateZ: 0 - options: - duration: 100 - stagger: 50 - drag: true - complete: (elements) => $(elements).addClass('is-hidden') - - onDragenter: (event) => - # on the first event, - # open textarea (it will only open if its closed) - @openTextarea() if @dragEventCounter is 0 - - @dragEventCounter++ - @articleNewEdit.parent().addClass('is-dropTarget') - - onDragleave: (event) => - @dragEventCounter-- - - @articleNewEdit.parent().removeClass('is-dropTarget') if @dragEventCounter is 0 - - renderAttachment: (file) => - @attachmentsHolder.append App.view('generic/attachment_item') - fileName: file.filename - fileSize: @humanFileSize( file.size ) - store_id: file.store_id - @attachmentsHolder.on( - 'click' - "[data-id=#{file.store_id}]", (e) => - @attachments = _.filter( - @attachments, - (item) -> - return if item.id isnt file.store_id - item - ) - store_id = $(e.currentTarget).data('id') - - # delete attachment from storage - App.Ajax.request( - type: 'DELETE' - url: App.Config.get('api_path') + '/ticket_attachment_upload' - data: JSON.stringify( { store_id: store_id } ), - processData: false - success: (data, status, xhr) => - ) - - # remove attachment from dom - element = $(e.currentTarget).closest('.attachments') - $(e.currentTarget).closest('.attachment').remove() - # empty .attachment (remove spaces) to keep css working, thanks @mrflix :-o - if element.find('.attachment').length == 0 - element.empty() - ) - -class ArticleView extends App.Controller - constructor: -> - super - - for ticket_article_id in @ticket_article_ids - el = $('
') - new ArticleViewItem( - ticket: @ticket - ticket_article_id: ticket_article_id - el: el - ui: @ui - ) - @el.append( el ) - -class ArticleViewItem extends App.Controller - events: - 'click .show_toogle': 'show_toogle' - 'click .textBubble': 'toggle_meta_with_delay' - 'click .textBubble a': 'stopPropagation' - 'click .js-unfold': 'unfold' - - constructor: -> - super - @render() - - render: -> - - # get articles - @article = App.TicketArticle.fullLocal( @ticket_article_id ) - - # prepare html body - if @article.content_type is 'text/html' - @article['html'] = @article.body - else - @article['html'] = App.Utils.textCleanup( @article.body ) - @article['html'] = App.Utils.text2html( @article.body ) - - @html App.view('ticket_zoom/article_view')( - ticket: @ticket - article: @article - isCustomer: @isRole('Customer') - ) - - new App.WidgetAvatar( - el: @$('.js-avatar') - user_id: @article.created_by_id - size: 40 - ) - - new ArticleActions( - el: @$('.js-article-actions') - ticket: @ticket - article: @article - ) - - # show frontend times - @frontendTimeUpdate() - - show_toogle: (e) -> - e.stopPropagation() - e.preventDefault() - #$(e.target).hide() - if $(e.target).next('div')[0] - if $(e.target).next('div').hasClass('hide') - $(e.target).next('div').removeClass('hide') - $(e.target).text( App.i18n.translateContent('Fold in') ) - else - $(e.target).text( App.i18n.translateContent('See more') ) - $(e.target).next('div').addClass('hide') - - stopPropagation: (e) -> - e.stopPropagation() - - toggle_meta_with_delay: (e) => - # allow double click select - # by adding a delay to the toggle - - if @lastClick and +new Date - @lastClick < 150 - clearTimeout(@toggleMetaTimeout) - else - @toggleMetaTimeout = setTimeout(@toggle_meta, 150, e) - @lastClick = +new Date - - toggle_meta: (e) => - e.preventDefault() - - animSpeed = 300 - article = $(e.target).closest('.ticket-article-item') - metaTopClip = article.find('.article-meta-clip.top') - metaBottomClip = article.find('.article-meta-clip.bottom') - metaTop = article.find('.article-content-meta.top') - metaBottom = article.find('.article-content-meta.bottom') - - if @elementContainsSelection( article.get(0) ) - @stopPropagation(e) - return false - - if !metaTop.hasClass('hide') - article.removeClass('state--folde-out') - - # scroll back up - article.velocity "scroll", - container: article.scrollParent() - offset: -article.offset().top - metaTop.outerHeight() - duration: animSpeed - easing: 'easeOutQuad' - - metaTop.velocity - properties: - translateY: 0 - opacity: [ 0, 1 ] - options: - speed: animSpeed - easing: 'easeOutQuad' - complete: -> metaTop.addClass('hide') - - metaBottom.velocity - properties: - translateY: [ -metaBottom.outerHeight(), 0 ] - opacity: [ 0, 1 ] - options: - speed: animSpeed - easing: 'easeOutQuad' - complete: -> metaBottom.addClass('hide') - - metaTopClip.velocity({ height: 0 }, animSpeed, 'easeOutQuad') - metaBottomClip.velocity({ height: 0 }, animSpeed, 'easeOutQuad') - else - article.addClass('state--folde-out') - metaBottom.removeClass('hide') - metaTop.removeClass('hide') - - # balance out the top meta height by scrolling down - article.velocity("scroll", - container: article.scrollParent() - offset: -article.offset().top + metaTop.outerHeight() - duration: animSpeed - easing: 'easeOutQuad' - ) - - metaTop.velocity - properties: - translateY: [ 0, metaTop.outerHeight() ] - opacity: [ 1, 0 ] - options: - speed: animSpeed - easing: 'easeOutQuad' - - metaBottom.velocity - properties: - translateY: [ 0, -metaBottom.outerHeight() ] - opacity: [ 1, 0 ] - options: - speed: animSpeed - easing: 'easeOutQuad' - - metaTopClip.velocity({ height: metaTop.outerHeight() }, animSpeed, 'easeOutQuad') - metaBottomClip.velocity({ height: metaBottom.outerHeight() }, animSpeed, 'easeOutQuad') - - unfold: (e) -> - e.preventDefault() - e.stopPropagation() - container = $(e.currentTarget).parents('.textBubble-content') - overflowContainer = container.find('.textBubble-overflowContainer') - - overflowContainer.velocity - properties: - opacity: 0 - options: - duration: 300 - - container.velocity - properties: - height: container.attr('data-height') - options: - duration: 300 - complete: -> overflowContainer.addClass('hide'); - - isOrContains: (node, container) -> - while node - if node is container - return true - node = node.parentNode - false - - elementContainsSelection: (el) -> - sel = window.getSelection() - if sel.rangeCount > 0 && sel.toString() - for i in [0..sel.rangeCount-1] - if !@isOrContains(sel.getRangeAt(i).commonAncestorContainer, el) - return false - return true - false - -class ArticleActions extends App.Controller - events: - 'click [data-type=public]': 'public_internal' - 'click [data-type=internal]': 'public_internal' - 'click [data-type=reply]': 'reply' - 'click [data-type=replyAll]': 'replyAll' - - constructor: -> - super - @render() - - render: -> - actions = @actionRow(@article) - - if actions - @html App.view('ticket_zoom/article_view_actions')( - article: @article - actions: actions - ) - else - @html '' - - public_internal: (e) -> - e.preventDefault() - article_id = $(e.target).parents('[data-id]').data('id') - - # storage update - article = App.TicketArticle.find(article_id) - internal = true - if article.internal == true - internal = false - article.updateAttributes( - internal: internal - ) - - # runntime update - if internal - $(e.target).closest('.ticket-article-item').addClass('is-internal') - else - $(e.target).closest('.ticket-article-item').removeClass('is-internal') - - @render() - - actionRow: (article) -> - if @isRole('Customer') - return [] - - actions = [] - if article.internal is true - actions = [ - { - name: 'set to public' - type: 'public' - icon: 'lock-open' - } - ] - else - actions = [ - { - name: 'set to internal' - type: 'internal' - icon: 'lock' - } - ] - #if @article.type.name is 'note' - # actions.push [] - if article.type.name is 'email' || article.type.name is 'phone' || article.type.name is 'web' - actions.push { - name: 'reply' - type: 'reply' - icon: 'reply' - href: '#' - } - recipients = [] - if article.sender.name is 'Agent' - if article.to - localRecipients = emailAddresses.parseAddressList(article.to) - if localRecipients - recipients = recipients.concat localRecipients - else - if article.from - localRecipients = emailAddresses.parseAddressList(article.from) - if localRecipients - recipients = recipients.concat localRecipients - if article.cc - localRecipients = emailAddresses.parseAddressList(article.cc) - if localRecipients - recipients = recipients.concat localRecipients - if recipients.length > 1 - actions.push { - name: 'reply all' - type: 'replyAll' - icon: 'reply-all' - href: '#' - } - actions.push { - name: 'split' - type: 'split' - icon: 'split' - href: '#ticket/create/' + article.ticket_id + '/' + article.id - } - actions - - replyAll: (e) => - @reply(e, true) - - reply: (e, all = false) => - e.preventDefault() - - # get reference article - article_id = $(e.target).parents('[data-id]').data('id') - article = App.TicketArticle.fullLocal( article_id ) - type = App.TicketArticleType.find( article.type_id ) - customer = App.User.find( article.created_by_id ) - - @el.closest('.article-add').ScrollTo() - - # empty form - articleNew = { - to: '' - cc: '' - body: '' - in_reply_to: '' - } - - #@el.closest('[name="in_reply_to"]').val('') - - if article.message_id - articleNew.in_reply_to = article.message_id - - if type.name is 'twitter status' - - # set to in body - to = customer.accounts['twitter'].username || customer.accounts['twitter'].uid - articleNew.body = '@' + to - - else if type.name is 'twitter direct-message' - - # show to - to = customer.accounts['twitter'].username || customer.accounts['twitter'].uid - articleNew.to = to - - else if type.name is 'email' || type.name is 'phone' || type.name is 'web' - - if article.sender.name is 'Agent' - articleNew.to = article.to - else - articleNew.to = article.from - - # if sender is customer but in article.from is no email, try to get - # customers email via customer user - if articleNew.to && !articleNew.to.match(/@/) - articleNew.to = article.created_by.email - - # filter for uniq recipients - recipientAddresses = {} - recipient = emailAddresses.parseAddressList(articleNew.to) - if recipient && recipient[0] - recipientAddresses[ recipient[0].address.toString().toLowerCase() ] = true - if all - addAddresses = (lineNew, addressLine) -> - localAddresses = App.EmailAddress.all() - recipients = emailAddresses.parseAddressList(addressLine) - if recipients - for recipient in recipients - if recipient.address - - # check if addess is not local - localAddess = false - for address in localAddresses - if recipient.address.toString().toLowerCase() == address.email.toString().toLowerCase() - localAddess = true - if !localAddess - - # filter for uniq recipients - if !recipientAddresses[ recipient.address.toString().toLowerCase() ] - recipientAddresses[ recipient.address.toString().toLowerCase() ] = true - - # add recipient - if lineNew - lineNew = lineNew + ', ' - lineNew = lineNew + recipient.address - lineNew - - if article.from - articleNew.cc = addAddresses(articleNew.cc, article.from) - if article.to - articleNew.cc = addAddresses(articleNew.cc, article.to) - if article.cc - articleNew.cc = addAddresses(articleNew.cc, article.cc) - - # get current body - body = @el.closest('[data-name="body"]').html() || '' - - # check if quote need to be added - selectedText = App.ClipBoard.getSelected() - if selectedText - - # clean selection - selectedText = App.Utils.textCleanup( selectedText ) - - # convert to html - selectedText = App.Utils.text2html( selectedText ) - if selectedText - selectedText = "


#{selectedText}

" - - # add selected text to body - body = selectedText + body - - articleNew.body = body - - App.Event.trigger('ui::ticket::setArticleType', { ticket: @ticket, type: type, article: articleNew } ) - class TicketZoomRouter extends App.ControllerPermanent constructor: (params) -> super diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/article_actions.js.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/article_actions.js.coffee new file mode 100644 index 000000000..79f730ce5 --- /dev/null +++ b/app/assets/javascripts/app/controllers/ticket_zoom/article_actions.js.coffee @@ -0,0 +1,212 @@ +class App.TicketZoomArticleActions extends App.Controller + events: + 'click [data-type=public]': 'public_internal' + 'click [data-type=internal]': 'public_internal' + 'click [data-type=reply]': 'reply' + 'click [data-type=replyAll]': 'replyAll' + + constructor: -> + super + @render() + + render: -> + actions = @actionRow(@article) + + if actions + @html App.view('ticket_zoom/article_view_actions')( + article: @article + actions: actions + ) + else + @html '' + + public_internal: (e) -> + e.preventDefault() + article_id = $(e.target).parents('[data-id]').data('id') + + # storage update + article = App.TicketArticle.find(article_id) + internal = true + if article.internal == true + internal = false + article.updateAttributes( + internal: internal + ) + + # runntime update + if internal + $(e.target).closest('.ticket-article-item').addClass('is-internal') + else + $(e.target).closest('.ticket-article-item').removeClass('is-internal') + + @render() + + actionRow: (article) -> + if @isRole('Customer') + return [] + + actions = [] + if article.internal is true + actions = [ + { + name: 'set to public' + type: 'public' + icon: 'lock-open' + } + ] + else + actions = [ + { + name: 'set to internal' + type: 'internal' + icon: 'lock' + } + ] + #if @article.type.name is 'note' + # actions.push [] + if article.type.name is 'email' || article.type.name is 'phone' || article.type.name is 'web' + actions.push { + name: 'reply' + type: 'reply' + icon: 'reply' + href: '#' + } + recipients = [] + if article.sender.name is 'Agent' + if article.to + localRecipients = emailAddresses.parseAddressList(article.to) + if localRecipients + recipients = recipients.concat localRecipients + else + if article.from + localRecipients = emailAddresses.parseAddressList(article.from) + if localRecipients + recipients = recipients.concat localRecipients + if article.cc + localRecipients = emailAddresses.parseAddressList(article.cc) + if localRecipients + recipients = recipients.concat localRecipients + if recipients.length > 1 + actions.push { + name: 'reply all' + type: 'replyAll' + icon: 'reply-all' + href: '#' + } + actions.push { + name: 'split' + type: 'split' + icon: 'split' + href: '#ticket/create/' + article.ticket_id + '/' + article.id + } + actions + + replyAll: (e) => + @reply(e, true) + + reply: (e, all = false) => + e.preventDefault() + + # get reference article + article_id = $(e.target).parents('[data-id]').data('id') + article = App.TicketArticle.fullLocal( article_id ) + type = App.TicketArticleType.find( article.type_id ) + customer = App.User.find( article.created_by_id ) + + @el.closest('.article-add').ScrollTo() + + # empty form + articleNew = { + to: '' + cc: '' + body: '' + in_reply_to: '' + } + + #@el.closest('[name="in_reply_to"]').val('') + + if article.message_id + articleNew.in_reply_to = article.message_id + + if type.name is 'twitter status' + + # set to in body + to = customer.accounts['twitter'].username || customer.accounts['twitter'].uid + articleNew.body = '@' + to + + else if type.name is 'twitter direct-message' + + # show to + to = customer.accounts['twitter'].username || customer.accounts['twitter'].uid + articleNew.to = to + + else if type.name is 'email' || type.name is 'phone' || type.name is 'web' + + if article.sender.name is 'Agent' + articleNew.to = article.to + else + articleNew.to = article.from + + # if sender is customer but in article.from is no email, try to get + # customers email via customer user + if articleNew.to && !articleNew.to.match(/@/) + articleNew.to = article.created_by.email + + # filter for uniq recipients + recipientAddresses = {} + recipient = emailAddresses.parseAddressList(articleNew.to) + if recipient && recipient[0] + recipientAddresses[ recipient[0].address.toString().toLowerCase() ] = true + if all + addAddresses = (lineNew, addressLine) -> + localAddresses = App.EmailAddress.all() + recipients = emailAddresses.parseAddressList(addressLine) + if recipients + for recipient in recipients + if recipient.address + + # check if addess is not local + localAddess = false + for address in localAddresses + if recipient.address.toString().toLowerCase() == address.email.toString().toLowerCase() + localAddess = true + if !localAddess + + # filter for uniq recipients + if !recipientAddresses[ recipient.address.toString().toLowerCase() ] + recipientAddresses[ recipient.address.toString().toLowerCase() ] = true + + # add recipient + if lineNew + lineNew = lineNew + ', ' + lineNew = lineNew + recipient.address + lineNew + + if article.from + articleNew.cc = addAddresses(articleNew.cc, article.from) + if article.to + articleNew.cc = addAddresses(articleNew.cc, article.to) + if article.cc + articleNew.cc = addAddresses(articleNew.cc, article.cc) + + # get current body + body = @el.closest('[data-name="body"]').html() || '' + + # check if quote need to be added + selectedText = App.ClipBoard.getSelected() + if selectedText + + # clean selection + selectedText = App.Utils.textCleanup( selectedText ) + + # convert to html + selectedText = App.Utils.text2html( selectedText ) + if selectedText + selectedText = "


#{selectedText}

" + + # add selected text to body + body = selectedText + body + + articleNew.body = body + + App.Event.trigger('ui::ticket::setArticleType', { ticket: @ticket, type: type, article: articleNew } ) diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/article_new.js.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/article_new.js.coffee new file mode 100644 index 000000000..a832bee5b --- /dev/null +++ b/app/assets/javascripts/app/controllers/ticket_zoom/article_new.js.coffee @@ -0,0 +1,520 @@ +class App.TicketZoomArticleNew extends App.Controller + elements: + '.js-textarea': 'textarea' + '.attachmentPlaceholder': 'attachmentPlaceholder' + '.attachmentPlaceholder-inputHolder': 'attachmentInputHolder' + '.attachmentPlaceholder-hint': 'attachmentHint' + '.article-add': 'articleNewEdit' + '.attachments': 'attachmentsHolder' + '.attachmentUpload': 'attachmentUpload' + '.attachmentUpload-progressBar': 'progressBar' + '.js-percentage': 'progressText' + '.js-cancel': 'cancelContainer' + '.textBubble': 'textBubble' + '.editControls-item': 'editControlItem' + #'.editControls': 'editControls' + #'.recipient-picker': 'recipientPicker' + #'.recipient-list': 'recipientList' + #'.recipient-list .list-arrow': 'recipientListArrow' + + events: + 'click .js-toggleVisibility': 'toggleVisibility' + 'click .js-articleTypeItem': 'selectArticleType' + 'click .js-selectedArticleType': 'showSelectableArticleType' + 'click .recipient-picker': 'toggle_recipients' + 'click .recipient-list': 'stopPropagation' + 'click .list-entry-type div': 'change_type' + 'submit .recipient-list form': 'add_recipient' + 'focus .js-textarea': 'openTextarea' + 'input .js-textarea': 'detectEmptyTextarea' + #'dragenter': 'onDragenter' + #'dragleave': 'onDragleave' + #'drop': 'onFileDrop' + #'change input[type=file]': 'onFilePick' + + constructor: -> + super + + # gets referenced in @setArticleType + @type = @defaults['type'] || 'note' + @articleTypes = [ + { + name: 'note' + icon: 'note' + attributes: [] + }, + { + name: 'email' + icon: 'email' + attributes: ['to','cc'] + }, + { + name: 'facebook' + icon: 'facebook' + attributes: [] + }, + { + name: 'twitter' + icon: 'twitter' + attributes: [] + }, + { + name: 'phone' + icon: 'phone' + attributes: [] + }, + ] + if @isRole('Customer') + @type = 'note' + @articleTypes = [ + { + name: 'note' + icon: 'note' + attributes: [] + }, + ] + + @textareaHeight = + open: 148 + closed: 20 + + @dragEventCounter = 0 + @attachments = [] + + @render() + + if @defaults.body or @isIE10() + @openTextarea(null, true) + + # set article type and expand text area + @bind( + 'ui::ticket::setArticleType' + (data) => + if data.ticket.id is @ticket.id + #@setArticleType(data.type.name) + + @openTextarea(null, true) + for key, value of data.article + if key is 'body' + @$('[data-name="' + key + '"]').html(value) + else + @$('[name="' + key + '"]').val(value) + + # preselect article type + @setArticleType( 'email' ) + ) + + # reset new article screen + @bind( + 'ui::ticket::taskReset' + (data) => + if data.ticket_id is @ticket.id + @type = 'note' + @defaults = {} + @render() + ) + + isIE10: -> + Function('/*@cc_on return document.documentMode===10@*/')() + + stopPropagation: (e) -> + e.stopPropagation() + + release: => + if @subscribeIdTextModule + App.Ticket.unsubscribe(@subscribeIdTextModule) + + render: -> + + ticket = App.Ticket.fullLocal( @ticket.id ) + + @html App.view('ticket_zoom/article_new')( + ticket: ticket + articleTypes: @articleTypes + article: @defaults + isCustomer: @isRole('Customer') + ) + @setArticleType(@type) + + new App.WidgetAvatar( + el: @$('.js-avatar') + user_id: App.Session.get('id') + size: 40 + position: 'right' + class: 'zIndex-5' + ) + + configure_attributes = [ + { name: 'customer_id', display: 'Recipients', tag: 'user_autocompletion', null: false, placeholder: 'Enter Person or Organization/Company', minLengt: 2, disableCreateUser: false }, + ] + + controller = new App.ControllerForm( + el: @$('.recipients') + model: + configure_attributes: configure_attributes, + ) + + @$('[data-name="body"]').ce({ + mode: 'richtext' + multiline: true + maxlength: 5000 + }) + + html5Upload.initialize( + uploadUrl: App.Config.get('api_path') + '/ticket_attachment_upload', + dropContainer: @el.get(0), + cancelContainer: @cancelContainer, + inputField: @$('.article-attachment input').get(0), + key: 'File', + data: { form_id: @form_id }, + maxSimultaneousUploads: 1, + onFileAdded: (file) => + + file.on( + + onStart: => + @attachmentPlaceholder.addClass('hide') + @attachmentUpload.removeClass('hide') + @cancelContainer.removeClass('hide') + console.log('upload start') + + onAborted: => + @attachmentPlaceholder.removeClass('hide') + @attachmentUpload.addClass('hide') + + # Called after received response from the server + onCompleted: (response) => + + response = JSON.parse(response) + @attachments.push response.data + + @attachmentPlaceholder.removeClass('hide') + @attachmentUpload.addClass('hide') + + @renderAttachment(response.data) + console.log('upload complete', response.data ) + + # Called during upload progress, first parameter + # is decimal value from 0 to 100. + onProgress: (progress, fileSize, uploadedBytes) => + @progressBar.width(parseInt(progress) + "%") + @progressText.text(parseInt(progress)) + # hide cancel on 90% + if parseInt(progress) >= 90 + @cancelContainer.addClass('hide') + console.log('uploadProgress ', parseInt(progress)) + ) + ) + + # show text module UI + if !@isRole('Customer') + textModule = new App.WidgetTextModule( + el: @$('.js-textarea').parent() + data: + ticket: ticket + ) + callback = (ticket) => + textModule.reload( + ticket: ticket + ) + @subscribeIdTextModule = ticket.subscribe( callback ) + + toggle_recipients: => + if !@pickRecipientsCatcher + @show_recipients() + else + @hide_recipients() + + show_recipients: -> + padding = 15 + + @recipientPicker.addClass('is-open') + @recipientList.removeClass('hide') + + pickerDimensions = @recipientPicker.get(0).getBoundingClientRect() + availableHeight = @recipientPicker.scrollParent().outerHeight() + + top = pickerDimensions.height/2 - @recipientList.height()/2 + bottomDistance = availableHeight - padding - (pickerDimensions.top + top + @recipientList.height()) + + if bottomDistance < 0 + top += bottomDistance + + arrowCenter = -top + pickerDimensions.height/2 + + @recipientListArrow.css('top', arrowCenter) + @recipientList.css('top', top) + + $.Velocity.hook(@recipientList, 'transformOriginX', "0") + $.Velocity.hook(@recipientList, 'transformOriginY', "#{ arrowCenter }px") + + @recipientList.velocity + properties: + scale: [ 1, 0 ] + opacity: [ 1, 0 ] + options: + speed: 300 + easing: [ 0.34, 1.61, 0.7, 1 ] + + @pickRecipientsCatcher = new App.clickCatcher + holder: @el.offsetParent() + callback: @hide_recipients + zIndexScale: 6 + + hide_recipients: => + @pickRecipientsCatcher.remove() + @pickRecipientsCatcher = null + + @recipientPicker.removeClass('is-open') + + @recipientList.velocity + properties: + scale: [ 0, 1 ] + opacity: [ 0, 1 ] + options: + speed: 300 + easing: [ 500, 20 ] + complete: -> @recipientList.addClass('hide') + + change_type: (e) -> + $(e.target).addClass('active').siblings('.active').removeClass('active') + # store $(this).data('value') + + add_recipient: (e) -> + e.stopPropagation() + e.preventDefault() + console.log "add recipient", e + # store recipient + + toggleVisibility: -> + if @articleNewEdit.hasClass 'is-public' + @articleNewEdit + .removeClass 'is-public' + .addClass 'is-internal' + + @$('[name="internal"]').val 'true' + else + @articleNewEdit + .addClass 'is-public' + .removeClass 'is-internal' + + + @$('[name="internal"]').val '' + + showSelectableArticleType: => + @el.find('.js-articleTypes').removeClass('is-hidden') + + @selectTypeCatcher = new App.clickCatcher + holder: @el.offsetParent() + callback: @hideSelectableArticleType + zIndexScale: 6 + + selectArticleType: (e) => + articleTypeToSet = $(e.target).closest('.pop-selectable').data('value') + @setArticleType( articleTypeToSet ) + @hideSelectableArticleType() + + @selectTypeCatcher.remove() + @selectTypeCatcher = null + + hideSelectableArticleType: => + @el.find('.js-articleTypes').addClass('is-hidden') + + setArticleType: (type) -> + typeIcon = @$('.js-selectedType') + @type = type + @$('[name="type"]').val(type) + @articleNewEdit.attr('data-type', type) + typeIcon.find('use').attr 'xlink:href', '#icon-'+ @type + + # show/hide attributes + for articleType in @articleTypes + if articleType.name is type + @$('.form-group').addClass('hide') + for name in articleType.attributes + @$("[name=#{name}]").closest('.form-group').removeClass('hide') + + # check if signature need to be added + body = @$('[data-name="body"]').html() || '' + signature = undefined + if @ticket.group.signature_id + signature = App.Signature.find( @ticket.group.signature_id ) + if signature && signature.body && @type is 'email' + signatureFinished = App.Utils.text2html( + App.Utils.replaceTags( signature.body, { user: App.Session.get(), ticket: @ticket } ) + ) + if App.Utils.signatureCheck( body, signatureFinished ) + if !App.Utils.lastLineEmpty(body) + body = body + '
' + body = body + "
#{signatureFinished}
" + @$('[data-name="body"]').html(body) + + # remove old signature + else + @$('[data-name="body"]').find("[data-signature=true]").remove() + + detectEmptyTextarea: => + if !@textarea.text().trim() + @addTextareaCatcher() + else + @removeTextareaCatcher() + + openTextarea: (event, withoutAnimation) => + if !@articleNewEdit.hasClass('is-open') + duration = 300 + + if withoutAnimation + duration = 0 + + @articleNewEdit.addClass('is-open') + + @textarea.velocity + properties: + minHeight: "#{ @textareaHeight.open - 38 }px" + options: + duration: duration + easing: 'easeOutQuad' + complete: => @addTextareaCatcher() + + @textBubble.velocity + properties: + paddingBottom: 28 + options: + duration: duration + easing: 'easeOutQuad' + + # scroll to bottom + @textarea.velocity "scroll", + container: @textarea.scrollParent() + offset: 99999 + duration: 300 + easing: 'easeOutQuad' + queue: false + + @editControlItem + .removeClass('is-hidden') + .velocity + properties: + opacity: [ 1, 0 ] + translateX: [ 0, 20 ] + translateZ: 0 + options: + duration: 300 + stagger: 50 + drag: true + + # move attachment text to the left bottom (bottom happens automatically) + @attachmentPlaceholder.velocity + properties: + translateX: -@attachmentInputHolder.position().left + "px" + options: + duration: duration + easing: 'easeOutQuad' + + @attachmentHint.velocity + properties: + opacity: 0 + options: + duration: duration + + addTextareaCatcher: => + if @articleNewEdit.is(':visible') + @textareaCatcher = new App.clickCatcher + holder: @articleNewEdit.offsetParent() + callback: @closeTextarea + zIndexScale: 4 + + removeTextareaCatcher: -> + return if !@textareaCatcher + @textareaCatcher.remove() + @textareaCatcher = null + + closeTextarea: => + @removeTextareaCatcher() + if !@textarea.text().trim() && !@attachments.length && not @isIE10() + + @textarea.velocity + properties: + minHeight: "#{ @textareaHeight.closed }px" + options: + duration: 300 + easing: 'easeOutQuad' + complete: => @articleNewEdit.removeClass('is-open') + + @textBubble.velocity + properties: + paddingBottom: 10 + options: + duration: 300 + easing: 'easeOutQuad' + + @attachmentPlaceholder.velocity + properties: + translateX: 0 + options: + duration: 300 + easing: 'easeOutQuad' + + @attachmentHint.velocity + properties: + opacity: 1 + options: + duration: 300 + + @editControlItem + .velocity + properties: + opacity: [ 0, 1 ] + translateX: [ 20, 0 ] + translateZ: 0 + options: + duration: 100 + stagger: 50 + drag: true + complete: (elements) => $(elements).addClass('is-hidden') + + onDragenter: (event) => + # on the first event, + # open textarea (it will only open if its closed) + @openTextarea() if @dragEventCounter is 0 + + @dragEventCounter++ + @articleNewEdit.parent().addClass('is-dropTarget') + + onDragleave: (event) => + @dragEventCounter-- + + @articleNewEdit.parent().removeClass('is-dropTarget') if @dragEventCounter is 0 + + renderAttachment: (file) => + @attachmentsHolder.append App.view('generic/attachment_item') + fileName: file.filename + fileSize: @humanFileSize( file.size ) + store_id: file.store_id + @attachmentsHolder.on( + 'click' + "[data-id=#{file.store_id}]", (e) => + @attachments = _.filter( + @attachments, + (item) -> + return if item.id isnt file.store_id + item + ) + store_id = $(e.currentTarget).data('id') + + # delete attachment from storage + App.Ajax.request( + type: 'DELETE' + url: App.Config.get('api_path') + '/ticket_attachment_upload' + data: JSON.stringify( { store_id: store_id } ), + processData: false + success: (data, status, xhr) => + ) + + # remove attachment from dom + element = $(e.currentTarget).closest('.attachments') + $(e.currentTarget).closest('.attachment').remove() + # empty .attachment (remove spaces) to keep css working, thanks @mrflix :-o + if element.find('.attachment').length == 0 + element.empty() + ) diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/article_view.js.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/article_view.js.coffee new file mode 100644 index 000000000..0244fa6a5 --- /dev/null +++ b/app/assets/javascripts/app/controllers/ticket_zoom/article_view.js.coffee @@ -0,0 +1,251 @@ +class App.TicketZoomArticleView extends App.Controller + constructor: -> + super + + @article_controller = {} + + execute: (params) -> + for ticket_article_id in params.ticket_article_ids + if !@article_controller[ticket_article_id] + el = $('
') + @article_controller[ticket_article_id] = new ArticleViewItem( + ticket: @ticket + ticket_article_id: ticket_article_id + el: el + ui: @ui + ) + @el.append( el ) + +class ArticleViewItem extends App.Controller + events: + 'click .show_toogle': 'show_toogle' + 'click .textBubble': 'toggle_meta_with_delay' + 'click .textBubble a': 'stopPropagation' + 'click .js-unfold': 'unfold' + + constructor: -> + super + + @seeMore = false + + @render() + + # set article type and expand text area + @bind( + 'ui::ticket::shown' + (data) => + if data.ticket_id is @ticket.id + @setSeeMore() + ) + + # subscribe to changes + @subscribeId = App.TicketArticle.full( @ticket_article_id, @render, false, true ) + + release: => + App.User.TicketArticle(@subscribeId) + + render: (article) => + + # get articles + @article = App.TicketArticle.fullLocal( @ticket_article_id ) + + # prepare html body + if @article.content_type is 'text/html' + @article['html'] = @article.body + else + @article['html'] = App.Utils.textCleanup( @article.body ) + @article['html'] = App.Utils.text2html( @article.body ) + + @html App.view('ticket_zoom/article_view')( + ticket: @ticket + article: @article + isCustomer: @isRole('Customer') + ) + + new App.WidgetAvatar( + el: @$('.js-avatar') + user_id: @article.created_by_id + size: 40 + ) + + new App.TicketZoomArticleActions( + el: @$('.js-article-actions') + ticket: @ticket + article: @article + ) + + # show frontend times + @frontendTimeUpdate() + + # set see more option + @setSeeMore() + + # set see more options + setSeeMore: => + maxHeight = 560 + bubble = @$('.textBubble-content') + + # expand if see more is already clicked + if @seeMore + bubble.css('height', 'auto') + bubble.parent().find('.textBubble-overflowContainer').addClass('hide') + return + + # reset bubble heigth and "see more" opacity + bubble.css('height', '') + bubble.parent().find('.textBubble-overflowContainer').css('opacity', '') + + # remember offset of "see more" + offsetTop = bubble.find('.js-signatureMarker').position() + + # remember bubble heigth + heigth = bubble.height() + if offsetTop + bubble.attr('data-height', heigth) + bubble.css('height', "#{offsetTop.top + 30}px") + bubble.parent().find('.textBubble-overflowContainer').removeClass('hide') + else if heigth > maxHeight + bubble.attr('data-height', heigth) + bubble.css('height', "#{maxHeight}px") + bubble.parent().find('.textBubble-overflowContainer').removeClass('hide') + else + bubble.parent().find('.textBubble-overflowContainer').addClass('hide') + + show_toogle: (e) -> + e.stopPropagation() + e.preventDefault() + #$(e.target).hide() + if $(e.target).next('div')[0] + if $(e.target).next('div').hasClass('hide') + $(e.target).next('div').removeClass('hide') + $(e.target).text( App.i18n.translateContent('Fold in') ) + else + $(e.target).text( App.i18n.translateContent('See more') ) + $(e.target).next('div').addClass('hide') + + stopPropagation: (e) -> + e.stopPropagation() + + toggle_meta_with_delay: (e) => + # allow double click select + # by adding a delay to the toggle + + if @lastClick and +new Date - @lastClick < 150 + clearTimeout(@toggleMetaTimeout) + else + @toggleMetaTimeout = setTimeout(@toggle_meta, 150, e) + @lastClick = +new Date + + toggle_meta: (e) => + e.preventDefault() + + animSpeed = 300 + article = $(e.target).closest('.ticket-article-item') + metaTopClip = article.find('.article-meta-clip.top') + metaBottomClip = article.find('.article-meta-clip.bottom') + metaTop = article.find('.article-content-meta.top') + metaBottom = article.find('.article-content-meta.bottom') + + if @elementContainsSelection( article.get(0) ) + @stopPropagation(e) + return false + + if !metaTop.hasClass('hide') + article.removeClass('state--folde-out') + + # scroll back up + article.velocity "scroll", + container: article.scrollParent() + offset: -article.offset().top - metaTop.outerHeight() + duration: animSpeed + easing: 'easeOutQuad' + + metaTop.velocity + properties: + translateY: 0 + opacity: [ 0, 1 ] + options: + speed: animSpeed + easing: 'easeOutQuad' + complete: -> metaTop.addClass('hide') + + metaBottom.velocity + properties: + translateY: [ -metaBottom.outerHeight(), 0 ] + opacity: [ 0, 1 ] + options: + speed: animSpeed + easing: 'easeOutQuad' + complete: -> metaBottom.addClass('hide') + + metaTopClip.velocity({ height: 0 }, animSpeed, 'easeOutQuad') + metaBottomClip.velocity({ height: 0 }, animSpeed, 'easeOutQuad') + else + article.addClass('state--folde-out') + metaBottom.removeClass('hide') + metaTop.removeClass('hide') + + # balance out the top meta height by scrolling down + article.velocity("scroll", + container: article.scrollParent() + offset: -article.offset().top + metaTop.outerHeight() + duration: animSpeed + easing: 'easeOutQuad' + ) + + metaTop.velocity + properties: + translateY: [ 0, metaTop.outerHeight() ] + opacity: [ 1, 0 ] + options: + speed: animSpeed + easing: 'easeOutQuad' + + metaBottom.velocity + properties: + translateY: [ 0, -metaBottom.outerHeight() ] + opacity: [ 1, 0 ] + options: + speed: animSpeed + easing: 'easeOutQuad' + + metaTopClip.velocity({ height: metaTop.outerHeight() }, animSpeed, 'easeOutQuad') + metaBottomClip.velocity({ height: metaBottom.outerHeight() }, animSpeed, 'easeOutQuad') + + unfold: (e) -> + e.preventDefault() + e.stopPropagation() + + @seeMore = true + + container = $(e.currentTarget).parents('.textBubble-content') + overflowContainer = container.find('.textBubble-overflowContainer') + + overflowContainer.velocity + properties: + opacity: 0 + options: + duration: 300 + + container.velocity + properties: + height: container.attr('data-height') + options: + duration: 300 + complete: -> overflowContainer.addClass('hide'); + + isOrContains: (node, container) -> + while node + if node is container + return true + node = node.parentNode + false + + elementContainsSelection: (el) -> + sel = window.getSelection() + if sel.rangeCount > 0 && sel.toString() + for i in [0..sel.rangeCount-1] + if !@isOrContains(sel.getRangeAt(i).commonAncestorContainer, el) + return false + return true + false diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/meta.js.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/meta.js.coffee new file mode 100644 index 000000000..cbc3865ea --- /dev/null +++ b/app/assets/javascripts/app/controllers/ticket_zoom/meta.js.coffee @@ -0,0 +1,19 @@ +class App.TicketZoomMeta extends App.Controller + constructor: -> + super + + @ticket = App.Ticket.fullLocal( @ticket.id ) + @subscribeId = @ticket.subscribe(@render) + @render(@ticket) + + render: (ticket) => + @html App.view('ticket_zoom/meta')( + ticket: ticket + isCustomer: @isRole('Customer') + ) + + # show frontend times + @frontendTimeUpdate() + + release: => + App.Ticket.unsubscribe( @subscribeId ) \ No newline at end of file diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/overview_navigator.js.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/overview_navigator.js.coffee new file mode 100644 index 000000000..a555258c9 --- /dev/null +++ b/app/assets/javascripts/app/controllers/ticket_zoom/overview_navigator.js.coffee @@ -0,0 +1,71 @@ +class App.TicketZoomOverviewNavigator extends App.Controller + events: + 'click a': 'open' + + constructor: -> + super + + # rebuild overview navigator if overview has changed + @bind 'ticket_overview_rebuild', (data) => + execute = => + @render() + @delay(execute, 600, 'overview-navigator') + + @render() + + render: (overview) => + if !@overview_id + @html('') + return + + # get overview data + worker = App.TaskManager.worker( 'TicketOverview' ) + return if !worker + overview = worker.overview(@overview_id) + return if !overview + current_position = 0 + next = false + previous = false + for ticket_id in overview.ticket_ids + current_position += 1 + next = overview.ticket_ids[current_position] + previous = overview.ticket_ids[current_position-2] + break if ticket_id is @ticket_id + + # get next/previous ticket + if next + next = App.Ticket.find(next) + if previous + previous = App.Ticket.find(previous) + + @html App.view('ticket_zoom/overview_navigator')( + title: overview.overview.name + total_count: overview.tickets_count + current_position: current_position + next: next + previous: previous + ) + + open: (e) => + e.preventDefault() + + # get requested object and location + id = $(e.target).data('id') + url = $(e.target).attr('href') + if !id + id = $(e.target).closest('a').data('id') + url = $(e.target).closest('a').attr('href') + + # return if we are unable to get id + return if !id + + # open task via task manager to get overview information + App.TaskManager.execute( + key: 'Ticket-' + id + controller: 'TicketZoom' + params: + ticket_id: id + overview_id: @overview_id + show: true + ) + @navigate url \ No newline at end of file diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar.js.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar.js.coffee new file mode 100644 index 000000000..2b3610c65 --- /dev/null +++ b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar.js.coffee @@ -0,0 +1,179 @@ +class App.TicketZoomSidebar extends App.Controller + constructor: -> + super + ticket = App.Ticket.fullLocal( @ticket.id ) + @subscribeId = ticket.subscribe(@render) + @render(ticket) + + release: => + App.Ticket.unsubscribe( @subscribeId ) + + render: (ticket) => + + editTicket = (el) => + el.append('
') + @editEl = el + + show = (ticket) => + el.find('.edit').html('') + + defaults = ticket.attributes() + task_state = @taskGet('ticket') + modelDiff = App.Utils.formDiff( task_state, defaults ) + #if @isRole('Customer') + # delete defaults['state_id'] + # delete defaults['state'] + if !_.isEmpty( task_state ) + defaults = _.extend( defaults, task_state ) + + new App.ControllerForm( + el: el.find('.edit') + model: App.Ticket + screen: 'edit' + params: App.Ticket.find(ticket.id) + handlers: [ + @ticketFormChanges + ] + filter: @form_meta.filter + params: defaults + #bookmarkable: true + ) + #console.log('Ichanges', modelDiff, task_state, ticket.attributes()) + #@markFormDiff( modelDiff ) + + show(ticket) + @bind( + 'ui::ticket::taskReset' + (data) => + if data.ticket_id is ticket.id + show(ticket) + ) + + if !@isRole('Customer') + el.append('
') + new App.WidgetTag( + el: el.find('.tags') + object_type: 'Ticket' + object: ticket + tags: @tags + ) + el.append('') + new App.WidgetLink( + el: el.find('.links') + object_type: 'Ticket' + object: ticket + links: @links + container: @el.closest('.content') + ) + + showTicketHistory = => + new App.TicketHistory( + ticket_id: ticket.id + container: @el.closest('.content') + ) + showTicketMerge = => + new App.TicketMerge( + ticket: ticket + task_key: @task_key + container: @el.closest('.content') + ) + changeCustomer = (e, el) => + new App.TicketCustomer( + ticket: ticket + container: @el.closest('.content') + ) + @sidebarItems = [ + { + head: 'Ticket' + name: 'ticket' + icon: 'message' + callback: editTicket + } + ] + if !@isRole('Customer') + @sidebarItems[0]['actions'] = [ + { + name: 'ticket-history' + title: 'History' + callback: showTicketHistory + }, + { + name: 'ticket-merge' + title: 'Merge' + callback: showTicketMerge + }, + { + title: 'Change Customer' + name: 'customer-change' + callback: changeCustomer + }, + ] + if !@isRole('Customer') + editCustomer = (e, el) => + new App.ControllerGenericEdit( + id: ticket.customer_id + genericObject: 'User' + screen: 'edit' + pageData: + title: 'Users' + object: 'User' + objects: 'Users' + container: @el.closest('.content') + ) + showCustomer = (el) => + new App.WidgetUser( + el: el + user_id: ticket.customer_id + ) + @sidebarItems.push { + head: 'Customer' + name: 'customer' + icon: 'person' + actions: [ + { + title: 'Change Customer' + name: 'customer-change' + callback: changeCustomer + }, + { + title: 'Edit Customer' + name: 'customer-edit' + callback: editCustomer + }, + ] + callback: showCustomer + } + if ticket.organization_id + editOrganization = (e, el) => + new App.ControllerGenericEdit( + id: ticket.organization_id, + genericObject: 'Organization' + pageData: + title: 'Organizations' + object: 'Organization' + objects: 'Organizations' + container: @el.closest('.content') + ) + showOrganization = (el) => + new App.WidgetOrganization( + el: el + organization_id: ticket.organization_id + ) + @sidebarItems.push { + head: 'Organization' + name: 'organization' + icon: 'group' + actions: [ + { + title: 'Edit Organization' + name: 'organization-edit' + callback: editOrganization + }, + ] + callback: showOrganization + } + new App.Sidebar( + el: @el + sidebarState: @sidebarState + items: @sidebarItems + ) diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/title.js.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/title.js.coffee new file mode 100644 index 000000000..a450f77b3 --- /dev/null +++ b/app/assets/javascripts/app/controllers/ticket_zoom/title.js.coffee @@ -0,0 +1,47 @@ +class App.TicketZoomTitle extends App.Controller + events: + 'blur .ticket-title-update': 'update' + + constructor: -> + super + + @ticket = App.Ticket.fullLocal( @ticket.id ) + @subscribeId = @ticket.subscribe(@render) + @render(@ticket) + + render: (ticket) => + + # check if render is needed + if @lastTitle && @lastTitle is ticket.title + return + @lastTitle = ticket.title + + @html App.view('ticket_zoom/title')( + ticket: ticket + ) + + @$('.ticket-title-update').ce({ + mode: 'textonly' + multiline: false + maxlength: 250 + }) + + update: (e) => + title = $(e.target).ceg() || '' + + # update title + if title isnt @ticket.title + @ticket.title = title + + # reset article - should not be resubmited on next ticket update + @ticket.article = undefined + + @ticket.save() + + App.TaskManager.mute( @task_key ) + + # update taskbar with new meta data + App.Event.trigger 'task:render' + + release: => + App.Ticket.unsubscribe( @subscribeId ) \ No newline at end of file