diff --git a/.gitignore b/.gitignore index b23c1cdea..aad805e24 100644 --- a/.gitignore +++ b/.gitignore @@ -4,49 +4,51 @@ # or operating system, you probably want to add a global ignore instead: # git config --global core.excludesfile ~/.gitignore_global +# Ignore .swp files +.*.swp + # Ignore bundler config /.bundle +# Ignore mac stuff +.DS_Store + +# Ignore Rubymine config +/.idea + +# Ignore .project files +/.project + +# Ignore .rbenv-vars +/.rbenv-vars + # Ignore database config /config/database.yml +# Ignore coverage stuff +/coverage + # Ignore the default SQLite database. /db/*.sqlite3 # Ignore local changes to schema.rb (e. g. through extentions) /db/schema.rb +# Ignore custom gem file +/Gemfile.local + +# Ignore node modules +/node_modules + # Ignore all logfiles and tempfiles. /log -/tmp/* -/tmp/pids/* /public/assets/*.* /public/assets/app /public/assets/custom +/tmp/* +/tmp/pids/* # except /tmp/pids/ which is needed for certain Zammad processes !/tmp !/tmp/pids !/tmp/pids/.keep - -# Ignore custom gem file -/Gemfile.local - -# Ignore .project files -/.project - -# Ignore local database settings -/config/database.yml - -# Ignore mac stuff -.DS_Store - -# Ignore .swp files -.*.swp - -# Ignore coverage stuff -/coverage - -# Ignore Rubymine config -/.idea -node_modules diff --git a/Gemfile b/Gemfile index 52624c5c0..11b2c490d 100644 --- a/Gemfile +++ b/Gemfile @@ -12,6 +12,7 @@ gem 'activerecord-session_store' gem 'json' # Supported DBs +gem 'activerecord-nulldb-adapter', group: :nulldb gem 'mysql2', group: :mysql gem 'pg', group: :postgres @@ -113,7 +114,8 @@ group :development, :test do gem 'github_changelog_generator' end -gem 'puma' +gem 'puma', group: :puma +gem 'unicorn', group: :unicorn # load onw gem's local_gemfile = File.join(File.dirname(__FILE__), 'Gemfile.local') diff --git a/Gemfile.lock b/Gemfile.lock index d2ffa73ec..faac04b1b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -30,6 +30,8 @@ GEM activemodel (= 4.2.7.1) activesupport (= 4.2.7.1) arel (~> 6.0) + activerecord-nulldb-adapter (0.3.6) + activerecord (>= 2.0.0) activerecord-session_store (1.0.0) actionpack (>= 4.0, < 5.1) activerecord (>= 4.0, < 5.1) @@ -152,6 +154,7 @@ GEM inflection (1.0.0) json (1.8.3) jwt (1.5.4) + kgio (2.11.0) koala (2.4.0) addressable faraday @@ -271,6 +274,7 @@ GEM rake (>= 0.8.7) thor (>= 0.18.1, < 2.0) rainbow (2.1.0) + raindrops (0.17.0) rake (11.2.2) rb-fsevent (0.9.7) rb-inotify (0.9.7) @@ -371,6 +375,9 @@ GEM unf_ext unf_ext (0.0.7.2) unicode-display_width (1.1.1) + unicorn (5.2.0) + kgio (~> 2.6) + raindrops (~> 0.7) websocket (1.2.3) writeexcel (1.0.5) zendesk_api (1.14.0) @@ -385,6 +392,7 @@ PLATFORMS ruby DEPENDENCIES + activerecord-nulldb-adapter activerecord-session_store autoprefixer-rails biz @@ -447,6 +455,7 @@ DEPENDENCIES therubyracer twitter uglifier + unicorn writeexcel zendesk_api diff --git a/app/assets/javascripts/app/controllers/ticket_overview.coffee b/app/assets/javascripts/app/controllers/ticket_overview.coffee index c7f3ae44d..670ab5934 100644 --- a/app/assets/javascripts/app/controllers/ticket_overview.coffee +++ b/app/assets/javascripts/app/controllers/ticket_overview.coffee @@ -1,13 +1,372 @@ class App.TicketOverview extends App.Controller className: 'overviews' activeFocus: 'nav' + mouse: + x: null + y: null + + elements: + '.js-batch-overlay': 'batchOverlay' + '.js-batch-overlay-backdrop': 'batchOverlayBackdrop' + '.js-batch-cancel': 'batchCancel' + '.js-batch-macro-circle': 'batchMacroCircle' + '.js-batch-assign-circle': 'batchAssignCircle' + '.js-batch-assign': 'batchAssign' + '.js-batch-macro': 'batchMacro' + + events: + 'mousedown .item': 'startDragItem' + 'mouseenter .js-batch-overlay-entry': 'highlightBatchEntry' + 'mouseleave .js-batch-overlay-entry': 'unhighlightBatchEntry' constructor: -> super @render() + startDragItem: (event) -> + @grabbedItem = $(event.currentTarget) + offset = @grabbedItem.offset() + @batchDragger = $(App.view('ticket_overview/batch_dragger')()) + @grabbedItemClone = @grabbedItem.clone() + @grabbedItemClone.data('offset', @grabbedItem.offset()) + @grabbedItemClone.addClass('batch-dragger-item js-main-item') + @batchDragger.append @grabbedItemClone + + @batchDragger.data + startX: event.pageX + startY: event.pageY + dx: Math.min(event.pageX - offset.left, 180) + dy: event.pageY - offset.top + moved: false + + $(document).on 'mousemove.item', @dragItem + $(document).one 'mouseup.item', @endDragItem + # TODO: fire @cancelDrag on ESC + + dragItem: (event) => + event.preventDefault() + pos = @batchDragger.data() + threshold = 3 + x = event.pageX - pos.dx + y = event.pageY - pos.dy + dir = if event.pageY > pos.startY then 1 else -1 + + if !pos.moved + if Math.abs(event.pageX - pos.startX) > threshold or Math.abs(event.pageY - pos.startY) > threshold + @batchDragger.data 'moved', true + # check grabbed items batch checkbox to make sure its checked + # (could be grabbed without checking the checkbox it) + @grabbedItemWasntChecked = !@grabbedItem.find('[name="bulk"]').prop('checked') + @grabbedItem.find('[name="bulk"]').prop('checked', true) + @grabbedItemClone.find('[name="bulk"]').prop('checked', true) + + additionalItems = @el.find('[name="bulk"]:checked').parents('.item').not(@grabbedItem) + additionalItemsClones = additionalItems.clone() + @draggedItems = @grabbedItemClone.add(additionalItemsClones) + # store offsets for later use + additionalItemsClones.each (i, item) -> $(@).data('offset', additionalItems.eq(i).offset()) + @batchDragger.prepend additionalItemsClones.addClass('batch-dragger-item').get().reverse() + if(additionalItemsClones.length) + @batchDragger.find('.js-batch-dragger-count').text(@draggedItems.length) + + $('#app').append @batchDragger + + @draggedItems.each (i, item) => + dx = $(item).data('offset').left - $(item).offset().left - x + dy = $(item).data('offset').top - $(item).offset().top - y + $.Velocity.hook item, 'translateX', "#{dx}px" + $.Velocity.hook item, 'translateY', "#{dy}px" + + @moveDraggedItems(-dir) + + @mouseY = event.pageY + @showBatchOverlay() + else + return + + $.Velocity.hook @batchDragger, 'translateX', "#{x}px" + $.Velocity.hook @batchDragger, 'translateY', "#{y}px" + + endDragItem: (event) => + $(document).off 'mousemove.item' + $(document).off 'mouseup.item' + pos = @batchDragger.data() + + if !@hoveredBatchEntry + @cleanUpDrag() + return + + $.Velocity.hook @batchDragger, 'transformOriginX', "#{pos.dx}px" + $.Velocity.hook @batchDragger, 'transformOriginY', "#{pos.dy}px" + @hoveredBatchEntry.velocity + properties: + scale: 1.1 + options: + duration: 200 + complete: => + @hoveredBatchEntry.velocity "reverse", + duration: 200 + complete: => + # clean scale + @hoveredBatchEntry.removeAttr('style') + @cleanUpDrag(true) + @performBatchAction @hoveredBatchEntry.attr('data-action') + @hoveredBatchEntry = null + @batchDragger.velocity + properties: + scale: 0 + options: + duration: 200 + + cancelDrag: -> + $(document).off 'mousemove.item' + $(document).off 'mouseup.item' + @cleanUpDrag() + + cleanUpDrag: (success) -> + @hideBatchOverlay() + $('.batch-dragger').remove() + + if @grabbedItemWasntChecked + @grabbedItem.find('[name="bulk"]').prop('checked', false) + + if success + # uncheck all checked items + @el.find('[name="bulk"]:checked').prop('checked', false) + @el.find('[name="bulk_all"]').prop('checked', false) + + moveDraggedItems: (dir) -> + @draggedItems.velocity + properties: + translateX: 0 + translateY: (i) => dir * i * @batchDragger.height()/2 + options: + easing: 'ease-in-out' + duration: 300 + + @batchDragger.find('.js-batch-dragger-count').velocity + properties: + translateY: if dir < 0 then 0 else -@batchDragger.height()+8 + options: + easing: 'ease-in-out' + duration: 300 + + performBatchAction: (action) -> + console.log "perform action #{action} on checked items" + + showBatchOverlay: -> + @batchOverlay.show() + @batchOverlayBackdrop.velocity { opacity: [1, 0] }, { duration: 500 } + @batchOverlayShown = true + $(document).on 'mousemove.batchoverlay', @controlBatchOverlay + + hideBatchOverlay: -> + $(document).off 'mousemove.batchoverlay' + @batchOverlayShown = false + @batchOverlayBackdrop.velocity { opacity: [0, 1] }, { duration: 300, queue: false } + @hideBatchCircles => + @batchOverlay.hide() + + if @batchAssignShown + @hideBatchAssign() + + if @batchMacroShown + @hideBatchMacro() + + controlBatchOverlay: (event) => + # store to detect if the mouse is hovering a drag-action entry + # after an animation ended -> @highlightBatchEntryAtMousePosition + @mouse.x = event.pageX + @mouse.y = event.pageY + + if event.pageY <= window.innerHeight/5*2 + mouseInArea = 'top' + else if event.pageY > window.innerHeight/5*2 && event.pageY <= window.innerHeight/5*3 + mouseInArea = 'middle' + else + mouseInArea = 'bottom' + + switch mouseInArea + when 'top' + if !@batchMacroShown + @hideBatchCircles() + @showBatchMacro() + @moveDraggedItems(1) + + when 'middle' + if @batchAssignShown + @hideBatchAssign() + + if @batchMacroShown + @hideBatchMacro() + + if !@batchCirclesShown + @showBatchCircles() + + when 'bottom' + if !@batchAssignShown + @hideBatchCircles() + @showBatchAssign() + @moveDraggedItems(-1) + + showBatchCircles: -> + @batchCirclesShown = true + + @batchMacroCircle.velocity + properties: + translateY: [0, '-150%'] + opacity: [1, 0] + options: + easing: [1,-.55,.2,1.37] + duration: 500 + visibility: 'visible' + delay: 200 + + @batchAssignCircle.velocity + properties: + translateY: [0, '150%'] + opacity: [1, 0] + options: + easing: [1,-.55,.2,1.37] + duration: 500 + visibility: 'visible' + delay: 200 + + hideBatchCircles: (callback) -> + @batchMacroCircle.velocity + properties: + translateY: ['-150%', 0] + opacity: [0, 1] + options: + duration: 300 + visibility: 'hidden' + queue: false + + @batchAssignCircle.velocity + properties: + translateY: ['150%', 0] + opacity: [0, 1] + options: + duration: 300 + complete: callback + visibility: 'hidden' + queue: false + + @batchCirclesShown = false + + showBatchAssign: -> + return if !@batchOverlayShown # user might have dropped the item already + @batchAssignShown = true + + @batchAssign.velocity + properties: + translateY: [0, '100%'] + opacity: [1, 0] + options: + easing: [1,-.55,.2,1.37] + duration: 500 + visibility: 'visible' + complete: @highlightBatchEntryAtMousePosition + delay: if @batchCirclesShown then 0 else 200 + + @batchCancel.css + top: 0 + bottom: 'auto' + + @batchCancel.velocity + properties: + translateY: [0, '100%'] + opacity: [1, 0] + options: + easing: [1,-.55,.2,1.37] + duration: 500 + visibility: 'visible' + delay: if @batchCirclesShown then 0 else 200 + + hideBatchAssign: -> + @batchAssign.velocity + properties: + translateY: ['100%', 0] + opacity: [0, 1] + options: + duration: 300 + visibility: 'hidden' + queue: false + + @batchCancel.velocity + properties: + translateY: ['100%', 0] + opacity: [0, 1] + options: + duration: 300 + visibility: 'hidden' + queue: false + + @batchAssignShown = false + + showBatchMacro: -> + return if !@batchOverlayShown # user might have dropped the item already + @batchMacroShown = true + + @batchMacro.velocity + properties: + translateY: [0, '-100%'] + opacity: [1, 0] + options: + easing: [1,-.55,.2,1.37] + duration: 500 + visibility: 'visible' + complete: @highlightBatchEntryAtMousePosition + delay: if @batchCirclesShown then 0 else 200 + + @batchCancel.css + top: 'auto' + bottom: 0 + @batchCancel.velocity + properties: + translateY: [0, '-100%'] + opacity: [1, 0] + options: + easing: [1,-.55,.2,1.37] + duration: 500 + visibility: 'visible' + delay: if @batchCirclesShown then 0 else 200 + + hideBatchMacro: -> + @batchMacro.velocity + properties: + translateY: ['-100%', 0] + opacity: [0, 1] + options: + duration: 300 + visibility: 'hidden' + queue: false + + @batchCancel.velocity + properties: + translateY: ['-100%', 0] + opacity: [0, 1] + options: + duration: 300 + visibility: 'hidden' + queue: false + + @batchMacroShown = false + + highlightBatchEntryAtMousePosition: => + entryAtPoint = $(document.elementFromPoint(@mouse.x, @mouse.y)).closest('.js-batch-overlay-entry') + if(entryAtPoint.length) + @hoveredBatchEntry = entryAtPoint.addClass('is-hovered') + + highlightBatchEntry: (event) -> + @hoveredBatchEntry = $(event.currentTarget).addClass('is-hovered') + + unhighlightBatchEntry: (event) -> + @hoveredBatchEntry = null + $(event.currentTarget).removeClass('is-hovered') + render: -> - elLocal = $(App.view('ticket_overview')()) + elLocal = $(App.view('ticket_overview/index')()) @navBarControllerVertical = new Navbar el: elLocal.find('.overview-header') @@ -397,10 +756,10 @@ class Table extends App.Controller ) table = $(table) table.delegate('[name="bulk_all"]', 'click', (e) -> - if $(e.target).attr('checked') - $(e.target).closest('table').find('[name="bulk"]').attr('checked', true) + if $(e.currentTarget).prop('checked') + $(e.currentTarget).closest('table').find('[name="bulk"]').prop('checked', true) else - $(e.target).closest('table').find('[name="bulk"]').attr('checked', false) + $(e.currentTarget).closest('table').find('[name="bulk"]').prop('checked', false) ) @$('.table-overview').append(table) else @@ -440,6 +799,25 @@ class Table extends App.Controller @bulkForm.hide() else @bulkForm.show() + + if @lastChecked && e.shiftKey + # check items in a row + currentItem = $(e.currentTarget).parents('.item') + lastCheckedItem = @lastChecked.parents('.item') + items = currentItem.parent().children() + + if currentItem.index() > lastCheckedItem.index() + # current item is below last checked item + startId = lastCheckedItem.index() + endId = currentItem.index() + else + # current item is above last checked item + startId = currentItem.index() + endId = lastCheckedItem.index() + + items.slice(startId+1, endId).find('[name="bulk"]').prop('checked', (-> !@checked)) + + @lastChecked = $(e.currentTarget) callbackIconHeader = (headers) -> attribute = name: 'icon' @@ -523,8 +901,8 @@ class Table extends App.Controller # deselect bulk_all if one item is uncheck observ @$('.table-overview').delegate('[name="bulk"]', 'click', (e) -> - if !$(e.target).attr('checked') - $(e.target).parents().find('[name="bulk_all"]').attr('checked', false) + if !$(e.target).prop('checked') + $(e.target).parents().find('[name="bulk_all"]').prop('checked', false) ) getSelected: -> @@ -540,7 +918,7 @@ class Table extends App.Controller ticketId = $(element).val() for ticketIdSelected in ticketIDs if ticketIdSelected is ticketId - $(element).attr('checked', true) + $(element).prop('checked', true) ) viewmode: (e) => diff --git a/app/assets/javascripts/app/views/ticket_overview.jst.eco b/app/assets/javascripts/app/views/ticket_overview.jst.eco deleted file mode 100644 index f4e27af10..000000000 --- a/app/assets/javascripts/app/views/ticket_overview.jst.eco +++ /dev/null @@ -1,5 +0,0 @@ - -
-
-
-
\ No newline at end of file diff --git a/app/assets/javascripts/app/views/ticket_overview/batch_dragger.jst.eco b/app/assets/javascripts/app/views/ticket_overview/batch_dragger.jst.eco new file mode 100644 index 000000000..2fe63e905 --- /dev/null +++ b/app/assets/javascripts/app/views/ticket_overview/batch_dragger.jst.eco @@ -0,0 +1,3 @@ +
+
+
\ No newline at end of file diff --git a/app/assets/javascripts/app/views/ticket_overview/index.jst.eco b/app/assets/javascripts/app/views/ticket_overview/index.jst.eco new file mode 100644 index 000000000..0659a1f1c --- /dev/null +++ b/app/assets/javascripts/app/views/ticket_overview/index.jst.eco @@ -0,0 +1,50 @@ + +
+
+
+
+
+
+
+ <%- @T('drag here to cancel') %> +
+
+
<%- @T('run macro') %>
+ <%- @Icon('arrow-up') %> +
+
+ <%- @Icon('arrow-down') %> +
<%- @T('assign tickets') %>
+
+
+
+ HH +
Hans Huber
+
+
+ + <%- @Icon('organization') %> + +
Zammad
+
3 Personen
+
+
+ FD +
Felicity Dickens
+
+
+
+
+
Close
+
+
+
Close & Tag as Spam
+
+
+
Close & Reply we're on Holidays
+
+
+
Escalate to 2nd level
+
+
+
\ No newline at end of file diff --git a/app/assets/stylesheets/zammad.scss b/app/assets/stylesheets/zammad.scss index 9ebd14508..487fa1dbd 100644 --- a/app/assets/stylesheets/zammad.scss +++ b/app/assets/stylesheets/zammad.scss @@ -3580,6 +3580,23 @@ footer { } } + &--organization { + display: flex; + align-items: center; + justify-content: center; + + .icon-organization { + fill: currentColor; + } + + &.size-80 { + .icon-organization { + width: 32px; + height: 32px; + } + } + } + .icon-logo { width: 100%; height: 100%; @@ -8321,6 +8338,207 @@ output { margin: 20px 0 32px; } +.batch-overlay { + @extend .fit; + z-index: 1; + color: white; + text-transform: uppercase; + text-align: center; + letter-spacing: 0.07em; + font-size: 0.95em; + line-height: 1.3; + display: none; + will-change: display; + cursor: grabbing; + overflow: hidden; + + &-backdrop { + @extend .fit; + background: hsla(231,20%,8%,.8); + opacity: 0; + will-change: opacity; + } + + &-circle { + margin: 35px auto; + background: hsl(207,7%,29%); + border-radius: 100%; + border: 4px solid white; + width: 140px; + height: 140px; + padding: 20px 0; + display: flex; + flex-direction: column; + justify-content: space-around; + align-items: center; + position: absolute; + left: 0; + right: 0; + will-change: transform, opacity; + visibility: hidden; + + &--top { + top: 0; + } + + &--bottom { + bottom: 0; + } + + .icon { + fill: currentColor; + opacity: 1; + } + + &-label { + width: 50%; + margin: 10px 0; + } + } + + &-cancel { + background: hsla(0,0%,100%,.21); + background-clip: padding-box; + border: 2px dashed hsla(0,0%,100%,.3); + border-radius: 8px; + padding: 28px; + margin: 50px 200px; + position: absolute; + top: 0; + left: 0; + right: 0; + visibility: hidden; + will-change: opacity; + } + + &-box { + background: hsl(232,9%,17%); + display: flex; + flex-wrap: wrap; + width: 100%; + padding: 37px 25px; + position: absolute; + visibility: hidden; + will-change: opacity, transition; + } + + &-assign { + padding-bottom: 87px; // 37px + 50px + bottom: -50px; // extra space for bounce animation + + &-entry { + padding: 13px; + + &.is-hovered { + .avatar { + border-color: $highlight-color; + transform: scale(1.05); + } + } + + .avatar { + border: 4px solid hsl(231,5%,30%); + margin-bottom: 10px; + box-sizing: content-box; + transition: transform 120ms; + } + + &-detail { + color: gray; + } + } + } + + &-macro { + padding-top: 87px; // 37px + 50px + top: -50px; // extra space for bounce animation + + &-entry { + margin: 13px; + border: 4px solid hsl(231,5%,30%); + background: hsl(233,9%,24%); + border-radius: 100%; + height: 120px; + width: 120px; + padding: 13px 13px 10px; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.9em; + + &.is-hovered { + border-color: $highlight-color; + transform: scale(1.05); + } + } + } +} + +.batch-dragger { + position: absolute; + left: 0; + top: 0; + pointer-events: none; + width: 250px; + height: 40px; + will-change: transform; + + &-item { + position: absolute; + left: 0; + width: 100%; + background: hsl(200,100%,91%); + border-radius: 4px; + display: flex; + align-items: center; + padding: 11px 0 9px 11px; + box-shadow: 0 0 10px hsla(0,0%,0%,.28); + will-change: transform; + + a { + color: inherit; + } + + td { + display: block; + padding: 0 12px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + flex-shrink: 0; + + &:nth-child(3) { + flex-shrink: 1; + } + + &:nth-child(n+4) { + display: none; + } + } + } + + &-counter { + position: absolute; + right: -8px; + bottom: -8px; + width: 25px; + height: 25px; + border-radius: 99px; + z-index: 1; + color: white; + background: $highlight-color; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 0 10px hsla(0,0%,0%,.28); + will-change: transform; + + &:empty { + display: none; + } + } +} + /* ---------------- diff --git a/config/unicorn.rb b/config/unicorn.rb new file mode 100644 index 000000000..56508b989 --- /dev/null +++ b/config/unicorn.rb @@ -0,0 +1,27 @@ +worker_processes 4 +timeout 30 +stderr_path 'log/unicorn_error.log' +stdout_path 'log/unicorn_access.log' +pid 'tmp/pids/unicorn.pid' + +before_fork do |server, _worker| + ## + # When sent a USR2, Unicorn will suffix its pidfile with .oldbin and + # immediately start loading up a new version of itself (loaded with a new + # version of our app). When this new Unicorn is completely loaded + # it will begin spawning workers. The first worker spawned will check to + # see if an .oldbin pidfile exists. If so, this means we've just booted up + # a new Unicorn and need to tell the old one that it can now die. To do so + # we send it a QUIT. + # + # Using this method we get 0 downtime deploys. + + old_pid = 'tmp/pids/unicorn.pid.oldbin' + if File.exist?(old_pid) && server.pid != old_pid + begin + Process.kill('QUIT', File.read(old_pid).to_i) + rescue Errno::ENOENT, Errno::ESRCH + logger.info 'Unicorn master already killed. Someone else did our job for us.' + end + end +end