diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index b7776694b..58570b4f9 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -1,6 +1,18 @@ ### Infos: -* Used Zammad version: -* Used Zammad installation source: (source, package, ...) -* Operating system: -* Browser + version: +* Used Zammad version: +* Installation method (source, package, ..): +* Operating system: +* Database + version: +* Elasticsearch version: +* Browser + version: ### Expected behavior: -* +* ### Actual behavior: -* +* ### Steps to reproduce the behavior: -* +* +Yes I'm sure this is a bug and no feature request or a general question. diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 2e89ebccc..cd85b1971 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -310,7 +310,8 @@ test:integration:es_mysql: - ruby -I test/ test/controllers/search_controller_test.rb - ruby -I test/ test/integration/report_test.rb - ruby -I test/ test/controllers/form_controller_test.rb - - ruby -I test/ test/controllers/user_organization_controller_test.rb + - ruby -I test/ test/controllers/user_controller_test.rb + - ruby -I test/ test/controllers/organization_controller_test.rb - rake db:drop test:integration:es_postgresql: @@ -328,7 +329,8 @@ test:integration:es_postgresql: - ruby -I test/ test/controllers/search_controller_test.rb - ruby -I test/ test/integration/report_test.rb - ruby -I test/ test/controllers/form_controller_test.rb - - ruby -I test/ test/controllers/user_organization_controller_test.rb + - ruby -I test/ test/controllers/user_controller_test.rb + - ruby -I test/ test/controllers/organization_controller_test.rb - rake db:drop test:integration:zendesk_mysql: @@ -355,24 +357,36 @@ test:integration:zendesk_postgresql: - ruby -I test/ test/integration/zendesk_import_test.rb - rake db:drop -test:integration:otrs_5_mysql: +test:integration:otrs_6_mysql: stage: test tags: - core - mysql script: - export RAILS_ENV=test - - export IMPORT_OTRS_ENDPOINT="http://vz1109.demo.znuny.com/otrs/public.pl?Action=ZammadMigrator" + - export IMPORT_OTRS_ENDPOINT="http://vz1185.test.znuny.com/otrs/public.pl?Action=ZammadMigrator" - rake db:create - rake db:migrate - ruby -I test/ test/integration/otrs_import_test.rb - rake db:drop -test:integration:otrs_5_postgresql: +test:integration:otrs_6_postgresql: stage: test tags: - core - postgresql + script: + - export RAILS_ENV=test + - export IMPORT_OTRS_ENDPOINT="http://vz1185.test.znuny.com/otrs/public.pl?Action=ZammadMigrator" + - rake db:create + - rake db:migrate + - ruby -I test/ test/integration/otrs_import_test.rb + - rake db:drop + +test:integration:otrs_5: + stage: test + tags: + - core script: - export RAILS_ENV=test - export IMPORT_OTRS_ENDPOINT="http://vz1109.demo.znuny.com/otrs/public.pl?Action=ZammadMigrator" diff --git a/.rubocop.yml b/.rubocop.yml index f9156d529..6e91d3296 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -45,29 +45,29 @@ Style/TrailingCommaInArguments: StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-trailing-array-commas' Enabled: false -Style/SpaceInsideParens: +Layout/SpaceInsideParens: Description: 'No spaces after ( or before ).' StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-spaces-braces' Enabled: false -Style/SpaceAfterMethodName: +Layout/SpaceAfterMethodName: Description: >- Do not put a space between a method name and the opening parenthesis in a method definition. StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#parens-no-spaces' Enabled: false -Style/LeadingCommentSpace: +Layout/LeadingCommentSpace: Description: 'Comments should start with a space.' StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#hash-space' Enabled: false -Style/MethodCallParentheses: +Style/MethodCallWithoutArgsParentheses: Description: 'Do not use parentheses for method calls with no arguments.' StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-args-no-parens' Enabled: false -Style/SpaceInsideBrackets: +Layout/SpaceInsideBrackets: Description: 'No spaces after [ or before ].' StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#no-spaces-braces' Enabled: false @@ -83,19 +83,19 @@ Style/MethodDefParentheses: StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#method-parens' Enabled: false -Style/EmptyLinesAroundClassBody: +Layout/EmptyLinesAroundClassBody: Description: "Keeps track of empty lines around class bodies." Enabled: false -Style/EmptyLinesAroundMethodBody: +Layout/EmptyLinesAroundMethodBody: Description: "Keeps track of empty lines around method bodies." Enabled: false -Style/EmptyLinesAroundBlockBody: +Layout/EmptyLinesAroundBlockBody: Description: "Keeps track of empty lines around block bodies." Enabled: false -Style/EmptyLinesAroundModuleBody: +Layout/EmptyLinesAroundModuleBody: Description: "Keeps track of empty lines around module bodies." Enabled: false @@ -143,17 +143,29 @@ Rails/HasAndBelongsToMany: # StyleGuide: 'https://github.com/bbatsov/rails-style-guide#has-many-through' Enabled: false +Rails/SkipsModelValidations: + Description: >- + Use methods that skips model validations with caution. + See reference for more information. + Reference: 'http://guides.rubyonrails.org/active_record_validations.html#skipping-validations' + Enabled: true + Exclude: + - test/**/* + Style/ClassAndModuleChildren: Description: 'Checks style of children classes and modules.' Enabled: false -Style/FileName: +Naming/FileName: Description: 'Use snake_case for source file names.' StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#snake-case-files' Enabled: true Exclude: - 'script/websocket-server.rb' +Naming/VariableNumber: + Description: 'Use the configured style when numbering variables.' + Enabled: false # 2.0 @@ -184,8 +196,23 @@ Metrics/ModuleLength: Description: 'Avoid modules longer than 100 lines of code.' Enabled: false +Metrics/BlockLength: + Enabled: false + +Lint/RescueWithoutErrorClass: + Enabled: false + +Rails/ApplicationRecord: + Enabled: false + # TODO +Rails/HasManyOrHasOneDependent: + Enabled: false + +Style/DateTime: + Enabled: false + Style/Documentation: Description: 'Document classes and non-namespace modules.' Enabled: false @@ -193,7 +220,7 @@ Style/Documentation: Lint/UselessAssignment: Enabled: false -Style/ExtraSpacing: +Layout/ExtraSpacing: Description: 'Do not use unnecessary spacing.' Enabled: false @@ -215,4 +242,14 @@ Style/NumericPredicate: AutoCorrect: false Enabled: true Exclude: - - "**/*_spec.rb" \ No newline at end of file + - "**/*_spec.rb" + +Lint/AmbiguousBlockAssociation: + Description: >- + Checks for ambiguous block association with method when param passed without + parentheses. + StyleGuide: '#syntax' + Enabled: true + Exclude: + - "**/*_spec.rb" + - "**/*_examples.rb" diff --git a/.ruby-version b/.ruby-version index 005119baa..8e8299dcc 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.4.1 +2.4.2 diff --git a/.travis.yml b/.travis.yml index e2ba0b4fc..7602977d6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -19,7 +19,7 @@ services: - mysql language: ruby rvm: - - 2.4.1 + - 2.4.2 before_install: - git fetch --unshallow - sudo apt-get -qq update @@ -62,3 +62,4 @@ script: after_success: - if [ "${DB}" = "mysql" ]; then contrib/travis-ci.org/trigger-docker-build.sh; fi - if [ "${DB}" = "mysql" ]; then contrib/travis-ci.org/trigger-docker-compose-build.sh; fi + - if [ "${DB}" = "mysql" ]; then contrib/travis-ci.org/trigger-docker-univention-build.sh; fi diff --git a/CHANGELOG.md b/CHANGELOG.md index e4ceaf451..283608e9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,7 @@ # Change Log -## [2.2.0](https://github.com/zammad/zammad/tree/2.2.0) (2017-xx-xx) -[Full Changelog](https://github.com/zammad/zammad/compare/2.1.0...2.2.0) +## [2.4.0](https://github.com/zammad/zammad/tree/2.4.0) (2018-xx-xx) +[Full Changelog](https://github.com/zammad/zammad/compare/2.3.0...2.4.0) **Implemented enhancements:** diff --git a/Gemfile b/Gemfile index 812e6cdf9..c980c2377 100644 --- a/Gemfile +++ b/Gemfile @@ -1,111 +1,131 @@ source 'https://rubygems.org' -ruby '2.4.1' - +# core - base +ruby '2.4.2' gem 'rails', '5.1.4' -gem 'rails-observers' + +# core - rails additions gem 'activerecord-session_store' - -# Bundle edge Rails instead: -#gem 'rails', :git => 'git://github.com/rails/rails.git' - +gem 'composite_primary_keys' gem 'json' +gem 'rails-observers' -# Supported DBs +# core - application servers +gem 'puma', group: :puma +gem 'unicorn', group: :unicorn + +# core - supported ORMs gem 'activerecord-nulldb-adapter', group: :nulldb gem 'mysql2', group: :mysql gem 'pg', group: :postgres +# core - asynchrous task execution +gem 'daemons' +gem 'delayed_job_active_record' + +# core - websocket +gem 'em-websocket' +gem 'eventmachine' + +# core - password security +gem 'argon2' + +# performance - Memcached +gem 'dalli' + +# asset handling group :assets do - gem 'sass-rails' #, github: 'rails/sass-rails' + # asset handling - coffee-script gem 'coffee-rails' gem 'coffee-script-source' - gem 'sprockets' - - gem 'uglifier' + # asset handling - frontend templating gem 'eco' + + # asset handling - SASS + gem 'sass-rails' + + # asset handling - pipeline + gem 'sprockets' + gem 'uglifier' end gem 'autoprefixer-rails' +# asset handling - javascript execution for e.g. linux +gem 'execjs' +gem 'libv8' +gem 'therubyracer' + +# authentication - provider gem 'doorkeeper' gem 'oauth2' +# authentication - third party gem 'omniauth' -gem 'omniauth-oauth2' gem 'omniauth-facebook' gem 'omniauth-github' gem 'omniauth-gitlab' gem 'omniauth-google-oauth2' gem 'omniauth-linkedin-oauth2' -gem 'omniauth-twitter' gem 'omniauth-microsoft-office365' +gem 'omniauth-oauth2' +gem 'omniauth-twitter' gem 'omniauth-weibo-oauth2' -gem 'twitter' -gem 'telegramAPI' +# channels gem 'koala' -gem 'mail' -gem 'valid_email2' +gem 'telegramAPI' +gem 'twitter' + +# channels - email additions gem 'htmlentities' - +gem 'mail', '2.6.6' gem 'mime-types' +gem 'valid_email2' +# feature - business hours gem 'biz' -gem 'composite_primary_keys' -gem 'delayed_job_active_record' -gem 'daemons' - -gem 'simple-rss' - -# e. g. on linux we need a javascript execution -gem 'libv8' -gem 'execjs' -gem 'therubyracer' - -require 'erb' -require 'yaml' - -gem 'net-ldap' - -# password security -gem 'argon2' +# feature - signature diffing +gem 'diffy' +# feature - excel output gem 'writeexcel' -gem 'icalendar' -gem 'icalendar-recurrence' + +# feature - device logging gem 'browser' +# feature - iCal export +gem 'icalendar' +gem 'icalendar-recurrence' + # integrations -gem 'slack-notifier' gem 'clearbit' +gem 'net-ldap' +gem 'slack-notifier' gem 'zendesk_api' -gem 'viewpoint' -gem 'rubyntlm', git: 'https://github.com/wimm/rubyntlm.git' + +# integrations - exchange gem 'autodiscover', git: 'https://github.com/thorsteneckel/autodiscover.git' - -# event machine -gem 'eventmachine' -gem 'em-websocket' - -gem 'diffy' +gem 'rubyntlm', git: 'https://github.com/wimm/rubyntlm.git' +gem 'viewpoint' # Gems used only for develop/test and not required # in production environments by default. group :development, :test do + # test frameworks gem 'rspec-rails' gem 'test-unit' - gem 'spring' - gem 'spring-commands-rspec' + + # test DB gem 'sqlite3' # code coverage + gem 'coveralls', require: false gem 'simplecov' gem 'simplecov-rcov' - gem 'coveralls', require: false # UI tests w/ Selenium gem 'selenium-webdriver', '2.53.4' @@ -120,9 +140,9 @@ group :development, :test do gem 'guard-symlink', require: false # code QA + gem 'coffeelint' gem 'pre-commit' gem 'rubocop' - gem 'coffeelint' # changelog generation gem 'github_changelog_generator' @@ -130,17 +150,14 @@ group :development, :test do # Setting ENV for testing purposes gem 'figaro' - # Use Factory Girl for generating random test data - gem 'factory_girl_rails' + # Use Factory Bot for generating random test data + gem 'factory_bot_rails' # mock http calls gem 'webmock' end -gem 'puma', group: :puma -gem 'unicorn', group: :unicorn - -# load onw gem's +# load onw gems for development and testing purposes local_gemfile = File.join(File.dirname(__FILE__), 'Gemfile.local') if File.exist?(local_gemfile) eval_gemfile local_gemfile diff --git a/Gemfile.lock b/Gemfile.lock index 3e2981f49..6821c1c42 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -65,22 +65,22 @@ GEM addressable (2.5.2) public_suffix (>= 2.0.2, < 4.0) arel (8.0.0) - argon2 (1.1.3) + argon2 (1.1.4) ffi (~> 1.9) ffi-compiler (~> 0.1) ast (2.3.0) - autoprefixer-rails (7.1.3) + autoprefixer-rails (7.1.6) execjs biz (1.7.0) clavius (~> 1.0) tzinfo - browser (2.5.1) + browser (2.5.2) buftok (0.2.0) builder (3.2.3) - childprocess (0.7.1) + childprocess (0.8.0) ffi (~> 1.0, >= 1.0.11) clavius (1.0.3) - clearbit (0.2.7) + clearbit (0.2.8) nestful (~> 1.1.0) coderay (1.1.2) coffee-rails (4.2.2) @@ -90,22 +90,24 @@ GEM coffee-script-source execjs coffee-script-source (1.12.2) - coffeelint (1.16.0) + coffeelint (1.16.1) coffee-script execjs json - composite_primary_keys (10.0.0) + composite_primary_keys (10.0.1) activerecord (~> 5.1.0) concurrent-ruby (1.0.5) - coveralls (0.8.21) - json (>= 1.8, < 3) - simplecov (~> 0.14.1) - term-ansicolor (~> 1.3) - thor (~> 0.19.4) - tins (~> 1.6) + coveralls (0.7.1) + multi_json (~> 1.3) + rest-client + simplecov (>= 0.7) + term-ansicolor + thor crack (0.4.3) safe_yaml (~> 1.0.0) - daemons (1.2.4) + crass (1.0.3) + daemons (1.2.5) + dalli (2.7.6) delayed_job (4.1.3) activesupport (>= 3.0, < 5.2) delayed_job_active_record (4.1.2) @@ -127,15 +129,15 @@ GEM eventmachine (>= 0.12.9) http_parser.rb (~> 0.6.0) equalizer (0.0.11) - erubi (1.6.1) + erubi (1.7.0) eventmachine (1.2.5) execjs (2.7.0) - factory_girl (4.8.0) + factory_bot (4.8.2) activesupport (>= 3.0.0) - factory_girl_rails (4.8.0) - factory_girl (~> 4.8.0) + factory_bot_rails (4.8.2) + factory_bot (~> 4.8.2) railties (>= 3.0.0) - faraday (0.11.0) + faraday (0.12.2) multipart-post (>= 1.2, < 3) faraday-http-cache (2.0.0) faraday (~> 0.8) @@ -154,7 +156,7 @@ GEM rainbow (>= 2.1) rake (>= 10.0) retriable (~> 2.1) - globalid (0.4.0) + globalid (0.4.1) activesupport (>= 4.2.0) guard (2.14.1) formatador (>= 0.2.4) @@ -174,20 +176,21 @@ GEM guard-symlink (0.1.1) guard guard-compat (~> 1.1) - hashdiff (0.3.6) + hashdiff (0.3.7) hashie (3.5.6) htmlentities (4.3.4) - http (2.2.2) + http (3.0.0) addressable (~> 2.3) http-cookie (~> 1.0) - http-form_data (~> 1.0.1) + http-form_data (>= 2.0.0.pre.pre2, < 3) http_parser.rb (~> 0.6.0) http-cookie (1.0.3) domain_name (~> 0.5) - http-form_data (1.0.3) + http-form_data (2.0.0) http_parser.rb (0.6.0) httpclient (2.8.3) - i18n (0.8.6) + i18n (0.9.1) + concurrent-ruby (~> 1.0) icalendar (2.4.1) icalendar-recurrence (1.1.2) icalendar (~> 2.0) @@ -210,25 +213,26 @@ GEM logging (2.2.2) little-plugger (~> 1.1) multi_json (~> 1.10) - loofah (2.0.3) + loofah (2.1.1) + crass (~> 1.0.2) nokogiri (>= 1.5.9) lumberjack (1.0.12) mail (2.6.6) mime-types (>= 1.16, < 4) memoizable (0.4.2) thread_safe (~> 0.3, >= 0.3.1) - method_source (0.8.2) + method_source (0.9.0) mime-types (2.99.3) mini_portile2 (2.3.0) minitest (5.10.3) multi_json (1.12.2) multi_xml (0.6.0) multipart-post (2.0.0) - mysql2 (0.4.9) + mysql2 (0.4.10) naught (1.1.0) nenv (0.3.0) - nestful (1.1.1) - net-ldap (0.16.0) + nestful (1.1.3) + net-ldap (0.16.1) netrc (0.11.0) nio4r (2.1.0) nokogiri (1.8.1) @@ -246,7 +250,7 @@ GEM rack (>= 1.2, < 3) octokit (4.7.0) sawyer (~> 0.8.0, >= 0.5.3) - omniauth (1.6.1) + omniauth (1.7.1) hashie (>= 3.4.6, < 3.6.0) rack (>= 1.6.2, < 3) omniauth-facebook (4.0.0) @@ -280,24 +284,24 @@ GEM omniauth-weibo-oauth2 (0.4.5) omniauth (~> 1.5) omniauth-oauth2 (>= 1.4.0) - parser (2.4.0.0) - ast (~> 2.2) + parallel (1.12.0) + parser (2.4.0.2) + ast (~> 2.3) pg (0.21.0) pluginator (1.5.0) - power_assert (1.1.0) + power_assert (1.1.1) powerpack (0.1.1) - pre-commit (0.35.0) + pre-commit (0.37.0) pluginator (~> 1.5) - pry (0.10.4) + pry (0.11.3) coderay (~> 1.1.0) - method_source (~> 0.8.1) - slop (~> 3.4) - public_suffix (3.0.0) - puma (3.10.0) + method_source (~> 0.9.0) + public_suffix (3.0.1) + puma (3.11.0) rack (2.0.3) rack-livereload (0.3.16) rack - rack-test (0.7.0) + rack-test (0.8.2) rack (>= 1.0, < 3) rails (5.1.4) actioncable (= 5.1.4) @@ -327,7 +331,7 @@ GEM rainbow (2.2.2) rake raindrops (0.19.0) - rake (12.1.0) + rake (12.3.0) rb-fsevent (0.10.2) rb-inotify (0.9.10) ffi (>= 0.5.0, < 2) @@ -337,39 +341,40 @@ GEM mime-types (>= 1.16, < 3.0) netrc (~> 0.7) retriable (2.1.0) - rspec-core (3.6.0) - rspec-support (~> 3.6.0) - rspec-expectations (3.6.0) + rspec-core (3.7.0) + rspec-support (~> 3.7.0) + rspec-expectations (3.7.0) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.6.0) - rspec-mocks (3.6.0) + rspec-support (~> 3.7.0) + rspec-mocks (3.7.0) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.6.0) - rspec-rails (3.6.1) + rspec-support (~> 3.7.0) + rspec-rails (3.7.2) actionpack (>= 3.0) activesupport (>= 3.0) railties (>= 3.0) - rspec-core (~> 3.6.0) - rspec-expectations (~> 3.6.0) - rspec-mocks (~> 3.6.0) - rspec-support (~> 3.6.0) - rspec-support (3.6.0) - rubocop (0.42.0) - parser (>= 2.3.1.1, < 3.0) + rspec-core (~> 3.7.0) + rspec-expectations (~> 3.7.0) + rspec-mocks (~> 3.7.0) + rspec-support (~> 3.7.0) + rspec-support (3.7.0) + rubocop (0.51.0) + parallel (~> 1.10) + parser (>= 2.3.3.1, < 3.0) powerpack (~> 0.1) - rainbow (>= 1.99.1, < 3.0) + rainbow (>= 2.2.2, < 3.0) ruby-progressbar (~> 1.7) unicode-display_width (~> 1.0, >= 1.0.1) - ruby-progressbar (1.8.1) + ruby-progressbar (1.9.0) ruby_dep (1.5.0) rubyzip (1.2.1) safe_yaml (1.0.4) - sass (3.5.1) + sass (3.5.3) sass-listen (~> 4.0.0) sass-listen (4.0.0) rb-fsevent (~> 0.9, >= 0.9.4) rb-inotify (~> 0.9, >= 0.9.7) - sass-rails (5.0.6) + sass-rails (5.0.7) railties (>= 4.0.0, < 6) sass (~> 3.1) sprockets (>= 2.8, < 4.0) @@ -383,9 +388,8 @@ GEM rubyzip (~> 1.0) websocket (~> 1.0) shellany (0.0.1) - simple-rss (1.3.1) simple_oauth (0.3.1) - simplecov (0.14.1) + simplecov (0.15.1) docile (~> 1.1.0) json (>= 1.8, < 3) simplecov-html (~> 0.10.0) @@ -393,11 +397,6 @@ GEM simplecov-rcov (0.2.3) simplecov (>= 0.4.1) slack-notifier (2.3.1) - slop (3.6.0) - spring (2.0.2) - activesupport (>= 4.2) - spring-commands-rspec (1.0.4) - spring (>= 0.9.1) sprockets (3.7.1) concurrent-ruby (~> 1.0) rack (> 1, < 3) @@ -410,26 +409,27 @@ GEM rest-client (~> 1.7, >= 1.7.3) term-ansicolor (1.6.0) tins (~> 1.0) - test-unit (3.2.5) + test-unit (3.2.6) power_assert therubyracer (0.12.3) libv8 (~> 3.16.14.15) ref - thor (0.19.4) + thor (0.20.0) thread_safe (0.3.6) tilt (2.0.8) - tins (1.15.0) - twitter (6.1.0) - addressable (~> 2.5) + tins (1.15.1) + twitter (6.2.0) + addressable (~> 2.3) buftok (~> 0.2.0) - equalizer (= 0.0.11) - faraday (~> 0.11.0) - http (~> 2.1) + equalizer (~> 0.0.11) + http (~> 3.0) + http-form_data (~> 2.0) http_parser.rb (~> 0.6.0) - memoizable (~> 0.4.2) - naught (~> 1.1) - simple_oauth (~> 0.3.1) - tzinfo (1.2.3) + memoizable (~> 0.4.0) + multipart-post (~> 2.0) + naught (~> 1.0) + simple_oauth (~> 0.3.0) + tzinfo (1.2.4) thread_safe (~> 0.1) uglifier (3.2.0) execjs (>= 0.3.0, < 3) @@ -437,10 +437,10 @@ GEM unf_ext unf_ext (0.0.7.4) unicode-display_width (1.3.0) - unicorn (5.3.0) + unicorn (5.3.1) kgio (~> 2.6) raindrops (~> 0.7) - valid_email2 (2.0.1) + valid_email2 (2.1.0) activemodel (>= 3.2) mail (~> 2.5) viewpoint (1.1.0) @@ -448,16 +448,16 @@ GEM logging nokogiri rubyntlm - webmock (3.0.1) + webmock (3.1.1) addressable (>= 2.3.6) crack (>= 0.3.2) hashdiff - websocket (1.2.4) + websocket (1.2.5) websocket-driver (0.6.5) websocket-extensions (>= 0.1.0) - websocket-extensions (0.1.2) + websocket-extensions (0.1.3) writeexcel (1.0.5) - zendesk_api (1.14.4) + zendesk_api (1.16.0) faraday (~> 0.9) hashie (>= 3.5.2, < 4.0.0) inflection @@ -482,6 +482,7 @@ DEPENDENCIES composite_primary_keys coveralls daemons + dalli delayed_job_active_record diffy doorkeeper @@ -489,7 +490,7 @@ DEPENDENCIES em-websocket eventmachine execjs - factory_girl_rails + factory_bot_rails figaro github_changelog_generator guard @@ -501,7 +502,7 @@ DEPENDENCIES json koala libv8 - mail + mail (= 2.6.6) mime-types mysql2 net-ldap @@ -528,12 +529,9 @@ DEPENDENCIES rubyntlm! sass-rails selenium-webdriver (= 2.53.4) - simple-rss simplecov simplecov-rcov slack-notifier - spring - spring-commands-rspec sprockets sqlite3 telegramAPI @@ -549,7 +547,7 @@ DEPENDENCIES zendesk_api RUBY VERSION - ruby 2.4.1p111 + ruby 2.4.2p198 BUNDLED WITH - 1.15.4 + 1.16.0 diff --git a/README.md b/README.md index e2f8578ce..030acd785 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,9 @@ Zammad is a web based open source helpdesk/customer support system with many features to manage customer communication via several channels like telephone, facebook, twitter, chat and e-mails. It is distributed under the GNU AFFERO -General Public License (AGPL) and tested on Linux, Solaris, AIX, FreeBSD, -OpenBSD and Mac OS 10.x. Do you receive many e-mails and want to answer them -with a team of agents? +General Public License (AGPL). + +Do you receive many e-mails and want to answer them with a team of agents? You're going to love Zammad! diff --git a/Rakefile b/Rakefile old mode 100644 new mode 100755 diff --git a/VERSION b/VERSION index 1f4ca9020..a13300a9a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.2.x +2.4.x diff --git a/app/assets/javascripts/app/controllers/_application_controller.coffee b/app/assets/javascripts/app/controllers/_application_controller.coffee index c6fc9fa3f..ff9a2f972 100644 --- a/app/assets/javascripts/app/controllers/_application_controller.coffee +++ b/app/assets/javascripts/app/controllers/_application_controller.coffee @@ -700,6 +700,8 @@ class App.ControllerModal extends App.Controller headPrefix: '' shown: true closeOnAnyClick: false + initalFormParams: {} + initalFormParamsIgnore: false events: 'submit form': 'submit' @@ -746,10 +748,10 @@ class App.ControllerModal extends App.Controller centerButtons: @centerButtons leftButtons: @leftButtons ) - modal.find('.modal-body').html content + modal.find('.modal-body').html(content) if !@initRenderingDone @initRenderingDone = true - @html modal + @html(modal) else @$('.modal-dialog').replaceWith(modal) @post() @@ -761,6 +763,8 @@ class App.ControllerModal extends App.Controller @el render: => + @initalFormParamsIgnore = false + if @buttonSubmit is true @buttonSubmit = 'Submit' if @buttonCancel is true @@ -775,19 +779,18 @@ class App.ControllerModal extends App.Controller if @small @el.addClass('modal--small') - @el.modal + @el.modal( keyboard: @keyboard show: true backdrop: @backdrop container: @container - .on - 'show.bs.modal': @onShow - 'shown.bs.modal': @onShown - 'hide.bs.modal': @onClose - 'hidden.bs.modal': => - @onClosed() - $('.modal').remove() - 'dismiss.bs.modal': @onCancel + ).on( + 'show.bs.modal': @localOnShow + 'shown.bs.modal': @localOnShown + 'hide.bs.modal': @localOnClose + 'hidden.bs.modal': @localOnClosed + 'dismiss.bs.modal': @localOnCancel + ) if @closeOnAnyClick @el.on('click', => @@ -797,6 +800,7 @@ class App.ControllerModal extends App.Controller close: (e) => if e e.preventDefault() + @initalFormParamsIgnore = true @el.modal('hide') formParams: => @@ -804,28 +808,50 @@ class App.ControllerModal extends App.Controller return @formParam(@container.find('.modal form')) return @formParam(@$('.modal form')) - onShow: -> + localOnShow: (e) => + @onShow(e) + + onShow: (e) -> # do nothing - onShown: => + localOnShown: (e) => + @onShown(e) + + onShown: (e) => @$('input:not([disabled]):not([type="hidden"]):not(".btn"), textarea').first().focus() + @initalFormParams = @formParams() + + localOnClose: (e) => + diff = difference(@initalFormParams, @formParams()) + if @initalFormParamsIgnore is false && !_.isEmpty(diff) + if !confirm(App.i18n.translateContent('The form content has been changed. Do you want to close it and lose your changes?')) + e.preventDefault() + return + @onClose(e) onClose: -> # do nothing - onClosed: -> + localOnClosed: (e) => + @onClosed(e) + $('.modal').remove() + + onClosed: (e) -> # do nothing - onSubmit: -> - # do nothing + localOnCancel: (e) => + @onCancel(e) - onCancel: -> + onCancel: (e) -> # do nothing cancel: (e) => @close(e) @onCancel(e) + onSubmit: (e) -> + # do nothing + submit: (e) => e.stopPropagation() e.preventDefault() diff --git a/app/assets/javascripts/app/controllers/_application_controller_form.coffee b/app/assets/javascripts/app/controllers/_application_controller_form.coffee index bc82db034..91fdcbdb1 100644 --- a/app/assets/javascripts/app/controllers/_application_controller_form.coffee +++ b/app/assets/javascripts/app/controllers/_application_controller_form.coffee @@ -86,6 +86,9 @@ class App.ControllerForm extends App.Controller for attribute in @attributes attribute_count = attribute_count + 1 + if @isDisabled == true + attribute.disabled = true + # add item item = @formGenItem(attribute, className, fieldset, attribute_count) item.appendTo(fieldset) diff --git a/app/assets/javascripts/app/controllers/_application_controller_generic.coffee b/app/assets/javascripts/app/controllers/_application_controller_generic.coffee index 71789f94c..9b2c87cc8 100644 --- a/app/assets/javascripts/app/controllers/_application_controller_generic.coffee +++ b/app/assets/javascripts/app/controllers/_application_controller_generic.coffee @@ -148,24 +148,26 @@ class App.ControllerGenericIndex extends App.Controller return item ) - # show description button, only if content exists - showDescription = false - if App[ @genericObject ].description && !_.isEmpty(objects) - showDescription = true + if !@table - @html App.view('generic/admin/index')( - head: @pageData.objects - notes: @pageData.notes - buttons: @pageData.buttons - menus: @pageData.menus - showDescription: showDescription - ) + # show description button, only if content exists + showDescription = false + if App[ @genericObject ].description && !_.isEmpty(objects) + showDescription = true - # show description in content if no no content exists - if _.isEmpty(objects) && App[ @genericObject ].description - description = marked(App[ @genericObject ].description) - @$('.table-overview').html(description) - return + @html App.view('generic/admin/index')( + head: @pageData.objects + notes: @pageData.notes + buttons: @pageData.buttons + menus: @pageData.menus + showDescription: showDescription + ) + + # show description in content if no no content exists + if _.isEmpty(objects) && App[ @genericObject ].description + description = marked(App[ @genericObject ].description) + @$('.table-overview').html(description) + return # append content table params = _.extend( @@ -184,7 +186,10 @@ class App.ControllerGenericIndex extends App.Controller }, @pageData.tableExtend ) - new App.ControllerTable(params) + if !@table + @table = new App.ControllerTable(params) + else + @table.update(objects: objects) edit: (id, e) => e.preventDefault() @@ -651,7 +656,7 @@ class App.Sidebar extends App.Controller '.sidebar': 'sidebars' events: - 'click .tabsSidebar-tab': 'toggleTab' + 'click .tabsSidebar-tab': 'toggleTab' 'click .tabsSidebar-close': 'toggleSidebar' 'click .sidebar-header .js-headline': 'toggleDropdown' @@ -675,26 +680,48 @@ class App.Sidebar extends App.Controller @toggleTabAction(name) render: => + itemsLocal = [] + for item in @items + itemLocal = item.sidebarItem() + if itemLocal + itemsLocal.push itemLocal + + # container localEl = $(App.view('generic/sidebar_tabs')( - items: @items + items: itemsLocal scrollbarWidth: App.Utils.getScrollBarWidth() dir: App.i18n.dir() )) - # init content callback - for item in @items - area = localEl.filter('.sidebar[data-tab="' + item.name + '"]') - if item.callback - item.callback( area.find('.sidebar-content') ) - if item.actions - new App.ActionRow( - el: area.find('.js-actions') - items: item.actions - type: 'small' - ) + # init sidebar badget + for item in itemsLocal + el = localEl.find('.tabsSidebar-tab[data-tab="' + item.name + '"]') + if item.badgeCallback + item.badgeCallback(el) + else + @badgeRender(el, item) + + # init sidebar content + for item in itemsLocal + if item.sidebarCallback + el = localEl.filter('.sidebar[data-tab="' + item.name + '"]') + item.sidebarCallback(el.find('.sidebar-content')) + if !_.isEmpty(item.sidebarActions) + new App.ActionRow( + el: el.find('.js-actions') + items: item.sidebarActions + type: 'small' + ) @html localEl + badgeRender: (el, item) => + @badgeEl = el + @badgeRenderLocal(item) + + badgeRenderLocal: (item) => + @badgeEl.html(App.view('generic/sidebar_tabs_item')(icon: item.badgeIcon)) + toggleDropdown: (e) -> e.stopPropagation() $(e.currentTarget).next('.js-actions').find('.dropdown-toggle').dropdown('toggle') @@ -1170,7 +1197,6 @@ class App.ObserverController extends App.Controller if @globalRerender @bind('ui:rerender', => @lastAttributres = undefined - console.log('aaaa', @model, @template) @maybeRender(App[@model].fullLocal(@object_id)) ) diff --git a/app/assets/javascripts/app/controllers/_application_controller_table.coffee b/app/assets/javascripts/app/controllers/_application_controller_table.coffee index 21fcab3e9..64433f28e 100644 --- a/app/assets/javascripts/app/controllers/_application_controller_table.coffee +++ b/app/assets/javascripts/app/controllers/_application_controller_table.coffee @@ -97,6 +97,7 @@ class App.ControllerTable extends App.Controller checkBoxColWidth: 40 radioColWidth: 22 sortableColWidth: 36 + destroyColWidth: 70 elements: '.js-tableHead': 'tableHead' @@ -133,6 +134,8 @@ class App.ControllerTable extends App.Controller customOrderDirection: undefined customOrderBy: undefined + frontendTimeUpdateExecute: true + bindCol: {} bindRow: {} @@ -269,6 +272,7 @@ class App.ControllerTable extends App.Controller @currentRows = newCurrentRows @log 'debug', 'table.fullRender.contentRemoved', removePositions, addPositions @renderPager(@el, true) + @frontendTimeUpdateElement(@el) if @frontendTimeUpdateExecute is true return ['fullRender.contentRemoved', removePositions, addPositions] if newRows.length isnt @currentRows.length @@ -304,6 +308,7 @@ class App.ControllerTable extends App.Controller else @currentRows = clone(rows) container.find('.js-tableBody').html(rows) + @frontendTimeUpdateElement(container) if @frontendTimeUpdateExecute is true @renderPager(container) @@ -506,6 +511,7 @@ class App.ControllerTable extends App.Controller # get header data @headers = [] + availableWidth = @availableWidth for item in @overviewAttributes headerFound = false for attributeName, attribute of @attributesList @@ -520,7 +526,7 @@ class App.ControllerTable extends App.Controller # e.g. column: owner headerFound = true if @headerWidth[attribute.name] - attribute.displayWidth = @headerWidth[attribute.name] * @availableWidth + attribute.displayWidth = @headerWidth[attribute.name] * availableWidth else if !attribute.width attribute.displayWidth = @baseColWidth else @@ -529,7 +535,7 @@ class App.ControllerTable extends App.Controller unit = attribute.width.match(/[px|%]+/)[0] if unit is '%' - attribute.displayWidth = value / 100 * @el.width() + attribute.displayWidth = value / 100 * availableWidth else attribute.displayWidth = value @headers.push attribute @@ -538,7 +544,7 @@ class App.ControllerTable extends App.Controller if attributeName is "#{item}_id" || attributeName is "#{item}_ids" headerFound = true if @headerWidth[attribute.name] - attribute.displayWidth = @headerWidth[attribute.name] * @availableWidth + attribute.displayWidth = @headerWidth[attribute.name] * availableWidth else if !attribute.width attribute.displayWidth = @baseColWidth else @@ -547,7 +553,7 @@ class App.ControllerTable extends App.Controller unit = attribute.width.match(/[px|%]+/)[0] if unit is '%' - attribute.displayWidth = value / 100 * @el.width() + attribute.displayWidth = value / 100 * availableWidth else attribute.displayWidth = value @headers.push attribute @@ -741,8 +747,10 @@ class App.ControllerTable extends App.Controller if @availableWidth is 0 @availableWidth = @minTableWidth + availableWidth = @availableWidth + widths = @getHeaderWidths() - shrinkBy = Math.ceil (widths - @availableWidth) / @getShrinkableHeadersCount() + shrinkBy = Math.ceil (widths - availableWidth) / @getShrinkableHeadersCount() # make all cols evenly smaller @headers = _.map @headers, (col) => @@ -751,7 +759,8 @@ class App.ControllerTable extends App.Controller return col # give left-over space from rounding to last column to get to 100% - roundingLeftOver = @availableWidth - @getHeaderWidths() + roundingLeftOver = availableWidth - @getHeaderWidths() + # but only if there is something left over (will get negative when there are too many columns for each column to stay in their min width) if roundingLeftOver > 0 @headers[@headers.length - 1].displayWidth = @headers[@headers.length - 1].displayWidth + roundingLeftOver @@ -777,6 +786,9 @@ class App.ControllerTable extends App.Controller if @dndCallback widths += @sortableColWidth + if @destroy + widths += @destroyColWidth + widths setHeaderWidths: => diff --git a/app/assets/javascripts/app/controllers/_channel/email.coffee b/app/assets/javascripts/app/controllers/_channel/email.coffee index 5c92bc279..20a827462 100644 --- a/app/assets/javascripts/app/controllers/_channel/email.coffee +++ b/app/assets/javascripts/app/controllers/_channel/email.coffee @@ -478,7 +478,6 @@ class App.ChannelEmailEdit extends App.ControllerModal class App.ChannelEmailAccountWizard extends App.WizardModal elements: '.modal-body': 'body' - events: 'submit .js-intro': 'probeBasedOnIntro' 'submit .js-inbound': 'probeInbound' @@ -487,6 +486,9 @@ class App.ChannelEmailAccountWizard extends App.WizardModal 'click .js-goToSlide': 'goToSlide' 'click .js-expert': 'probeBasedOnIntro' 'click .js-close': 'hide' + inboundPassword: '' + outboundPassword: '' + passwordPlaceholder: '{{{{{{{{{{{{SECRTE_PASSWORD}}}}}}}}}}}}' constructor: -> super @@ -503,9 +505,17 @@ class App.ChannelEmailAccountWizard extends App.WizardModal if @channel @account = - inbound: @channel.options.inbound - outbound: @channel.options.outbound - meta: {} + inbound: clone(@channel.options.inbound) + outbound: clone(@channel.options.outbound) + meta: {} + + # remember passwords, do not show in ui + if @account.inbound.options && @account.inbound.options.password + @inboundPassword = @account.inbound.options.password + @account.inbound.options.password = @passwordPlaceholder + if @account.outbound.options && @account.outbound.options.password + @outboundPassword = @account.outbound.options.password + @account.outbound.options.password = @passwordPlaceholder if @container @el.addClass('modal--local') @@ -515,17 +525,17 @@ class App.ChannelEmailAccountWizard extends App.WizardModal if @channel @$('.js-goToSlide[data-slide=js-intro]').addClass('hidden') - @el.modal + @el.modal( keyboard: true show: true backdrop: true container: @container - .on + ).on( 'hidden.bs.modal': => if @callback @callback() @el.remove() - + ) if @slide @showSlide(@slide) @@ -712,6 +722,9 @@ class App.ChannelEmailAccountWizard extends App.WizardModal # get params params = @formParam(e.target) + if params.options.password is @passwordPlaceholder + params.options.password = @inboundPassword + # let backend know about the channel if @channel params.channel_id = @channel.id @@ -771,6 +784,9 @@ class App.ChannelEmailAccountWizard extends App.WizardModal params = @formParam(e.target) params['email'] = @account['meta']['email'] + if params.options.password is @passwordPlaceholder + params.options.password = @outboundPassword + if !params['email'] && @channel email_addresses = App.EmailAddress.search(filter: { channel_id: @channel.id }) if email_addresses && email_addresses[0] @@ -867,11 +883,13 @@ class App.ChannelEmailAccountWizard extends App.WizardModal class App.ChannelEmailNotificationWizard extends App.WizardModal elements: '.modal-body': 'body' - events: 'change .js-outbound [name=adapter]': 'toggleOutboundAdapter' 'submit .js-outbound': 'probleOutbound' 'click .js-close': 'hide' + inboundPassword: '' + outboundPassword: '' + passwordPlaceholder: '{{{{{{{{{{{{SECRTE_PASSWORD}}}}}}}}}}}}' constructor: -> super @@ -888,27 +906,35 @@ class App.ChannelEmailNotificationWizard extends App.WizardModal if @channel @account = - inbound: @channel.options.inbound - outbound: @channel.options.outbound + inbound: clone(@channel.options.inbound) + outbound: clone(@channel.options.outbound) + + # remember passwords, do not show in ui + if @account.inbound && @account.inbound.options && @account.inbound.options.password + @inboundPassword = @account.inbound.options.password + @account.inbound.options.password = @passwordPlaceholder + if @account.outbound && @account.outbound.options && @account.outbound.options.password + @outboundPassword = @account.outbound.options.password + @account.outbound.options.password = @passwordPlaceholder if @container @el.addClass('modal--local') @render() - @el.modal + @el.modal( keyboard: true show: true backdrop: true container: @container - .on + ).on( 'show.bs.modal': @onShow 'shown.bs.modal': @onShown 'hidden.bs.modal': => if @callback @callback() @el.remove() - + ) if @slide @showSlide(@slide) @@ -956,6 +982,9 @@ class App.ChannelEmailNotificationWizard extends App.WizardModal # get params params = @formParam(e.target) + if params.options && params.options.password is @passwordPlaceholder + params.options.password = @outboundPassword + # let backend know about the channel params.channel_id = @channel.id diff --git a/app/assets/javascripts/app/controllers/_channel/form.coffee b/app/assets/javascripts/app/controllers/_channel/form.coffee index 96cca32d3..2edaa3cdc 100644 --- a/app/assets/javascripts/app/controllers/_channel/form.coffee +++ b/app/assets/javascripts/app/controllers/_channel/form.coffee @@ -67,11 +67,15 @@ class App.ChannelForm extends App.ControllerSubContent # rebuild preview params.test = true if params.modal + @$('.js-modal').removeClass('hide') + @$('.js-inlineForm').addClass('hide') @$('.js-formInline').addClass('hide') @$('.js-formBtn').removeClass('hide') @$('.js-formBtn').ZammadForm(params) @$('.js-formBtn').text('Feedback') else + @$('.js-modal').addClass('hide') + @$('.js-inlineForm').removeClass('hide') @$('.js-formBtn').addClass('hide') @$('.js-formInline').removeClass('hide') @$('.js-formInline').ZammadForm(params) diff --git a/app/assets/javascripts/app/controllers/_integration/exchange.coffee b/app/assets/javascripts/app/controllers/_integration/exchange.coffee index c02c541ba..13ea97709 100644 --- a/app/assets/javascripts/app/controllers/_integration/exchange.coffee +++ b/app/assets/javascripts/app/controllers/_integration/exchange.coffee @@ -131,13 +131,12 @@ class Form extends App.Controller if _.isEmpty(job) @lastImport.html('') return - countDone = job.result.created + job.result.updated + job.result.unchanged + job.result.skipped + job.result.failed if !job.result.roles job.result.roles = {} for role_id, statistic of job.result.role_ids role = App.Role.find(role_id) job.result.roles[role.displayName()] = statistic - el = $(App.view('integration/exchange_last_import')(job: job, countDone: countDone)) + el = $(App.view('integration/exchange_last_import')(job: job)) @lastImport.html(el) activeDryRun: => @@ -540,34 +539,26 @@ class ConnectionWizard extends App.WizardModal @showAlert('js-error', (job.result.error || job.result.info)) return - total = 0 if job.result && _.keys(job.result).length > 0 @$('.js-preprogress').addClass('hide') @$('.js-analyzing').removeClass('hide') - analized = 0 - total = job.result.sum - for action, sum of job.result - continue if action == 'folders' - continue if action == 'sum' - analized += sum - - @$('.js-progress progress').attr('value', analized) - @$('.js-progress progress').attr('max', total) + @$('.js-progress progress').attr('value', job.result.sum) + @$('.js-progress progress').attr('max', job.result.total) if job.finished_at # reset initial state in case the back button is used @$('.js-preprogress').removeClass('hide') @$('.js-analyzing').addClass('hide') - @tryResult(job, total) + @tryResult(job) else @delay(@tryLoop, 4000) ) - tryResult: (job, total) => + tryResult: (job) => @showSlide('js-try') - el = $(App.view('integration/exchange_summary')(job: job, countDone: total)) + el = $(App.view('integration/exchange_summary')(job: job)) @el.find('.js-summary').html(el) App.Config.set( diff --git a/app/assets/javascripts/app/controllers/_integration/ldap.coffee b/app/assets/javascripts/app/controllers/_integration/ldap.coffee index cd6312a68..433b80b57 100644 --- a/app/assets/javascripts/app/controllers/_integration/ldap.coffee +++ b/app/assets/javascripts/app/controllers/_integration/ldap.coffee @@ -132,13 +132,12 @@ class Form extends App.Controller if _.isEmpty(job) @lastImport.html('') return - countDone = job.result.created + job.result.updated + job.result.unchanged + job.result.skipped + job.result.failed if !job.result.roles job.result.roles = {} for role_id, statistic of job.result.role_ids role = App.Role.find(role_id) job.result.roles[role.displayName()] = statistic - el = $(App.view('integration/ldap_last_import')(job: job, countDone: countDone)) + el = $(App.view('integration/ldap_last_import')(job: job)) @lastImport.html(el) activeDryRun: => @@ -419,7 +418,7 @@ class ConnectionWizard extends App.WizardModal if !_.isArray(user_attributes[key]) user_attributes[key] = [user_attributes[key]] user_attributes_local = - "#{@wizardConfig['user_uid']}": 'login' + 'samaccountname': 'login' length = user_attributes.source.length-1 for count in [0..length] if user_attributes.source[count] && user_attributes.dest[count] @@ -450,7 +449,7 @@ class ConnectionWizard extends App.WizardModal buildRowsUserMap: (user_attribute_map) => # show static login row - userUidDisplayValue = @wizardConfig.wizardData.backend_user_attributes[ @wizardConfig['user_uid'] ] + userUidDisplayValue = @wizardConfig.wizardData.backend_user_attributes['samaccountname'] el = [ $(App.view('integration/ldap_user_attribute_row_read_only')( @@ -459,7 +458,7 @@ class ConnectionWizard extends App.WizardModal )) ] for source, dest of user_attribute_map - continue if source == @wizardConfig['user_uid'] + continue if source == 'samaccountname' continue if !(source of @wizardConfig.wizardData.backend_user_attributes) el.push @buildRowUserAttribute(source, dest) el @@ -539,22 +538,12 @@ class ConnectionWizard extends App.WizardModal @showAlert('js-error', (job.result.error || job.result.info)) return - if job.result && job.result.sum + if job.result && job.result.total @$('.js-preprogress').addClass('hide') @$('.js-analyzing').removeClass('hide') - total = 0 - if job.result.created - total += job.result.created - if job.result.failed - total += job.result.failed - if job.result.skipped - total += job.result.skipped - if job.result.unchanged - total += job.result.unchanged - if job.result.updated - total += job.result.updated - @$('.js-progress progress').attr('value', total) - @$('.js-progress progress').attr('max', job.result.sum) + + @$('.js-progress progress').attr('value', job.result.sum) + @$('.js-progress progress').attr('max', job.result.total) if job.finished_at # reset initial state in case the back button is used @$('.js-preprogress').removeClass('hide') @@ -574,9 +563,8 @@ class ConnectionWizard extends App.WizardModal for role_id, statistic of job.result.role_ids role = App.Role.find(role_id) job.result.roles[role.displayName()] = statistic - countDone = job.result.created + job.result.updated + job.result.unchanged + job.result.skipped @showSlide('js-try') - el = $(App.view('integration/ldap_summary')(job: job, countDone: countDone)) + el = $(App.view('integration/ldap_summary')(job: job)) @el.find('.js-summary').html(el) App.Config.set( diff --git a/app/assets/javascripts/app/controllers/_settings/area_item.coffee b/app/assets/javascripts/app/controllers/_settings/area_item.coffee index c7db71eb3..877caeee0 100644 --- a/app/assets/javascripts/app/controllers/_settings/area_item.coffee +++ b/app/assets/javascripts/app/controllers/_settings/area_item.coffee @@ -32,7 +32,7 @@ class App.SettingsAreaItem extends App.Controller ) new App.ControllerForm( - el: @el.find('.form-item'), + el: @el.find('.form-item') model: { configure_attributes: @configure_attributes, className: '' } autofocus: false ) diff --git a/app/assets/javascripts/app/controllers/_settings/area_item_default_locale.coffee b/app/assets/javascripts/app/controllers/_settings/area_item_default_locale.coffee new file mode 100644 index 000000000..5d22c5a59 --- /dev/null +++ b/app/assets/javascripts/app/controllers/_settings/area_item_default_locale.coffee @@ -0,0 +1,21 @@ +class App.SettingsAreaItemDefaultLocale extends App.SettingsAreaItem + + render: => + + options = {} + locales = App.Locale.all() + for locale in locales + options[locale.locale] = locale.name + configure_attributes = [ + { name: 'locale_default', display: '', tag: 'searchable_select', null: false, class: 'input', options: options, default: @setting.state_current.value }, + ] + + @html App.view(@template)( + setting: @setting + ) + + new App.ControllerForm( + el: @el.find('.form-item') + model: { configure_attributes: configure_attributes, className: '' } + autofocus: false + ) diff --git a/app/assets/javascripts/app/controllers/_settings/area_ticket_number.coffee b/app/assets/javascripts/app/controllers/_settings/area_ticket_number.coffee index 70ccfb563..dcbd9f6f6 100644 --- a/app/assets/javascripts/app/controllers/_settings/area_ticket_number.coffee +++ b/app/assets/javascripts/app/controllers/_settings/area_ticket_number.coffee @@ -42,7 +42,7 @@ class App.SettingsAreaTicketNumber extends App.Controller number = "#{App.Config.get('ticket_hook')}#{App.Config.get('system_id')}" counter = '1' if paramsItem.min_size - minSize = parseInt(paramsItem.min_size) + minSize = parseInt(paramsItem.min_size) - "#{App.Config.get('system_id')}".length if paramsItem.checksum minSize -= 1 if minSize > 1 diff --git a/app/assets/javascripts/app/controllers/_ui_element/column_select.coffee b/app/assets/javascripts/app/controllers/_ui_element/column_select.coffee index e7ab5301e..6741b3946 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/column_select.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/column_select.coffee @@ -6,24 +6,24 @@ class App.UiElement.column_select extends App.UiElement.ApplicationUiElement attribute.multiple = 'multiple' # build options list based on config - @getConfigOptionList( attribute, params ) + @getConfigOptionList(attribute, params) # build options list based on relation - @getRelationOptionList( attribute, params ) + @getRelationOptionList(attribute, params) # add null selection if needed - @addNullOption( attribute, params ) + @addNullOption(attribute, params) # sort attribute.options - @sortOptions( attribute, params ) + @sortOptions(attribute, params) # find selected/checked item of list - @selectedOptions( attribute, params ) + @selectedOptions(attribute, params) # disable item of list - @disabledOptions( attribute, params ) + @disabledOptions(attribute, params) # filter attributes - @filterOption( attribute, params ) + @filterOption(attribute, params) - new App.ColumnSelect( attribute: attribute ).element() + new App.ColumnSelect(attribute: attribute).element() diff --git a/app/assets/javascripts/app/controllers/_ui_element/richtext.coffee b/app/assets/javascripts/app/controllers/_ui_element/richtext.coffee index f3cba6f7b..387a35a8f 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/richtext.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/richtext.coffee @@ -1,8 +1,7 @@ # coffeelint: disable=camel_case_classes class App.UiElement.richtext - @render: (attribute) -> - - item = $( App.view('generic/richtext')( attribute: attribute ) ) + @render: (attribute, params) -> + item = $( App.view('generic/richtext')(attribute: attribute) ) item.find('[contenteditable]').ce( mode: attribute.type maxlength: attribute.maxlength @@ -15,42 +14,42 @@ class App.UiElement.richtext new App[plugin.controller](params) if attribute.upload - item.append( $( App.view('generic/attachment')( attribute: attribute ) ) ) + @attachments = [] + item.append( $( App.view('generic/attachment')(attribute: attribute) ) ) - renderAttachment = (file) => - item.find('.attachments').append( App.view('generic/attachment_item')( - fileName: file.filename - fileSize: App.Utils.humanFileSize(file.size) - store_id: file.store_id - )) - item.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') + renderFile = (file) => + item.find('.attachments').append(App.view('generic/attachment_item')(file)) + @attachments.push file - # 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 - ) + if params && params.attachments + for file in params.attachments + renderFile(file) - # 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() + # remove items + item.find('.attachments').on('click', '.js-delete', (e) => + id = $(e.currentTarget).data('id') + @attachments = _.filter( + @attachments, + (item) -> + return if item.id.toString() is id.toString() + item ) - @attachments = [] + # delete attachment from storage + App.Ajax.request( + type: 'DELETE' + url: "#{App.Config.get('api_path')}/ticket_attachment_upload" + data: JSON.stringify(id: id), + processData: false + ) + + # remove attachment from dom + element = $(e.currentTarget).closest('.attachments') + $(e.currentTarget).closest('.attachment').remove() + if element.find('.attachment').length == 0 + element.empty() + ) + @progressBar = item.find('.attachmentUpload-progressBar') @progressText = item.find('.js-percentage') @attachmentPlaceholder = item.find('.attachmentPlaceholder') @@ -84,7 +83,6 @@ class App.UiElement.richtext # Called after received response from the server onCompleted: (response) => response = JSON.parse(response) - @attachments.push response.data @attachmentPlaceholder.removeClass('hide') @attachmentUpload.addClass('hide') @@ -93,7 +91,7 @@ class App.UiElement.richtext @progressBar.width(parseInt(0) + '%') @progressText.text('') - renderAttachment(response.data) + renderFile(response.data) item.find('input').val('') App.Log.debug 'UiElement.richtext', 'upload complete', response.data @@ -111,4 +109,5 @@ class App.UiElement.richtext ) ) App.Delay.set(u, 100, undefined, 'form_upload') + item diff --git a/app/assets/javascripts/app/controllers/_ui_element/richtext_search.coffee b/app/assets/javascripts/app/controllers/_ui_element/richtext_search.coffee new file mode 100644 index 000000000..9a5f05581 --- /dev/null +++ b/app/assets/javascripts/app/controllers/_ui_element/richtext_search.coffee @@ -0,0 +1,4 @@ +# coffeelint: disable=camel_case_classes +class App.UiElement.richtext_search + @render: (attribute) -> + $( App.view('generic/input')( attribute: attribute ) ) diff --git a/app/assets/javascripts/app/controllers/_ui_element/select_search.coffee b/app/assets/javascripts/app/controllers/_ui_element/select_search.coffee new file mode 100644 index 000000000..2b53dd6d1 --- /dev/null +++ b/app/assets/javascripts/app/controllers/_ui_element/select_search.coffee @@ -0,0 +1,35 @@ +# coffeelint: disable=camel_case_classes +class App.UiElement.select_search extends App.UiElement.ApplicationUiElement + @render: (attribute, params) -> + + # set multiple option + if attribute.multiple + attribute.multiple = 'multiple' + else + attribute.multiple = '' + + delete attribute.filter + + # build options list based on config + @getConfigOptionList(attribute, params) + + # build options list based on relation + @getRelationOptionList(attribute, params) + + # add null selection if needed + @addNullOption(attribute, params) + + # sort attribute.options + @sortOptions(attribute, params) + + # finde selected/checked item of list + @selectedOptions(attribute, params) + + # disable item of list + @disabledOptions(attribute, params) + + # filter attributes + @filterOption(attribute, params) + + # return item + $( App.view('generic/select')(attribute: attribute) ) diff --git a/app/assets/javascripts/app/controllers/_ui_element/ticket_selector.coffee b/app/assets/javascripts/app/controllers/_ui_element/ticket_selector.coffee index a88901169..f46eac253 100644 --- a/app/assets/javascripts/app/controllers/_ui_element/ticket_selector.coffee +++ b/app/assets/javascripts/app/controllers/_ui_element/ticket_selector.coffee @@ -22,9 +22,12 @@ class App.UiElement.ticket_selector '^timestamp$': ['before (absolute)', 'after (absolute)', 'before (relative)', 'after (relative)', 'within next (relative)', 'within last (relative)'] '^date$': ['before (absolute)', 'after (absolute)', 'before (relative)', 'after (relative)', 'within next (relative)', 'within last (relative)'] 'boolean$': ['is', 'is not'] + 'integer$': ['is', 'is not'] '^radio$': ['is', 'is not'] '^select$': ['is', 'is not'] + '^tree_select$': ['is', 'is not'] '^input$': ['contains', 'contains not'] + '^richtext$': ['contains', 'contains not'] '^textarea$': ['contains', 'contains not'] '^tag$': ['contains all', 'contains one', 'contains all not', 'contains one not'] @@ -34,9 +37,12 @@ class App.UiElement.ticket_selector '^timestamp$': ['before (absolute)', 'after (absolute)', 'before (relative)', 'after (relative)', 'within next (relative)', 'within last (relative)', 'has changed'] '^date$': ['before (absolute)', 'after (absolute)', 'before (relative)', 'after (relative)', 'within next (relative)', 'within last (relative)', 'has changed'] 'boolean$': ['is', 'is not', 'has changed'] + 'integer$': ['is', 'is not', 'has changed'] '^radio$': ['is', 'is not', 'has changed'] '^select$': ['is', 'is not', 'has changed'] + '^tree_select$': ['is', 'is not', 'has changed'] '^input$': ['contains', 'contains not', 'has changed'] + '^richtext$': ['contains', 'contains not', 'has changed'] '^textarea$': ['contains', 'contains not', 'has changed'] '^tag$': ['contains all', 'contains one', 'contains all not', 'contains one not'] diff --git a/app/assets/javascripts/app/controllers/agent_ticket_create.coffee b/app/assets/javascripts/app/controllers/agent_ticket_create.coffee index 617c83f0c..de43df318 100644 --- a/app/assets/javascripts/app/controllers/agent_ticket_create.coffee +++ b/app/assets/javascripts/app/controllers/agent_ticket_create.coffee @@ -14,6 +14,8 @@ class App.TicketCreate extends App.Controller # define default type @default_type = 'phone-in' + @formId = App.ControllerForm.formId() + # remember split info if exists @split = '' if @ticket_id && @article_id @@ -92,6 +94,10 @@ class App.TicketCreate extends App.Controller else @$('[name="cc"]').closest('.form-group').addClass('hide') + # show notice + @$('.js-note').addClass('hide') + @$(".js-note[data-type='#{type}']").removeClass('hide') + App.TaskManager.touch(@task_key) meta: => @@ -158,7 +164,7 @@ class App.TicketCreate extends App.Controller # get data / in case also ticket data for split buildScreen: (params) => - if !params.ticket_id && !params.article_id + if _.isEmpty(params.ticket_id) && _.isEmpty(params.article_id) if !_.isEmpty(params.customer_id) @render(options: { customer_id: params.customer_id }) return @@ -173,6 +179,7 @@ class App.TicketCreate extends App.Controller data: ticket_id: params.ticket_id article_id: params.article_id + form_id: @formId processData: true success: (data, status, xhr) => @@ -194,6 +201,9 @@ class App.TicketCreate extends App.Controller else t.body = App.Utils.text2html(a.body) + # add attachments + t.attachments = data.attachments + # render page @render(options: t) ) @@ -201,23 +211,20 @@ class App.TicketCreate extends App.Controller render: (template = {}) -> # get params - params = {} + params = @prefilledParams || {} if template && !_.isEmpty(template.options) params = template.options else if App.TaskManager.get(@task_key) && !_.isEmpty(App.TaskManager.get(@task_key).state) params = App.TaskManager.get(@task_key).state + if !_.isEmpty(params['form_id']) + @formId = params['form_id'] - if params['form_id'] - @form_id = params['form_id'] - else - @form_id = App.ControllerForm.formId() - - @html App.view('agent_ticket_create')( + @html(App.view('agent_ticket_create')( head: 'New Ticket' agent: @permissionCheck('ticket.agent') admin: @permissionCheck('admin') - form_id: @form_id - ) + form_id: @formId + )) signatureChanges = (params, attribute, attributes, classname, form, ui) => if attribute && attribute.name is 'group_id' @@ -272,7 +279,7 @@ class App.TicketCreate extends App.Controller } new App.ControllerForm( el: @$('.ticket-form-top') - form_id: @form_id + form_id: @formId model: App.Ticket screen: 'create_top' events: @@ -288,14 +295,14 @@ class App.TicketCreate extends App.Controller new App.ControllerForm( el: @$('.article-form-top') - form_id: @form_id + form_id: @formId model: App.TicketArticle screen: 'create_top' params: params ) new App.ControllerForm( el: @$('.ticket-form-middle') - form_id: @form_id + form_id: @formId model: App.Ticket screen: 'create_middle' events: @@ -310,7 +317,7 @@ class App.TicketCreate extends App.Controller ) new App.ControllerForm( el: @$('.ticket-form-bottom') - form_id: @form_id + form_id: @formId model: App.Ticket screen: 'create_bottom' events: @@ -420,7 +427,7 @@ class App.TicketCreate extends App.Controller body: params.body type_id: type.id sender_id: sender.id - form_id: @form_id + form_id: @formId content_type: 'text/html' } else @@ -432,7 +439,7 @@ class App.TicketCreate extends App.Controller body: params.body type_id: type.id sender_id: sender.id - form_id: @form_id + form_id: @formId content_type: 'text/html' } diff --git a/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar.coffee b/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar.coffee index 88b151449..1d34f85d6 100644 --- a/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar.coffee +++ b/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar.coffee @@ -32,9 +32,7 @@ class App.TicketCreateSidebar extends App.Controller params: @params query: @query ) - item = @sidebarBackends[key].sidebarItem() - if item - @sidebarItems.push item + @sidebarItems.push @sidebarBackends[key] new App.Sidebar( el: @el diff --git a/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar_customer.coffee b/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar_customer.coffee index cbfd4e7e9..8de9ea04a 100644 --- a/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar_customer.coffee +++ b/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar_customer.coffee @@ -1,26 +1,62 @@ class SidebarCustomer extends App.Controller sidebarItem: => return if !@permissionCheck('ticket.agent') - return if !@params.customer_id - { - head: 'Customer' - name: 'customer' - icon: 'person' - actions: [ + return if _.isEmpty(@params.customer_id) + @item = { + name: 'customer' + badgeCallback: @badgeRender + sidebarHead: 'Customer' + sidebarCallback: @showCustomer + sidebarActions: [ { title: 'Edit Customer' name: 'customer-edit' callback: @editCustomer }, ] - callback: @showCustomer } + metaBadge: (user) => + counter = '' + cssClass = '' + counter = @sidebarItemCounter(user) + + if @Config.get('ui_sidebar_open_ticket_indicator_colored') is true + if counter == 1 + cssClass = 'tabsSidebar-tab-count--warning' + if counter > 1 + cssClass = 'tabsSidebar-tab-count--danger' + + { + name: 'customer' + icon: 'person' + counterPossible: true + counter: counter + cssClass: cssClass + } + + badgeRender: (el) => + @badgeEl = el + if App.User.exists(@params.customer_id) + user = App.User.find(@params.customer_id) + @badgeRenderLocal(user) + + badgeRenderLocal: (user) => + @badgeEl.html(App.view('generic/sidebar_tabs_item')(@metaBadge(user))) + + sidebarItemCounter: (user) -> + counter = '' + if user && user.preferences && user.preferences.tickets_open + counter = user.preferences.tickets_open + counter + showCustomer: (el) => - @el = el + @elSidebar = el + return if _.isEmpty(@params.customer_id) new App.WidgetUser( - el: @el + el: @elSidebar user_id: @params.customer_id + callback: @badgeRenderLocal ) editCustomer: => @@ -32,7 +68,7 @@ class SidebarCustomer extends App.Controller title: 'Users' object: 'User' objects: 'Users' - container: @el.closest('.content') + container: @elSidebar.closest('.content') ) App.Config.set('200-Customer', SidebarCustomer, 'TicketCreateSidebar') diff --git a/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar_organization.coffee b/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar_organization.coffee index db2a9239d..de2bc6b26 100644 --- a/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar_organization.coffee +++ b/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar_organization.coffee @@ -6,24 +6,25 @@ class SidebarOrganization extends App.Controller customer = App.User.find(@params.customer_id) @organization_id = customer.organization_id return if !@organization_id - { - head: 'Organization' + @item = { name: 'organization' - icon: 'group' - actions: [ + badgeIcon: 'group' + sidebarHead: 'Organization' + sidebarCallback: @showOrganization + sidebarActions: [ { title: 'Edit Organization' name: 'organization-edit' callback: @editOrganization }, ] - callback: @showOrganization } + @item showOrganization: (el) => - @el = el + @elSidebar = el new App.WidgetOrganization( - el: @el + el: @elSidebar organization_id: @organization_id ) @@ -35,7 +36,7 @@ class SidebarOrganization extends App.Controller title: 'Organizations' object: 'Organization' objects: 'Organizations' - container: @el.closest('.content') + container: @elSidebar.closest('.content') ) App.Config.set('300-Organization', SidebarOrganization, 'TicketCreateSidebar') diff --git a/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar_template.coffee b/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar_template.coffee index bb724bc83..318813dec 100644 --- a/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar_template.coffee +++ b/app/assets/javascripts/app/controllers/agent_ticket_create/sidebar_template.coffee @@ -1,13 +1,15 @@ class SidebarTemplate extends App.Controller sidebarItem: => return if !@permissionCheck('ticket.agent') - { - head: 'Templates' - name: 'template' - icon: 'templates' - actions: [] - callback: @showTemplates + @item = { + name: 'template' + badgeIcon: 'templates' + badgeCallback: @badgeRender + sidebarHead: 'Templates' + sidebarActions: [] + sidebarCallback: @showTemplates } + @item showTemplates: (el) => @el = el diff --git a/app/assets/javascripts/app/controllers/chat.coffee b/app/assets/javascripts/app/controllers/chat.coffee index 2966e3b4c..7414c4c12 100644 --- a/app/assets/javascripts/app/controllers/chat.coffee +++ b/app/assets/javascripts/app/controllers/chat.coffee @@ -35,7 +35,7 @@ class App.CustomerChat extends App.Controller active_agent_ids: [] @render() - @on 'layout-has-changed', @propagateLayoutChange + @on('layout-has-changed', @propagateLayoutChange) # update navbar on new status @bind('chat_status_agent', (data) => @@ -163,6 +163,12 @@ class App.CustomerChat extends App.Controller @title 'Customer Chat', true @navupdate '#customer_chat' + if params.session_id + callback = (session) => + @addChat(session) + App.ChatSession.full(params.session_id, callback) + @navigate '#customer_chat' + active: (state) => return @shown if state is undefined @shown = state @@ -264,10 +270,11 @@ class App.CustomerChat extends App.Controller addChat: (session) -> return if @chatWindows[session.session_id] - chat = new ChatWindow + chat = new ChatWindow( session: session removeCallback: @removeChat messageCallback: @updateNavMenu + ) @workspace.append chat.el chat.render() @@ -289,7 +296,7 @@ class App.CustomerChat extends App.Controller propagateLayoutChange: (event) => # adjust scroll position on layoutChange for session_id, chat of @chatWindows - chat.trigger 'layout-changed' + chat.trigger('layout-changed') acceptChat: => return if @windowCount() >= @maxChatWindows @@ -324,19 +331,6 @@ class App.CustomerChat extends App.Controller currentPosition: => @$('.main').scrollTop() -class CustomerChatRouter extends App.ControllerPermanent - requiredPermission: 'chat.agent' - constructor: (params) -> - super - - App.TaskManager.execute( - key: 'CustomerChat' - controller: 'CustomerChat' - params: {} - show: true - persistent: true - ) - class ChatWindow extends App.Controller className: 'chat-window' @@ -348,6 +342,9 @@ class ChatWindow extends App.Controller 'click .js-close': 'close' 'click .js-disconnect': 'disconnect' 'click .js-scrollHint': 'onScrollHintClick' + 'click .js-info': 'toggleMeta' + 'click .js-createTicket': 'ticketCreate' + 'submit .js-metaForm': 'sendMetaForm' elements: '.js-customerChatInput': 'input' @@ -355,8 +352,11 @@ class ChatWindow extends App.Controller '.js-close': 'closeButton' '.js-disconnect': 'disconnectButton' '.js-body': 'body' + '.js-meta': 'meta' + '.js-name': 'metaName' '.js-scrollHolder': 'scrollHolder' '.js-scrollHint': 'scrollHint' + '.js-metaForm': 'metaForm' sounds: message: new Audio('assets/sounds/chat_message.mp3') @@ -374,9 +374,11 @@ class ChatWindow extends App.Controller @scrollSnapTolerance = 10 # pixels @chat = App.Chat.find(@session.chat_id) - @name = "#{@chat.displayName()} ##{@session.id}" + @name = @chat.displayName() + if @session && !_.isEmpty(@session.name) + @name = @session.name - @on 'layout-change', @onLayoutChange + @on('layout-change', @onLayoutChange) @bind('chat_session_typing', (data) => return if data.session_id isnt @session.session_id @@ -413,12 +415,45 @@ class ChatWindow extends App.Controller onLayoutChange: => @scrollToBottom() - render: -> - @html App.view('customer_chat/chat_window') - name: @name + toggleMeta: => + if @meta.hasClass('hidden') + @showMeta() + else + @hideMeta() - @el.one 'transitionend', @onTransitionend - @scrollHolder.scroll @detectScrolledtoBottom + hideMeta: => + @body.removeClass('hidden') + @meta.addClass('hidden') + @sendMetaForm() + + showMeta: => + @body.addClass('hidden') + @meta.removeClass('hidden') + + sendMetaForm: (e) => + if e + e.preventDefault() + params = @formParam(@metaForm) + + App.WebSocket.send( + event:'chat_session_update' + data: + session_id: @session.session_id + name: params.name + tags: params.tags + ) + + if !_.isEmpty(params.name) + @metaName.text(params.name) + + render: -> + @html App.view('customer_chat/chat_window')( + name: @name + session: @session + ) + + @el.one('transitionend', @onTransitionend) + @scrollHolder.scroll(@detectScrolledtoBottom) # force repaint @el.prop('offsetHeight') @@ -426,18 +461,24 @@ class ChatWindow extends App.Controller # @addMessage 'Hello. My name is Roger, how can I help you?', 'agent' if @session + + # set chat to offline if state is already closed + activeChat = true + if @session.state is 'closed' + activeChat = false + if @session && @session.preferences && @session.preferences.url - @addNoticeMessage(@session.preferences.url) + @addNoticeMessage(@session.preferences.url, undefined, activeChat) if @session.messages for message in @session.messages if message.created_by_id - @addMessage message.content, 'agent' + @addMessage(message.content, 'agent', false, activeChat) else - @addMessage message.content, 'customer' + @addMessage(message.content, 'customer', false, activeChat) # send init reply - if !@session.messages || _.isEmpty(@session.messages) + if activeChat && _.isEmpty(@session.messages) preferences = @Session.get('preferences') if preferences.chat && preferences.chat.phrase phrases = preferences.chat.phrase[@session.chat_id] @@ -447,20 +488,9 @@ class ChatWindow extends App.Controller @input.html(phrase) @sendMessage(1600) - @$('.js-info').popover( - trigger: 'hover' - html: true - animation: false - delay: 0 - placement: 'bottom' - container: 'body' # place in body do prevent it from animating - title: -> - App.i18n.translateContent('Details') - content: => - App.view('customer_chat/chat_window_info')( - session: @session - ) - ) + # set chat to offline if state is already closed + if !activeChat + @goOffline() # show text module UI new App.WidgetTextModule( @@ -470,6 +500,18 @@ class ChatWindow extends App.Controller config: App.Config.all() ) + configureAttributesOutbound = [ + { name: 'name', display: 'Name', tag: 'input', null: true, }, + { name: 'tags', display: 'Tags', tag: 'tag', null: true, }, + ] + new App.ControllerForm( + el: @$('.js-metaForm') + model: + configure_attributes: configureAttributesOutbound + className: '' + params: @session + ) + focus: => @input.focus() @@ -498,7 +540,8 @@ class ChatWindow extends App.Controller @goOffline() close: => - @el.one 'transitionend', { callback: @release }, @onTransitionend + @sendMetaForm() + @el.one('transitionend', { callback: @release }, @onTransitionend) @el.removeClass('is-open') if @removeCallback @removeCallback(@session.session_id) @@ -577,7 +620,8 @@ class ChatWindow extends App.Controller ) @delay(send, delay) - @addMessage content, 'agent' + @hideMeta() + @addMessage(content, 'agent') @input.html('') updateModified: (state) => @@ -614,18 +658,19 @@ class ChatWindow extends App.Controller @messageCallback(@session.session_id) @unreadMessagesCounter = 0 - addMessage: (message, sender, isNew) => - @maybeAddTimestamp() + addMessage: (message, sender, isNew, useMaybeAddTimestamp = true) => + @maybeAddTimestamp() if useMaybeAddTimestamp @lastAddedType = sender - @body.append App.view('customer_chat/chat_message') + @body.append App.view('customer_chat/chat_message')( message: message sender: sender isNew: isNew timestamp: Date.now() + ) - @scrollToBottom showHint: true + @scrollToBottom(showHint: true) showWritingLoader: => if !@isTyping @@ -667,33 +712,37 @@ class ChatWindow extends App.Controller @lastAddedType = 'timestamp' addTimestamp: (label, time) => - @body.append App.view('customer_chat/chat_timestamp') + @body.append App.view('customer_chat/chat_timestamp')( label: label time: time + ) updateLastTimestamp: (label, time) -> @body .find('.js-timestamp') .last() - .replaceWith App.view('customer_chat/chat_timestamp') + .replaceWith App.view('customer_chat/chat_timestamp')( label: label time: time + ) - addStatusMessage: (message, args) -> - @maybeAddTimestamp() + addStatusMessage: (message, args, useMaybeAddTimestamp = true) -> + @maybeAddTimestamp() if useMaybeAddTimestamp - @body.append App.view('customer_chat/chat_status_message') + @body.append App.view('customer_chat/chat_status_message')( message: message args: args + ) @scrollToBottom() - addNoticeMessage: (message, args) -> - @maybeAddTimestamp() + addNoticeMessage: (message, args, useMaybeAddTimestamp = true) -> + @maybeAddTimestamp() if useMaybeAddTimestamp - @body.append App.view('customer_chat/chat_notice_message') + @body.append App.view('customer_chat/chat_notice_message')( message: message args: args + ) @scrollToBottom() @@ -717,6 +766,37 @@ class ChatWindow extends App.Controller else if showHint @showScrollHint() + ticketCreate: (e) => + e.preventDefault() + + id = Math.floor( Math.random() * 99999 ) + @navigate "#ticket/create/id/#{id}" + + # cleanup params + fqdn = App.Config.get('fqdn') + http_type = App.Config.get('http_type') + url = '' + session = @session + + # in case we do not have a model, create one + if session && !session.uiUrl + session = new App.ChatSession(session) + if session && session.uiUrl + url = session.uiUrl() + + clean_params = + id: id + prefilledParams: + body: "#{http_type}://#{fqdn}/#{url}" + title: 'Chat' + + App.TaskManager.execute( + key: "TicketCreateScreen-#{id}" + controller: 'TicketCreate' + params: clean_params + show: true + ) + class Setting extends App.ControllerModal buttonClose: true buttonCancel: true @@ -784,6 +864,24 @@ class Setting extends App.ControllerModal msg: App.i18n.translateContent(data.message) ) +class CustomerChatRouter extends App.ControllerPermanent + requiredPermission: 'chat.agent' + constructor: (params) -> + super + + # cleanup params + clean_params = + session_id: params.session_id + + App.TaskManager.execute( + key: 'CustomerChat' + controller: 'CustomerChat' + params: clean_params + show: true + persistent: true + ) + App.Config.set('customer_chat', CustomerChatRouter, 'Routes') +App.Config.set('customer_chat/session/:session_id', CustomerChatRouter, 'Routes') App.Config.set('CustomerChat', { controller: 'CustomerChat', permission: ['chat.agent'] }, 'permanentTask') App.Config.set('CustomerChat', { prio: 1200, parent: '', name: 'Customer Chat', target: '#customer_chat', key: 'CustomerChat', shown: false, permission: ['chat.agent'], class: 'chat' }, 'NavBar') diff --git a/app/assets/javascripts/app/controllers/getting_started.coffee b/app/assets/javascripts/app/controllers/getting_started.coffee index 8c2fbb71d..de73f124e 100644 --- a/app/assets/javascripts/app/controllers/getting_started.coffee +++ b/app/assets/javascripts/app/controllers/getting_started.coffee @@ -337,11 +337,9 @@ class Base extends App.WizardFullScreen @hideAlerts() @disable(e) - # get params @params = @formParam(e.target) - - # add logo @params.logo = @logoPreview.attr('src') + @params.locale_default = App.i18n.detectBrowserLocale() store = (logoResizeDataUrl) => @params.logo_resize = logoResizeDataUrl @@ -354,7 +352,7 @@ class Base extends App.WizardFullScreen success: (data, status, xhr) => if data.result is 'ok' for key, value of data.settings - App.Config.set( key, value ) + App.Config.set(key, value) if App.Config.get('system_online_service') @navigate 'getting_started/channel/email_pre_configured' else diff --git a/app/assets/javascripts/app/controllers/idoit_object_selector.coffee b/app/assets/javascripts/app/controllers/idoit_object_selector.coffee index d2b7f2065..2a7053e50 100644 --- a/app/assets/javascripts/app/controllers/idoit_object_selector.coffee +++ b/app/assets/javascripts/app/controllers/idoit_object_selector.coffee @@ -3,6 +3,7 @@ class App.IdoitObjectSelector extends App.ControllerModal buttonCancel: true buttonSubmit: true head: 'i-doit' + lastSearchTermEmpty: false content: -> @ajax( @@ -44,16 +45,24 @@ class App.IdoitObjectSelector extends App.ControllerModal '' search: (filter) => + if _.isEmpty(filter.type) && _.isEmpty(filter.title) + @lastSearchTermEmpty = true + @renderResult() + return + if _.isEmpty(filter.type) + delete filter.type if _.isEmpty(filter.title) delete filter.title else filter.title = "%#{filter.title}%" + @lastSearchTermEmpty = false @ajax( id: 'idoit-object-selector' type: 'POST' url: "#{@apiPath}/integration/idoit" data: JSON.stringify(method: 'cmdb.objects', filter: filter) success: (data, status, xhr) => + return if @lastSearchTermEmpty @renderResult(data.response.result) error: (xhr, status, error) => diff --git a/app/assets/javascripts/app/controllers/import_zendesk.coffee b/app/assets/javascripts/app/controllers/import_zendesk.coffee index 1858adde1..c49004230 100644 --- a/app/assets/javascripts/app/controllers/import_zendesk.coffee +++ b/app/assets/javascripts/app/controllers/import_zendesk.coffee @@ -154,35 +154,35 @@ class Index extends App.ControllerContent processData: true success: (data, status, xhr) => - if data.result is 'import_done' - window.location.reload() - return - - if data.result is 'error' - @$('.js-error').removeClass('hide') - @$('.js-error').html(App.i18n.translateContent(data.message)) - else - @$('.js-error').addClass('hide') - - if data.message is 'not running' && @updateMigrationDisplayLoop > 16 + if _.isEmpty(data.result) && @updateMigrationDisplayLoop > 16 @$('.js-error').removeClass('hide') @$('.js-error').html(App.i18n.translateContent('Background process did not start or has not finished! Please contact your support.')) return - if data.result is 'in_progress' - for key, item of data.data - if item.done > item.total - item.done = item.total + if !_.isEmpty(data.result['error']) + @$('.js-error').removeClass('hide') + @$('.js-error').html(App.i18n.translateContent(data.result['error'])) + else + @$('.js-error').addClass('hide') - if key == 'Ticket' && item.total >= 1000 + if !_.isEmpty(data.finished_at) && _.isEmpty(data.result['error']) + window.location.reload() + return + + if !_.isEmpty(data.result) + for model, stats of data.result + if stats.sum > stats.total + stats.sum = stats.total + + if model == 'Ticket' && stats.total >= 1000 @ticketCountInfo.removeClass('hide') - element = @$('.js-' + key.toLowerCase() ) - element.find('.js-done').text(item.done) - element.find('.js-total').text(item.total) - element.find('progress').attr('max', item.total ) - element.find('progress').attr('value', item.done ) - if item.total <= item.done + element = @$('.js-' + model.toLowerCase() ) + element.find('.js-done').text(stats.sum) + element.find('.js-total').text(stats.total) + element.find('progress').attr('max', stats.total ) + element.find('progress').attr('value', stats.sum ) + if stats.total <= stats.sum element.addClass('is-done') else element.removeClass('is-done') diff --git a/app/assets/javascripts/app/controllers/layout_ref.coffee b/app/assets/javascripts/app/controllers/layout_ref.coffee index f4dc268e7..07ee4bafa 100644 --- a/app/assets/javascripts/app/controllers/layout_ref.coffee +++ b/app/assets/javascripts/app/controllers/layout_ref.coffee @@ -349,7 +349,7 @@ class LayoutRefCommunicationReply extends App.ControllerContent file = @uploadQueue.shift() # console.log "working of", file, "from", @uploadQueue - @fakeUpload file.name, file.size, @workOfUploadQueue + @fakeUpload(file.name, file.size, @workOfUploadQueue) humanFileSize: (size) -> i = Math.floor( Math.log(size) / Math.log(1024) ) @@ -363,27 +363,27 @@ class LayoutRefCommunicationReply extends App.ControllerContent @attachmentPlaceholder.removeClass('hide') @attachmentUpload.addClass('hide') - fakeUpload: (fileName, fileSize, callback) -> + fakeUpload: (filename, size, callback) -> @attachmentPlaceholder.addClass('hide') @attachmentUpload.removeClass('hide') progress = 0 - duration = fileSize / 1024 + duration = size / 1024 for i in [0..100] setTimeout @updateUploadProgress, i*duration/100 , i setTimeout (=> callback() - @renderAttachment(fileName, fileSize) + @renderAttachment(filename, size) ), duration - renderAttachment: (fileName, fileSize) => - @attachments.push([fileName, fileSize]) - @attachmentsHolder.append App.view('generic/attachment_item') - fileName: fileName - fileSize: @humanFileSize(fileSize) - + renderAttachment: (filename, size) => + @attachments.push([filename, size]) + @attachmentsHolder.append(App.view('generic/attachment_item') + filename: filename + size: @humanFileSize(size) + ) App.Config.set( 'layout_ref/communication_reply/:content', LayoutRefCommunicationReply, 'Routes' ) @@ -2121,7 +2121,7 @@ class TwitterConversationRef extends App.ControllerContent open: 88 closed: 20 - maxTextLength: 140 + maxTextLength: 280 warningTextLength: 10 constructor: -> diff --git a/app/assets/javascripts/app/controllers/overview.coffee b/app/assets/javascripts/app/controllers/overview.coffee index 3a8fae430..377328750 100644 --- a/app/assets/javascripts/app/controllers/overview.coffee +++ b/app/assets/javascripts/app/controllers/overview.coffee @@ -23,17 +23,22 @@ class Index extends App.ControllerSubContent ] container: @el.closest('.content') large: true - dndCallback: => + dndCallback: (e, item) => items = @el.find('table > tbody > tr') - order = [] + prios = [] prio = 0 for item in items prio += 1 id = $(item).data('id') - overview = App.Overview.find(id) - if overview.prio isnt prio - overview.prio = prio - overview.save() + prios.push [id, prio] + + @ajax( + id: 'overview_prio' + type: 'POST' + url: "#{@apiPath}/overviews_prio" + processData: true + data: JSON.stringify(prios: prios) + ) ) App.Config.set('Overview', { prio: 2300, name: 'Overviews', parent: '#manage', target: '#manage/overviews', controller: Index, permission: ['admin.overview'] }, 'NavBarAdmin') diff --git a/app/assets/javascripts/app/controllers/report.coffee b/app/assets/javascripts/app/controllers/report.coffee index 90c3c9d1f..c8cc04620 100644 --- a/app/assets/javascripts/app/controllers/report.coffee +++ b/app/assets/javascripts/app/controllers/report.coffee @@ -108,33 +108,40 @@ class Graph extends App.ControllerContent @render() - render: => + update: (data) => - update = (data) => + # show only selected lines + dataNew = {} + for key, value of data.data + if @params.backendSelected[key] is true + dataNew[key] = value + @ui.storeParams() - # show only selected lines - dataNew = {} - for key, value of data.data - if @params.backendSelected[key] is true - dataNew[key] = value - @ui.storeParams() + if !@lastNewData + @lastNewData = {} - if !@lastNewData - @lastNewData = {} + return if @lastNewData && JSON.stringify(dataNew) is JSON.stringify(@lastNewData) + @lastNewData = dataNew - return if @lastNewData && JSON.stringify(dataNew) is JSON.stringify(@lastNewData) - @lastNewData = dataNew - - @draw(dataNew) - t = new Date - @el.find('#download-chart').html(t.toString()) - new Download( + @draw(dataNew) + t = new Date + @el.find('#download-chart').html(t.toString()) + if @downloadWidget + @downloadWidget.update( + config: @config + params: @params + ui: @ui + ) + else + @downloadWidget = new Download( el: @el.find('.js-dataDownload') config: @config params: @params ui: @ui ) + render: => + url = "#{@apiPath}/reports/generate" interval = 5 * 60000 if @params.timeRange is 'year' @@ -142,9 +149,9 @@ class Graph extends App.ControllerContent if @params.timeRange is 'month' interval = 60000 if @params.timeRange is 'week' - interval = 40000 + interval = 50000 if @params.timeRange is 'day' - interval = 20000 + interval = 30000 if @params.timeRange is 'realtime' interval = 10000 @@ -164,7 +171,7 @@ class Graph extends App.ControllerContent ) processData: true success: (data) => - update(data) + @update(data) @delay(@render, interval, 'report-update', 'page') ) @@ -215,7 +222,7 @@ class Graph extends App.ControllerContent class Download extends App.Controller events: - 'click .js-dataDownloadBackendSelector': 'tableUpdate' + 'click .js-dataDownloadBackendSelector': 'selectBackend' constructor: (data) -> @@ -225,7 +232,24 @@ class Download extends App.Controller super @render() - render: -> + selectBackend: (e) => + e.preventDefault() + @el.find('.js-dataDownloadBackendSelector').parent().removeClass('active') + $(e.target).parent().addClass('active') + @profileSelectedId = $(e.target).data('profile-id') + @params.downloadBackendSelected = $(e.target).data('backend') + @ui.storeParams() + @table = false + @render() + + update: => + @render() + + render: => + + if !@contentRendered + @contentRendered = true + @html(App.view('report/download_content')()) reports = [] @@ -244,44 +268,84 @@ class Download extends App.Controller @profileSelectedId = key profiles.push App.ReportProfile.find(key) - @html App.view('report/download_header')( + downloadHeaderHtml = App.view('report/download_header')( reports: reports profiles: profiles downloadBackendSelected: @params.downloadBackendSelected metric: @config.metric[@params.metric] ) + if downloadHeaderHtml isnt @downloadHeaderHtml + @el.find('.js-dataDownloadHeader').html(downloadHeaderHtml) + @downloadHeaderHtml = downloadHeaderHtml @tableUpdate() - tableUpdate: (e) => - if e - e.preventDefault() - @el.find('.js-dataDownloadBackendSelector').parent().removeClass('active') - $(e.target).parent().addClass('active') - @profileSelectedId = $(e.target).data('profile-id') - @params.downloadBackendSelected = $(e.target).data('backend') - @ui.storeParams() + tableRender: (tickets, count) => + if _.isEmpty(tickets) + @$('.js-dataDownloadButton').html('') + @$('.js-dataDownloadTable').html('') + return - table = (tickets, count) => - url = '#ticket/zoom/' - if App.Config.get('import_mode') - url = App.Config.get('import_otrs_endpoint') + '/index.pl?Action=AgentTicketZoom;TicketID=' - if _.isEmpty(tickets) - @el.find('.js-dataDownloadTable').html('') - else - profile_id = 0 - for key, value of @params.profileSelected - if value - profile_id = key - downloadUrl = "#{@apiPath}/reports/sets?sheet=true;metric=#{@params.metric};year=#{@params.year};month=#{@params.month};week=#{@params.week};day=#{@params.day};timeRange=#{@params.timeRange};profile_id=#{profile_id};downloadBackendSelected=#{@params.downloadBackendSelected}" - html = App.view('report/download_list')( - tickets: tickets - count: count - url: url - download: downloadUrl - ) - @el.find('.js-dataDownloadTable').html(html) + profile_id = 0 + for key, value of @params.profileSelected + if value + profile_id = key + downloadUrl = "#{@apiPath}/reports/sets?sheet=true;metric=#{@params.metric};year=#{@params.year};month=#{@params.month};week=#{@params.week};day=#{@params.day};timeRange=#{@params.timeRange};profile_id=#{profile_id};downloadBackendSelected=#{@params.downloadBackendSelected}" + @$('.js-dataDownloadButton').html(App.view('report/download_button')( + count: count + downloadUrl: downloadUrl + )) + openTicket = (id,e) => + ticket = App.Ticket.findNative(id) + @navigate ticket.uiUrl() + callbackTicketTitleAdd = (value, object, attribute, attributes) -> + attribute.title = object.title + value + callbackLinkToTicket = (value, object, attribute, attributes) -> + attribute.link = object.uiUrl() + value + callbackIconHeader = (headers) -> + attribute = + name: 'icon' + display: '' + translation: false + width: '28px' + displayWidth:28 + unresizable: true + headers.unshift(0) + headers[0] = attribute + headers + callbackIcon = (value, object, attribute, header) -> + value = ' ' + attribute.class = object.iconClass() + attribute.link = '' + attribute.title = object.iconTitle() + value + + params = + el: @el.find('.js-dataDownloadTable') + model: App.Ticket + objects: tickets + overviewAttributes: ['number', 'title', 'state', 'group', 'created_at'] + bindRow: + events: + 'click': openTicket + callbackHeader: [ callbackIconHeader ] + callbackAttributes: + icon: + [ callbackIcon ] + title: + [ callbackLinkToTicket, callbackTicketTitleAdd ] + number: + [ callbackLinkToTicket, callbackTicketTitleAdd ] + + if !@table + @table = new App.ControllerTable(params) + else + @table.update(objects: tickets) + + tableUpdate: => @ajax( id: 'report_download' type: 'POST' @@ -298,15 +362,14 @@ class Download extends App.Controller downloadBackendSelected: @params.downloadBackendSelected ) processData: true - success: (data) -> + success: (data) => App.Collection.loadAssets(data.assets) ticket_collection = [] if data.ticket_ids for record_id in data.ticket_ids - ticket = App.Ticket.fullLocal( record_id ) + ticket = App.Ticket.fullLocal(record_id) ticket_collection.push ticket - - table(ticket_collection, data.count) + @tableRender(ticket_collection, data.count) ) class TimeRangePicker extends App.Controller diff --git a/app/assets/javascripts/app/controllers/search.coffee b/app/assets/javascripts/app/controllers/search.coffee index 9a6f45052..67e9c009a 100644 --- a/app/assets/javascripts/app/controllers/search.coffee +++ b/app/assets/javascripts/app/controllers/search.coffee @@ -79,6 +79,7 @@ class App.Search extends App.Controller @tabs = [] for model in App.Config.get('models_searchable') + model = model.replace(/::/, '') tab = name: model model: model diff --git a/app/assets/javascripts/app/controllers/ticket_customer.coffee b/app/assets/javascripts/app/controllers/ticket_customer.coffee index edb5df0c0..713761a10 100644 --- a/app/assets/javascripts/app/controllers/ticket_customer.coffee +++ b/app/assets/javascripts/app/controllers/ticket_customer.coffee @@ -6,7 +6,7 @@ class App.TicketCustomer extends App.ControllerModal content: -> configure_attributes = [ - { name: 'customer_id', display: 'Customer', tag: 'user_autocompletion', null: false, placeholder: 'Enter Person or Organization/Company', minLengt: 2, disableCreateObject: true }, + { name: 'customer_id', display: 'Customer', tag: 'user_autocompletion', null: false, placeholder: 'Enter Person or Organization/Company', minLengt: 2, disableCreateObject: false }, ] controller = new App.ControllerForm( model: @@ -18,8 +18,19 @@ class App.TicketCustomer extends App.ControllerModal onSubmit: (e) => params = @formParam(e.target) - @customer_id = params['customer_id'] + ticket = App.Ticket.find(@ticket_id) + ticket.customer_id = params['customer_id'] + errors = ticket.validate() + if !_.isEmpty(errors) + @log 'error', errors + @formValidate( + form: e.target + errors: errors + ) + return + + @customer_id = params['customer_id'] callback = => # close modal diff --git a/app/assets/javascripts/app/controllers/ticket_overview.coffee b/app/assets/javascripts/app/controllers/ticket_overview.coffee index de9d4df17..40f9b7a7e 100644 --- a/app/assets/javascripts/app/controllers/ticket_overview.coffee +++ b/app/assets/javascripts/app/controllers/ticket_overview.coffee @@ -956,7 +956,6 @@ class Table extends App.Controller ticketListShow = [] for ticket in tickets ticketListShow.push App.Ticket.find(ticket.id) - console.log('overview', overview) @overview = App.Overview.find(overview.id) @table.update( overviewAttributes: @overview.view.s diff --git a/app/assets/javascripts/app/controllers/ticket_zoom.coffee b/app/assets/javascripts/app/controllers/ticket_zoom.coffee index 094a15f30..d05db0448 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom.coffee @@ -461,6 +461,7 @@ class App.TicketZoom extends App.Controller ui: @ highligher: @highligher ticket_article_ids: @ticket_article_ids + form_id: @form_id ) new App.TicketCustomerAvatar( diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/article_action/delete.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/article_action/delete.coffee new file mode 100644 index 000000000..7436c15f6 --- /dev/null +++ b/app/assets/javascripts/app/controllers/ticket_zoom/article_action/delete.coffee @@ -0,0 +1,34 @@ +class Delete + @action: (actions, ticket, article, ui) -> + return actions if ui.permissionCheck('ticket.customer') + + if article.type.name is 'note' + user = undefined + if App.Session.get('id') == article.created_by_id + user = App.User.find(App.Session.get('id')) + if user.permission('ticket.agent') + actions.push { + name: 'delete' + type: 'delete' + icon: 'trash' + href: '#' + } + + actions + + @perform: (articleContainer, type, ticket, article, ui) -> + return true if type isnt 'delete' + + callback = -> + article = App.TicketArticle.find(article.id) + article.destroy() + + new App.ControllerConfirm( + message: 'Sure?' + callback: callback + container: ui.el.closest('.content') + ) + + true + +App.Config.set('900-Delete', Delete, 'TicketZoomArticleAction') diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/article_action/email_reply.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/article_action/email_reply.coffee new file mode 100644 index 000000000..f233d2bb2 --- /dev/null +++ b/app/assets/javascripts/app/controllers/ticket_zoom/article_action/email_reply.coffee @@ -0,0 +1,203 @@ +class EmailReply extends App.Controller + @action: (actions, ticket, article, ui) -> + return actions if ui.permissionCheck('ticket.customer') + + group = ticket.group + if group.email_address_id && (article.type.name is 'email' || article.type.name is 'web') + actions.push { + name: 'reply' + type: 'emailReply' + icon: 'reply' + href: '#' + } + recipients = [] + if article.sender.name is 'Customer' + if article.from + localRecipients = emailAddresses.parseAddressList(article.from) + if localRecipients + recipients = recipients.concat localRecipients + if article.to + localRecipients = emailAddresses.parseAddressList(article.to) + if localRecipients + recipients = recipients.concat localRecipients + if article.cc + localRecipients = emailAddresses.parseAddressList(article.cc) + if localRecipients + recipients = recipients.concat localRecipients + + # remove system addresses + localAddresses = App.EmailAddress.all() + forgeinRecipients = [] + recipientUsed = {} + for recipient in recipients + if !_.isEmpty(recipient.address) + localRecipientAddress = recipient.address.toString().toLowerCase() + if !recipientUsed[localRecipientAddress] + recipientUsed[localRecipientAddress] = true + localAddress = false + for address in localAddresses + if localRecipientAddress is address.email.toString().toLowerCase() + recipientUsed[localRecipientAddress] = true + localAddress = true + if !localAddress + forgeinRecipients.push recipient + + # check if reply all is neede + if forgeinRecipients.length > 1 + actions.push { + name: 'reply all' + type: 'emailReplyAll' + icon: 'reply-all' + href: '#' + } + + actions.push { + name: 'forward' + type: 'emailForward' + icon: 'forward' + href: '#' + } + + if article.sender.name is 'Customer' && article.type.name is 'phone' + actions.push { + name: 'reply' + type: 'emailReply' + icon: 'reply' + href: '#' + } + actions.push { + name: 'forward' + type: 'emailForward' + icon: 'forward' + href: '#' + } + if article.sender.name is 'Agent' && article.type.name is 'phone' + actions.push { + name: 'reply' + type: 'emailReply' + icon: 'reply' + href: '#' + } + actions.push { + name: 'forward' + type: 'emailForward' + icon: 'forward' + href: '#' + } + + actions + + @perform: (articleContainer, type, ticket, article, ui) -> + return true if type isnt 'emailReply' && type isnt 'emailReplyAll' && type isnt 'emailForward' + + if type is 'emailReply' + @emailReply(false, ticket, article, ui) + + else if type is 'emailReplyAll' + @emailReply(true, ticket, article, ui) + + else if type is 'emailForward' + @emailForward(ticket, article, ui) + + true + + @emailReply: (all = false, ticket, article, ui) -> + + # get reference article + type = App.TicketArticleType.find(article.type_id) + article_created_by = App.User.find(article.created_by_id) + email_addresses = App.EmailAddress.all() + + ui.scrollToCompose() + + # empty form + articleNew = App.Utils.getRecipientArticle(ticket, article, article_created_by, type, email_addresses, all) + + # get current body + body = ui.el.closest('.ticketZoom').find('.article-add [data-name="body"]').html() || '' + + # check if quote need to be added + signaturePosition = 'bottom' + selected = App.ClipBoard.getSelected('html') + if selected + selected = App.Utils.htmlCleanup(selected).html() + if !selected + selected = App.ClipBoard.getSelected('text') + if selected + selected = App.Utils.textCleanup(selected) + selected = App.Utils.text2html(selected) + + # full quote, if needed + if !selected && article && App.Config.get('ui_ticket_zoom_article_email_full_quote') + signaturePosition = 'top' + if article.content_type.match('html') + selected = App.Utils.textCleanup(article.body) + if article.content_type.match('plain') + selected = App.Utils.textCleanup(article.body) + selected = App.Utils.text2html(selected) + + if selected + selected = "


#{selected}

" + + # add selected text to body + body = selected + body + + articleNew.body = body + + type = App.TicketArticleType.findByAttribute(name:'email') + + App.Event.trigger('ui::ticket::setArticleType', { + ticket: ticket + type: type + article: articleNew + signaturePosition: signaturePosition + }) + + true + + @emailForward: (ticket, article, ui) -> + + ui.scrollToCompose() + + signaturePosition = 'top' + body = '' + if article.content_type.match('html') + body = App.Utils.textCleanup(article.body) + if article.content_type.match('plain') + body = App.Utils.textCleanup(article.body) + body = App.Utils.text2html(body) + + body = "
---Begin forwarded message:---

#{body}

" + + articleNew = {} + articleNew.body = body + + type = App.TicketArticleType.findByAttribute(name:'email') + + App.Event.trigger('ui::ticket::setArticleType', { + ticket: ticket + type: type + article: articleNew + signaturePosition: signaturePosition + }) + + # add attachments to form + App.Ajax.request( + id: "ticket_attachment_clone#{ui.form_id}" + type: 'POST' + url: "#{App.Config.get('api_path')}/ticket_attachment_upload_clone_by_article/#{article.id}" + data: JSON.stringify(form_id: ui.form_id) + processData: true + success: (data, status, xhr) -> + return if _.isEmpty(data.attachments) + App.Event.trigger('ui::ticket::addArticleAttachent', { + ticket: ticket + article: article + attachments: data.attachments + form_id: ui.form_id + }) + ) + + true + +App.Config.set('200-EmailReply', EmailReply, 'TicketZoomArticleAction') diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/article_action/facebook_reply.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/article_action/facebook_reply.coffee new file mode 100644 index 000000000..41c9ad4e8 --- /dev/null +++ b/app/assets/javascripts/app/controllers/ticket_zoom/article_action/facebook_reply.coffee @@ -0,0 +1,37 @@ +class FacebookReply + @action: (actions, ticket, article, ui) -> + return actions if ui.permissionCheck('ticket.customer') + + if article.type.name is 'facebook feed post' || article.type.name is 'facebook feed comment' + actions.push { + name: 'reply' + type: 'facebookFeedReply' + icon: 'reply' + href: '#' + } + + actions + + @perform: (articleContainer, type, ticket, article, ui) -> + return true if type isnt 'facebookFeedReply' + + ui.scrollToCompose() + + type = App.TicketArticleType.findByAttribute('name', 'facebook feed comment') + + articleNew = { + to: '' + cc: '' + body: '' + in_reply_to: '' + } + + App.Event.trigger('ui::ticket::setArticleType', { + ticket: ticket + type: type + article: articleNew + }) + + true + +App.Config.set('300-FacebookReply', FacebookReply, 'TicketZoomArticleAction') diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/article_action/internal.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/article_action/internal.coffee new file mode 100644 index 000000000..8fc1e88bd --- /dev/null +++ b/app/assets/javascripts/app/controllers/ticket_zoom/article_action/internal.coffee @@ -0,0 +1,40 @@ +class Internal + @action: (actions, ticket, article, ui) -> + return actions if ui.permissionCheck('ticket.customer') + + if article.internal is true + actions.push { + name: 'set to public' + type: 'public' + icon: 'lock-open' + } + else + actions.push { + name: 'set to internal' + type: 'internal' + icon: 'lock' + } + + actions + + @perform: (articleContainer, type, ticket, article, ui) -> + return true if type isnt 'internal' && type isnt 'public' + + # storage update + internal = true + if article.internal == true + internal = false + ui.lastAttributres.internal = internal + article.updateAttributes(internal: internal) + + # runtime update + if internal + articleContainer.addClass('is-internal') + else + articleContainer.removeClass('is-internal') + + ui.render() + + true + +App.Config.set('100-Internal', Internal, 'TicketZoomArticleAction') diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/article_action/split.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/article_action/split.coffee new file mode 100644 index 000000000..716267dd2 --- /dev/null +++ b/app/assets/javascripts/app/controllers/ticket_zoom/article_action/split.coffee @@ -0,0 +1,18 @@ +class Split + @action: (actions, ticket, article, ui) -> + return actions if ui.permissionCheck('ticket.customer') + + actions.push { + name: 'split' + type: 'split' + icon: 'split' + href: "#ticket/create/#{article.ticket_id}/#{article.id}" + } + actions + + @perform: (articleContainer, type, ticket, article, ui) -> + return true if type isnt 'split' + ui.navigate "#ticket/create/#{article.ticket_id}/#{article.id}" + true + +App.Config.set('700-Split', Split, 'TicketZoomArticleAction') diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/article_action/telegram.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/article_action/telegram.coffee new file mode 100644 index 000000000..6f18a172d --- /dev/null +++ b/app/assets/javascripts/app/controllers/ticket_zoom/article_action/telegram.coffee @@ -0,0 +1,45 @@ +class TelegramReply + @action: (actions, ticket, article, ui) -> + return actions if ui.permissionCheck('ticket.customer') + + if article.sender.name is 'Customer' && article.type.name is 'telegram personal-message' + actions.push { + name: 'reply' + type: 'telegramPersonalMessageReply' + icon: 'reply' + href: '#' + } + + actions + + @perform: (articleContainer, type, ticket, article, ui) -> + return true if type isnt 'telegramPersonalMessageReply' + + ui.scrollToCompose() + + # get reference article + type = App.TicketArticleType.find(article.type_id) + + articleNew = { + to: '' + cc: '' + body: '' + in_reply_to: '' + } + + if article.message_id + articleNew.in_reply_to = article.message_id + + # get current body + articleNew.body = ui.el.closest('.ticketZoom').find('.article-add [data-name="body"]').html().trim() || '' + + App.Event.trigger('ui::ticket::setArticleType', { + ticket: ticket + type: type + article: articleNew + position: 'end' + }) + + true + +App.Config.set('300-TelegramReply', TelegramReply, 'TicketZoomArticleAction') diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/article_action/twitter_reply.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/article_action/twitter_reply.coffee new file mode 100644 index 000000000..6ce871868 --- /dev/null +++ b/app/assets/javascripts/app/controllers/ticket_zoom/article_action/twitter_reply.coffee @@ -0,0 +1,128 @@ +class TwitterReply + @action: (actions, ticket, article, ui) -> + return actions if ui.permissionCheck('ticket.customer') + + if article.type.name is 'twitter status' + actions.push { + name: 'reply' + type: 'twitterStatusReply' + icon: 'reply' + href: '#' + } + if article.type.name is 'twitter direct-message' + actions.push { + name: 'reply' + type: 'twitterDirectMessageReply' + icon: 'reply' + href: '#' + } + + actions + + @perform: (articleContainer, type, ticket, article, ui) -> + return true if type isnt 'twitterStatusReply' && type isnt 'twitterDirectMessageReply' + + if type is 'twitterStatusReply' + @twitterStatusReply(ticket, article, ui) + + else if type is 'twitterDirectMessageReply' + @twitterDirectMessageReply(ticket, article, ui) + + true + + @twitterStatusReply: (ticket, article, ui) -> + + ui.scrollToCompose() + + # get reference article + type = App.TicketArticleType.find(article.type_id) + + # empty form + articleNew = { + to: '' + cc: '' + body: '' + in_reply_to: '' + } + + if article.message_id + articleNew.in_reply_to = article.message_id + + # get current body + body = ui.el.closest('.ticketZoom').find('.article-add [data-name="body"]').html().trim() || '' + articleNew.body = body + + recipients = article.from + if article.to + if recipients + recipients += ', ' + recipients += article.to + + if recipients + recipientString = '' + recipientScreenNames = recipients.split(',') + for recipientScreenName in recipientScreenNames + if recipientScreenName + recipientScreenName = recipientScreenName.trim().toLowerCase() + + # exclude already listed screen name + exclude = false + if body && body.toLowerCase().match(recipientScreenName) + exclude = true + + # exclude own screen_name + if recipientScreenName is "@#{ticket.preferences.channel_screen_name}".toLowerCase() + exclude = true + + if exclude is false + if recipientString isnt '' + recipientString += ' ' + recipientString += recipientScreenName + + if body + articleNew.body = "#{recipientString} #{body} " + else + articleNew.body = "#{recipientString} " + + App.Event.trigger('ui::ticket::setArticleType', { + ticket: ticket + type: type + article: articleNew + position: 'end' + }) + + @twitterDirectMessageReply: (ticket, article, ui) -> + + # get reference article + type = App.TicketArticleType.find(article.type_id) + sender = App.TicketArticleSender.find(article.sender_id) + customer = App.User.find(article.created_by_id) + + ui.scrollToCompose() + + # empty form + articleNew = { + to: '' + cc: '' + body: '' + in_reply_to: '' + } + + if article.message_id + articleNew.in_reply_to = article.message_id + + if sender.name is 'Agent' + articleNew.to = article.to + else + articleNew.to = article.from + + if !articleNew.to + articleNew.to = customer.accounts['twitter'].username || customer.accounts['twitter'].uid + + App.Event.trigger('ui::ticket::setArticleType', { + ticket: ticket + type: type + article: articleNew + }) + +App.Config.set('300-TwitterReply', TwitterReply, 'TicketZoomArticleAction') diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/article_actions.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/article_actions.coffee index 3f42ce106..fbece5315 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/article_actions.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/article_actions.coffee @@ -1,21 +1,13 @@ class App.TicketZoomArticleActions extends App.Controller events: - 'click [data-type=public]': 'publicInternal' - 'click [data-type=internal]': 'publicInternal' - 'click [data-type=emailReply]': 'emailReply' - 'click [data-type=emailReplyAll]': 'emailReplyAll' - 'click [data-type=twitterStatusReply]': 'twitterStatusReply' - 'click [data-type=twitterDirectMessageReply]': 'twitterDirectMessageReply' - 'click [data-type=facebookFeedReply]': 'facebookFeedReply' - 'click [data-type=telegramPersonalMessageReply]': 'telegramPersonalMessageReply' - 'click [data-type=delete]': 'delete' + 'click .js-ArticleAction': 'actionPerform' constructor: -> super @render() render: -> - actions = @actionRow(@article) + actions = @actionRow(@ticket, @article) if actions @html App.view('ticket_zoom/article_view_actions')( @@ -25,371 +17,31 @@ class App.TicketZoomArticleActions extends App.Controller else @html '' - publicInternal: (e) => - e.preventDefault() - articleContainer = $(e.target).closest('.ticket-article-item') - 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 - @lastAttributres.internal = internal - article.updateAttributes(internal: internal) - - # runntime update - if internal - articleContainer.addClass('is-internal') - else - articleContainer.removeClass('is-internal') - - @render() - - actionRow: (article) -> - if @permissionCheck('ticket.customer') - return [] - + actionRow: (ticket, article) -> + actionConfig = App.Config.get('TicketZoomArticleAction') + keys = _.keys(actionConfig).sort() 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 [] - group = @ticket.group - if group.email_address_id && (article.type.name is 'email' || article.type.name is 'web') - actions.push { - name: 'reply' - type: 'emailReply' - icon: 'reply' - href: '#' - } - recipients = [] - if article.sender.name is 'Customer' - if article.from - localRecipients = emailAddresses.parseAddressList(article.from) - if localRecipients - recipients = recipients.concat localRecipients - if article.to - localRecipients = emailAddresses.parseAddressList(article.to) - if localRecipients - recipients = recipients.concat localRecipients - if article.cc - localRecipients = emailAddresses.parseAddressList(article.cc) - if localRecipients - recipients = recipients.concat localRecipients - - # remove system addresses - localAddresses = App.EmailAddress.all() - forgeinRecipients = [] - recipientUsed = {} - for recipient in recipients - if !_.isEmpty(recipient.address) - localRecipientAddress = recipient.address.toString().toLowerCase() - if !recipientUsed[localRecipientAddress] - recipientUsed[localRecipientAddress] = true - localAddress = false - for address in localAddresses - if localRecipientAddress is address.email.toString().toLowerCase() - recipientUsed[localRecipientAddress] = true - localAddress = true - if !localAddress - forgeinRecipients.push recipient - - # check if reply all is neede - if forgeinRecipients.length > 1 - actions.push { - name: 'reply all' - type: 'emailReplyAll' - icon: 'reply-all' - href: '#' - } - if article.sender.name is 'Customer' && article.type.name is 'phone' - actions.push { - name: 'reply' - type: 'emailReply' - icon: 'reply' - href: '#' - } - if article.sender.name is 'Agent' && article.type.name is 'phone' - actions.push { - name: 'reply' - type: 'emailReply' - icon: 'reply' - href: '#' - } - if article.type.name is 'twitter status' - actions.push { - name: 'reply' - type: 'twitterStatusReply' - icon: 'reply' - href: '#' - } - if article.type.name is 'twitter direct-message' - actions.push { - name: 'reply' - type: 'twitterDirectMessageReply' - icon: 'reply' - href: '#' - } - if article.type.name is 'facebook feed post' || article.type.name is 'facebook feed comment' - actions.push { - name: 'reply' - type: 'facebookFeedReply' - icon: 'reply' - href: '#' - } - if article.sender.name is 'Customer' && article.type.name is 'telegram personal-message' - actions.push { - name: 'reply' - type: 'telegramPersonalMessageReply' - icon: 'reply' - href: '#' - } - - actions.push { - name: 'split' - type: 'split' - icon: 'split' - href: '#ticket/create/' + article.ticket_id + '/' + article.id - } - - if article.type.name is 'note' - user = undefined - if App.Session.get('id') == article.created_by_id - user = App.User.find(App.Session.get('id')) - if user.permission('ticket.agent') - actions.push { - name: 'delete' - type: 'delete' - icon: 'trash' - href: '#' - } + for key in keys + config = actionConfig[key] + if config + actions = config.action(actions, ticket, article, @) actions - facebookFeedReply: (e) => + actionPerform: (e) => e.preventDefault() - type = App.TicketArticleType.findByAttribute('name', 'facebook feed comment') - @scrollToCompose() + articleContainer = $(e.target).closest('.ticket-article-item') + type = $(e.currentTarget).attr('data-type') + ticket = App.Ticket.fullLocal(@ticket.id) + article = App.TicketArticle.fullLocal(@article.id) - # empty form - articleNew = { - to: '' - cc: '' - body: '' - in_reply_to: '' - } - - App.Event.trigger('ui::ticket::setArticleType', { ticket: @ticket, type: type, article: articleNew } ) - - twitterStatusReply: (e) => - e.preventDefault() - - # get reference article - article_id = $(e.target).parents('[data-id]').data('id') - article = App.TicketArticle.fullLocal(article_id) - sender = App.TicketArticleSender.find(article.sender_id) - type = App.TicketArticleType.find(article.type_id) - customer = App.User.find(article.created_by_id) - - @scrollToCompose() - - # empty form - articleNew = { - to: '' - cc: '' - body: '' - in_reply_to: '' - } - - if article.message_id - articleNew.in_reply_to = article.message_id - - # get current body - body = @el.closest('.ticketZoom').find('.article-add [data-name="body"]').html().trim() || '' - articleNew.body = body - - recipients = article.from - if article.to - if recipients - recipients += ', ' - recipients += article.to - - if recipients - recipientString = '' - recipientScreenNames = recipients.split(',') - for recipientScreenName in recipientScreenNames - if recipientScreenName - recipientScreenName = recipientScreenName.trim().toLowerCase() - - # exclude already listed screen name - exclude = false - if body && body.toLowerCase().match(recipientScreenName) - exclude = true - - # exclude own screen_name - if recipientScreenName is "@#{@ticket.preferences.channel_screen_name}".toLowerCase() - exclude = true - - if exclude is false - if recipientString isnt '' - recipientString += ' ' - recipientString += recipientScreenName - - if body - articleNew.body = "#{recipientString} #{body} " - else - articleNew.body = "#{recipientString} " - - App.Event.trigger('ui::ticket::setArticleType', { ticket: @ticket, type: type, article: articleNew, position: 'end' } ) - - twitterDirectMessageReply: (e) => - 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) - sender = App.TicketArticleSender.find(article.sender_id) - customer = App.User.find(article.created_by_id) - - @scrollToCompose() - - # empty form - articleNew = { - to: '' - cc: '' - body: '' - in_reply_to: '' - } - - if article.message_id - articleNew.in_reply_to = article.message_id - - if sender.name is 'Agent' - articleNew.to = article.to - else - articleNew.to = article.from - - if !articleNew.to - articleNew.to = customer.accounts['twitter'].username || customer.accounts['twitter'].uid - - App.Event.trigger('ui::ticket::setArticleType', { ticket: @ticket, type: type, article: articleNew } ) - - emailReplyAll: (e) => - @emailReply(e, true) - - emailReply: (e, all = false) => - e.preventDefault() - - # get reference article - article_id = $(e.target).parents('[data-id]').data('id') - article = App.TicketArticle.fullLocal(article_id) - ticket = App.Ticket.fullLocal(article.ticket_id) - type = App.TicketArticleType.find(article.type_id) - article_created_by = App.User.find(article.created_by_id) - email_addresses = App.EmailAddress.all() - - @scrollToCompose() - - # empty form - articleNew = App.Utils.getRecipientArticle(ticket, article, article_created_by, type, email_addresses, all) - - # get current body - body = @el.closest('.ticketZoom').find('.article-add [data-name="body"]').html() || '' - - # check if quote need to be added - signaturePosition = 'bottom' - selected = App.ClipBoard.getSelected('html') - if selected - selected = App.Utils.htmlCleanup(selected).html() - if !selected - selected = App.ClipBoard.getSelected('text') - if selected - selected = App.Utils.textCleanup(selected) - selected = App.Utils.text2html(selected) - - # full quote, if needed - if !selected && article && App.Config.get('ui_ticket_zoom_article_email_full_quote') - signaturePosition = 'top' - if article.content_type.match('html') - selected = App.Utils.textCleanup(article.body) - if article.content_type.match('plain') - selected = App.Utils.textCleanup(article.body) - selected = App.Utils.text2html(selected) - - if selected - selected = "


#{selected}

" - - # add selected text to body - body = selected + body - - articleNew.body = body - - type = App.TicketArticleType.findByAttribute(name:'email') - - App.Event.trigger('ui::ticket::setArticleType', { - ticket: @ticket - type: type - article: articleNew - signaturePosition: signaturePosition - }) - - telegramPersonalMessageReply: (e) => - e.preventDefault() - - # get reference article - article_id = $(e.target).parents('[data-id]').data('id') - article = App.TicketArticle.fullLocal(article_id) - sender = App.TicketArticleSender.find(article.sender_id) - type = App.TicketArticleType.find(article.type_id) - customer = App.User.find(article.created_by_id) - - @scrollToCompose() - - # empty form - articleNew = { - to: '' - cc: '' - body: '' - in_reply_to: '' - } - - if article.message_id - articleNew.in_reply_to = article.message_id - - # get current body - articleNew.body = @el.closest('.ticketZoom').find('.article-add [data-name="body"]').html().trim() || '' - - App.Event.trigger('ui::ticket::setArticleType', { ticket: @ticket, type: type, article: articleNew, position: 'end' } ) - - delete: (e) => - e.preventDefault() - - callback = -> - article_id = $(e.target).parents('[data-id]').data('id') - article = App.TicketArticle.find(article_id) - article.destroy() - - new App.ControllerConfirm( - message: 'Sure?' - callback: callback - container: @el.closest('.content') - ) + actionConfig = App.Config.get('TicketZoomArticleAction') + keys = _.keys(actionConfig).sort() + actions = [] + for key in keys + config = actionConfig[key] + if config + return if !config.perform(articleContainer, type, ticket, article, @) scrollToCompose: => @el.closest('.content').find('.article-add').ScrollTo() diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/article_new.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/article_new.coffee index edb39480d..d8bc066ba 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/article_new.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/article_new.coffee @@ -70,6 +70,14 @@ class App.TicketZoomArticleNew extends App.Controller @textarea.focus() ) + # add article attachment + @bind('ui::ticket::addArticleAttachent', (data) => + return if data.ticket.id.toString() isnt @ticket_id.toString() + return if _.isEmpty(data.attachments) + for file in data.attachments + @renderAttachment(file) + ) + # reset new article screen @bind('ui::ticket::taskReset', (data) => return if data.ticket_id.toString() isnt @ticket_id.toString() @@ -143,8 +151,8 @@ class App.TicketZoomArticleNew extends App.Controller icon: 'twitter' attributes: [] internal: false, - features: ['body:limit', 'body:initials'] - maxTextLength: 140 + features: attributes + maxTextLength: 280 warningTextLength: 30 } if possibleArticleType['twitter direct-message'] @@ -156,7 +164,7 @@ class App.TicketZoomArticleNew extends App.Controller icon: 'twitter' attributes: ['to'] internal: false, - features: ['body:limit', 'body:initials'] + features: attributes maxTextLength: 10000 warningTextLength: 500 } @@ -245,7 +253,7 @@ class App.TicketZoomArticleNew extends App.Controller controller = new App.ControllerForm( el: @$('.recipients') model: - configure_attributes: configure_attributes, + configure_attributes: configure_attributes ) @$('[data-name="body"]').ce({ @@ -255,12 +263,13 @@ class App.TicketZoomArticleNew extends App.Controller }) html5Upload.initialize( - uploadUrl: App.Config.get('api_path') + '/ticket_attachment_upload', - dropContainer: @$('.article-add').get(0), - cancelContainer: @cancelContainer, - inputField: @$('.article-attachment input').get(0), - key: 'File', - data: { form_id: @form_id }, + uploadUrl: App.Config.get('api_path') + '/ticket_attachment_upload' + dropContainer: @$('.article-add').get(0) + cancelContainer: @cancelContainer + inputField: @$('.article-attachment input').get(0) + key: 'File' + data: + form_id: @form_id maxSimultaneousUploads: 1, onFileAdded: (file) => @@ -303,6 +312,8 @@ class App.TicketZoomArticleNew extends App.Controller ) ) + @bindAttachmentDelete() + # show text module UI if !@permissionCheck('ticket.customer') textModule = new App.WidgetTextModule( @@ -737,33 +748,29 @@ class App.TicketZoomArticleNew extends App.Controller @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') + @attachmentsHolder.append(App.view('generic/attachment_item')(file)) - # 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 - ) + bindAttachmentDelete: => + @attachmentsHolder.on('click', '.js-delete', (e) => + id = $(e.currentTarget).data('id') + @attachments = _.filter( + @attachments, + (item) -> + return if item.id.toString() is id.toString() + item + ) - # 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() + # delete attachment from storage + App.Ajax.request( + type: 'DELETE' + url: App.Config.get('api_path') + '/ticket_attachment_upload' + data: JSON.stringify(id: id) + processData: false + ) + + # remove attachment from dom + element = $(e.currentTarget).closest('.attachments') + $(e.currentTarget).closest('.attachment').remove() + if element.find('.attachment').length == 0 + element.empty() ) diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee index d66ed3c15..d70282853 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee @@ -20,6 +20,7 @@ class App.TicketZoomArticleView extends App.Controller el: el ui: @ui highligher: @highligher + form_id: @form_id ) if !@ticketArticleInsertByIndex(index, el) all.push el @@ -193,6 +194,7 @@ class ArticleViewItem extends App.ObserverController ticket: @ticket article: article lastAttributres: @lastAttributres + form_id: @form_id ) # set see more diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar.coffee index 3059d1d0c..416d16742 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar.coffee @@ -32,16 +32,14 @@ class App.TicketZoomSidebar extends App.ObserverController ) else @sidebarBackends[key].reload( - params: @params - query: @query + params: @params + query: @query formMeta: @formMeta markForm: @markForm tags: @tags links: @links ) - item = @sidebarBackends[key].sidebarItem() - if item - @sidebarItems.push item + @sidebarItems.push @sidebarBackends[key] new App.Sidebar( el: @el.find('.tabsSidebar') diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_customer.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_customer.coffee index 92bfd8c65..521e0d4ac 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_customer.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_customer.coffee @@ -1,32 +1,67 @@ class SidebarCustomer extends App.Controller sidebarItem: => return if !@permissionCheck('ticket.agent') - items = { - head: 'Customer' - name: 'customer' - icon: 'person' - actions: [ + @item = { + name: 'customer' + badgeCallback: @badgeRender + sidebarHead: 'Customer' + sidebarCallback: @showCustomer + sidebarActions: [ { title: 'Change Customer' name: 'customer-change' callback: @changeCustomer }, ] - callback: @showCustomer } - return items if @ticket && @ticket.customer_id == 1 - items.actions.push { + return @item if @ticket && @ticket.customer_id == 1 + @item.sidebarActions.push { title: 'Edit Customer' name: 'customer-edit' callback: @editCustomer } - items + @item + + metaBadge: (user) => + counter = '' + cssClass = '' + counter = @sidebarItemCounter(user) + + if @Config.get('ui_sidebar_open_ticket_indicator_colored') is true + if counter == 1 + cssClass = 'tabsSidebar-tab-count--warning' + if counter > 1 + cssClass = 'tabsSidebar-tab-count--danger' + + { + name: 'customer' + icon: 'person' + counterPossible: true + counter: counter + cssClass: cssClass + } + + badgeRender: (el) => + @badgeEl = el + if App.User.exists(@ticket.customer_id) + user = App.User.find(@ticket.customer_id) + @badgeRenderLocal(user) + + badgeRenderLocal: (user) => + @badgeEl.html(App.view('generic/sidebar_tabs_item')(@metaBadge(user))) + + sidebarItemCounter: (user) -> + counter = '' + if user && user.preferences && user.preferences.tickets_open + counter = user.preferences.tickets_open + counter showCustomer: (el) => - @el = el + @elSidebar = el new App.WidgetUser( - el: @el + el: @elSidebar user_id: @ticket.customer_id + callback: @badgeRenderLocal ) editCustomer: => @@ -38,13 +73,13 @@ class SidebarCustomer extends App.Controller title: 'Users' object: 'User' objects: 'Users' - container: @el.closest('.content') + container: @elSidebar.closest('.content') ) changeCustomer: => new App.TicketCustomer( ticket_id: @ticket.id - container: @el.closest('.content') + container: @elSidebar.closest('.content') ) App.Config.set('200-Customer', SidebarCustomer, 'TicketZoomSidebar') diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_idoit.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_idoit.coffee index 5ebce7924..a3a8aad7d 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_idoit.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_idoit.coffee @@ -1,19 +1,20 @@ class SidebarIdoit extends App.Controller sidebarItem: => return if !@Config.get('idoit_integration') - { - head: 'i-doit' - name: 'idoit' - icon: 'printer' - actions: [ + @item = { + name: 'idoit' + badgeIcon: 'printer' + sidebarHead: 'i-doit' + sidebarCallback: @showObjects + sidebarActions: [ { title: 'Change Objects' name: 'objects-change' callback: @changeObjects }, ] - callback: @showObjects } + @item changeObjects: => new App.IdoitObjectSelector( diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_organization.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_organization.coffee index 6fc2ef5cb..357813ef4 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_organization.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_organization.coffee @@ -2,24 +2,25 @@ class SidebarOrganization extends App.Controller sidebarItem: => return if !@permissionCheck('ticket.agent') return if !@ticket.organization_id - { - head: 'Organization' + @item = { name: 'organization' - icon: 'group' - actions: [ + badgeIcon: 'group' + sidebarHead: 'Organization' + sidebarCallback: @showOrganization + sidebarActions: [ { title: 'Edit Organization' name: 'organization-edit' callback: @editOrganization }, ] - callback: @showOrganization } + @item showOrganization: (el) => - @el = el + @elSidebar = el new App.WidgetOrganization( - el: @el + el: @elSidebar organization_id: @ticket.organization_id ) @@ -31,7 +32,7 @@ class SidebarOrganization extends App.Controller title: 'Organizations' object: 'Organization' objects: 'Organizations' - container: @el.closest('.content') + container: @elSidebar.closest('.content') ) App.Config.set('300-Organization', SidebarOrganization, 'TicketZoomSidebar') diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_ticket.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_ticket.coffee index cb656f976..53253d729 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_ticket.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/sidebar_ticket.coffee @@ -20,8 +20,9 @@ class Edit extends App.ObserverController handlers: [ @ticketFormChanges ] - filter: @formMeta.filter - params: defaults + filter: @formMeta.filter + params: defaults + isDisabled: !ticket.editable() #bookmarkable: true ) @@ -36,14 +37,14 @@ class Edit extends App.ObserverController class SidebarTicket extends App.Controller sidebarItem: => - sidebarItem = { - head: 'Ticket' - name: 'ticket' - icon: 'message' - callback: @editTicket + @item = { + name: 'ticket' + badgeIcon: 'message' + sidebarHead: 'Ticket' + sidebarCallback: @editTicket } if @permissionCheck('ticket.agent') - sidebarItem['actions'] = [ + @item.sidebarActions = [ { title: 'History' name: 'ticket-history' @@ -60,7 +61,7 @@ class SidebarTicket extends App.Controller callback: @changeCustomer }, ] - sidebarItem + @item reload: (args) => @@ -79,7 +80,7 @@ class SidebarTicket extends App.Controller editTicket: (el) => @el = el - localEl = $( App.view('ticket_zoom/sidebar_ticket')() ) + localEl = $(App.view('ticket_zoom/sidebar_ticket')()) @edit = new Edit( object_id: @ticket.id diff --git a/app/assets/javascripts/app/controllers/users.coffee b/app/assets/javascripts/app/controllers/users.coffee index 07b893a58..27b464580 100644 --- a/app/assets/javascripts/app/controllers/users.coffee +++ b/app/assets/javascripts/app/controllers/users.coffee @@ -145,7 +145,7 @@ class Index extends App.ControllerSubContent query: @query limit: 140 role_ids: role_ids - full: 1 + full: true processData: true, success: (data, status, xhr) => App.Collection.loadAssets(data.assets) @@ -167,7 +167,7 @@ class Index extends App.ControllerSubContent data: limit: 50 role_ids: role_ids - full: 1 + full: true processData: true success: (data, status, xhr) => App.Collection.loadAssets(data.assets) diff --git a/app/assets/javascripts/app/controllers/widget/default_locale.coffee b/app/assets/javascripts/app/controllers/widget/default_locale.coffee new file mode 100644 index 000000000..db4437c30 --- /dev/null +++ b/app/assets/javascripts/app/controllers/widget/default_locale.coffee @@ -0,0 +1,23 @@ +class DefaultLocale extends App.Controller + constructor: -> + super + + check = => + + preferences = App.Session.get('preferences') + return if !preferences + return if !_.isEmpty(preferences.locale) + locale = App.i18n.get() + @ajax( + id: "i18n-set-user-#{locale}" + type: 'PUT' + url: "#{App.Config.get('api_path')}/users/preferences" + data: JSON.stringify(locale: locale) + processData: true + ) + + App.Event.bind('auth:login', (session) => + @delay(check, 3500, 'default_locale') + ) + +App.Config.set('default_locale', DefaultLocale, 'Widgets') diff --git a/app/assets/javascripts/app/controllers/widget/switch_back_to_user.coffee b/app/assets/javascripts/app/controllers/widget/switch_back_to_user.coffee index 9f5e3c526..612d9cbe4 100644 --- a/app/assets/javascripts/app/controllers/widget/switch_back_to_user.coffee +++ b/app/assets/javascripts/app/controllers/widget/switch_back_to_user.coffee @@ -47,4 +47,4 @@ class Widget extends App.ControllerWidgetOnDemand 800 ) -App.Config.set( 'switch_back_to_user', Widget, 'Widgets' ) +App.Config.set('switch_back_to_user', Widget, 'Widgets') diff --git a/app/assets/javascripts/app/controllers/widget/ticket_stats.coffee b/app/assets/javascripts/app/controllers/widget/ticket_stats.coffee index 7445e8294..67d8b4b5b 100644 --- a/app/assets/javascripts/app/controllers/widget/ticket_stats.coffee +++ b/app/assets/javascripts/app/controllers/widget/ticket_stats.coffee @@ -70,6 +70,7 @@ class App.TicketStats extends App.Controller render: (data) => if !data data = @data + return if !data user_total = 0 if data.user.open_ids && data.user.closed_ids diff --git a/app/assets/javascripts/app/index.coffee b/app/assets/javascripts/app/index.coffee index 3e6211d9b..d9fbb2028 100644 --- a/app/assets/javascripts/app/index.coffee +++ b/app/assets/javascripts/app/index.coffee @@ -12,8 +12,8 @@ class App extends Spine.Controller helper = # define print name helper - P: (object, attributeName, attributes) -> - App.viewPrint(object, attributeName, attributes) + P: (object, attributeName, attributes, table = false) -> + App.viewPrint(object, attributeName, attributes, table) # define date format helper date: (time) -> @@ -136,7 +136,7 @@ class App extends Spine.Controller return marked(string) App.i18n.translateContent(string) - @viewPrint: (object, attributeName, attributes) -> + @viewPrint: (object, attributeName, attributes, table) -> if !attributes attributes = {} if object.constructor.attributesGet @@ -172,10 +172,10 @@ class App extends Spine.Controller if object[attributeNameWithoutRef] valueRef = object[attributeNameWithoutRef] - @viewPrintItem(value, attributeConfig, valueRef) + @viewPrintItem(value, attributeConfig, valueRef, table) # define print name helper - @viewPrintItem: (item, attributeConfig = {}, valueRef) -> + @viewPrintItem: (item, attributeConfig = {}, valueRef, table) -> return '-' if item is undefined return '-' if item is '' return item if item is null @@ -238,7 +238,7 @@ class App extends Spine.Controller # transform date if attributeConfig.tag is 'date' isHtmlEscape = true - resultLocal = App.i18n.translateDate(resultLocal) + resultLocal = App.i18n.translateDate(resultLocal) # transform input tel|url to make it clickable if attributeConfig.tag is 'input' @@ -258,8 +258,10 @@ class App extends Spine.Controller cssClass = attributeConfig.class || '' if cssClass.match 'escalation' escalation = true - humanTime = App.PrettyDate.humanTime(resultLocal, escalation) - resultLocal = "" + humanTime = '' + if !table + humanTime = App.PrettyDate.humanTime(resultLocal, escalation) + resultLocal = "" if !isHtmlEscape && typeof resultLocal is 'string' resultLocal = App.Utils.htmlEscape(resultLocal) diff --git a/app/assets/javascripts/app/lib/app_post/_collection_base.coffee b/app/assets/javascripts/app/lib/app_post/_collection_base.coffee index ddd758914..2e1a487b0 100644 --- a/app/assets/javascripts/app/lib/app_post/_collection_base.coffee +++ b/app/assets/javascripts/app/lib/app_post/_collection_base.coffee @@ -77,7 +77,7 @@ class App._CollectionSingletonBase callback: (data) => for counter, attr of @callbacks - callback = -> + callback = => attr.callback(data) if attr.one delete @callbacks[counter] diff --git a/app/assets/javascripts/app/lib/app_post/_object_organization_autocompletion.coffee b/app/assets/javascripts/app/lib/app_post/_object_organization_autocompletion.coffee index 011dc2202..da0d8b2ee 100644 --- a/app/assets/javascripts/app/lib/app_post/_object_organization_autocompletion.coffee +++ b/app/assets/javascripts/app/lib/app_post/_object_organization_autocompletion.coffee @@ -71,7 +71,7 @@ class App.ObjectOrganizationAutocompletion extends App.Controller @open() focusInput: => - @objectSelect.focus() if not @formControl.hasClass 'focus' + @objectSelect.focus() if not @formControl.hasClass('focus') onBlur: => selectObject = @objectSelect.val() @@ -85,6 +85,9 @@ class App.ObjectOrganizationAutocompletion extends App.Controller @objectId.val("guess:#{selectObject}") @formControl.removeClass 'focus' + resetObjectSelection: => + @objectId.val('').trigger('change') + onObjectClick: (e) => objectId = $(e.currentTarget).data('object-id') @selectObject(objectId) @@ -103,23 +106,23 @@ class App.ObjectOrganizationAutocompletion extends App.Controller # Only work with the last one since its the newest one objectId = @objectId.val().split(',').pop() - return if !objectId - return if !App[@objectSingle].exists(objectId) - object = App[@objectSingle].find(objectId) - name = object.displayName() + if objectId && App[@objectSingle].exists(objectId) + object = App[@objectSingle].find(objectId) + name = object.displayName() - if @attribute.multiple - # create token - @createToken name, objectId - else - if object.email + if @attribute.multiple - # quote name for special character - if name.match(/\@|,|;|\^|\+|#|§|\$|%|&|\/|\(|\)|=|\?|!|\*|\[|\]/) - name = "\"#{name}\"" - name += " <#{object.email}>" + # create token + @createToken(name, objectId) + else + if object.email - @objectSelect.val(name) + # quote name for special character + if name.match(/\@|,|;|\^|\+|#|§|\$|%|&|\/|\(|\)|=|\?|!|\*|\[|\]/) + name = "\"#{name}\"" + name += " <#{object.email}>" + + @objectSelect.val(name) if @callback @callback(objectId) @@ -321,12 +324,16 @@ class App.ObjectOrganizationAutocompletion extends App.Controller @hideOrganizationMembers() # hide dropdown - if !query + if _.isEmpty(query) @emptyResultList() if !@attribute.disableCreateObject @recipientList.append(@buildObjectNew()) + # reset object selection + @resetObjectSelection() + return + # show dropdown if query && ( !@attribute.minLengt || @attribute.minLengt <= query.length ) @lazySearch(query) diff --git a/app/assets/javascripts/app/lib/app_post/auth.coffee b/app/assets/javascripts/app/lib/app_post/auth.coffee index f46c8c6e3..7f95f2375 100644 --- a/app/assets/javascripts/app/lib/app_post/auth.coffee +++ b/app/assets/javascripts/app/lib/app_post/auth.coffee @@ -79,8 +79,7 @@ class App.Auth @_updateModelAttributes(data.models) # set locale - locale = window.navigator.userLanguage || window.navigator.language || 'en-us' - App.i18n.set(locale) + App.i18n.set(App.i18n.detectBrowserLocale()) # rebuild navbar with new navbar items App.Event.trigger('auth') @@ -120,7 +119,7 @@ class App.Auth if preferences && preferences.locale locale = preferences.locale if !locale - locale = window.navigator.userLanguage || window.navigator.language || 'en-us' + locale = App.i18n.detectBrowserLocale() App.i18n.set(locale) App.Event.trigger('auth:login', data.session) diff --git a/app/assets/javascripts/app/lib/app_post/column_select.coffee b/app/assets/javascripts/app/lib/app_post/column_select.coffee index 728c2d499..a38cca006 100644 --- a/app/assets/javascripts/app/lib/app_post/column_select.coffee +++ b/app/assets/javascripts/app/lib/app_post/column_select.coffee @@ -39,14 +39,27 @@ class App.ColumnSelect extends Spine.Controller ) render: -> + if !_.isEmpty(@attribute.seperator) + values = [] + if @attribute.value + values = @attribute.value.split(';') + else if @attribute.default + values = @attribute.default.split(';') + + for value in values + for option in @options.attribute.options + if option.value is value + option.selected = true + @values = [] _.each @options.attribute.options, (option) => if option.selected @values.push option.value.toString() - @html App.view('generic/column_select') + @html App.view('generic/column_select')( attribute: @options.attribute values: @values + ) # keep inital height # disabled for now since controls in modals get rendered hidden @@ -60,13 +73,17 @@ class App.ColumnSelect extends Spine.Controller @throttledSelect() select: (value) -> - @selected.find("[data-value='#{value}']").removeClass 'is-hidden' - @pool.find("[data-value='#{value}']").addClass 'is-hidden' + @selected.find("[data-value='#{value}']").removeClass('is-hidden') + @pool.find("[data-value='#{value}']").addClass('is-hidden') @values.push(value) - @shadow.val(@values) - @shadow.trigger('change') - @placeholder.addClass 'is-hidden' + if !_.isEmpty(@attribute.seperator) + @shadow.val(@values.join(';')) + else + @shadow.val(@values) + @shadow.trigger('change') + + @placeholder.addClass('is-hidden') if @search.val() and @poolOptions.not('.is-filtered').not('.is-hidden').size() is 0 @clear() @@ -76,14 +93,17 @@ class App.ColumnSelect extends Spine.Controller @throttledRemove() remove: (value) -> - @pool.find("[data-value='#{value}']").removeClass 'is-hidden' - @selected.find("[data-value='#{value}']").addClass 'is-hidden' + @pool.find("[data-value='#{value}']").removeClass('is-hidden') + @selected.find("[data-value='#{value}']").addClass('is-hidden') @values.splice(@values.indexOf(value), 1) - @shadow.val(@values) - @shadow.trigger('change') + if !_.isEmpty(@attribute.seperator) + @shadow.val(@values.join(';')) + else + @shadow.val(@values) + @shadow.trigger('change') if !@values.length - @placeholder.removeClass 'is-hidden' + @placeholder.removeClass('is-hidden') filter: (event) -> filter = $(event.currentTarget).val() @@ -92,16 +112,16 @@ class App.ColumnSelect extends Spine.Controller return if $(el).hasClass('is-hidden') if $(el).text().toLowerCase().indexOf(filter.toLowerCase()) > -1 - $(el).removeClass 'is-filtered' + $(el).removeClass('is-filtered') else - $(el).addClass 'is-filtered' + $(el).addClass('is-filtered') @clearButton.toggleClass 'is-hidden', filter.length is 0 clear: -> @search.val('') - @poolOptions.removeClass 'is-filtered' - @clearButton.addClass 'is-hidden' + @poolOptions.removeClass('is-filtered') + @clearButton.addClass('is-hidden') onFilterKeydown: (event) -> return if event.keyCode != 13 @@ -111,4 +131,4 @@ class App.ColumnSelect extends Spine.Controller firstVisibleOption = @poolOptions.not('.is-filtered').not('.is-hidden').first() if firstVisibleOption - @select firstVisibleOption.attr('data-value') \ No newline at end of file + @select firstVisibleOption.attr('data-value') diff --git a/app/assets/javascripts/app/lib/app_post/i18n.coffee b/app/assets/javascripts/app/lib/app_post/i18n.coffee index 560562269..6eff7b768 100644 --- a/app/assets/javascripts/app/lib/app_post/i18n.coffee +++ b/app/assets/javascripts/app/lib/app_post/i18n.coffee @@ -80,6 +80,24 @@ class App.i18n _instance ?= new _i18nSingleton() _instance.mapTime + @detectBrowserLocale: -> + return 'en-us' if !window.navigator.userLanguage && !window.navigator.language + + if window.navigator.languages + allLocales = App.Locale.all() + for browserLocale in window.navigator.languages + for localAllLocale in allLocales + if browserLocale is localAllLocale.locale + return localAllLocale.locale + + for browserLocale in window.navigator.languages + browserLocale = browserLocale.substr(0, 2) + for localAllLocale in allLocales + if browserLocale is localAllLocale.alias + return localAllLocale.locale + + window.navigator.userLanguage || window.navigator.language || 'en-us' + class _i18nSingleton extends Spine.Module @include App.LogInclude @@ -319,18 +337,20 @@ class _i18nSingleton extends Spine.Module if offset timeObject = new Date(timeObject.getTime() + (timeObject.getTimezoneOffset() * 60000)) - d = timeObject.getDate() - m = timeObject.getMonth() + 1 - y = timeObject.getFullYear() - S = timeObject.getSeconds() - M = timeObject.getMinutes() - H = timeObject.getHours() + d = timeObject.getDate() + m = timeObject.getMonth() + 1 + yfull = timeObject.getFullYear() + yshort = timeObject.getYear()-100 + S = timeObject.getSeconds() + M = timeObject.getMinutes() + H = timeObject.getHours() format = format .replace(/dd/, s(d, 2)) .replace(/d/, d) .replace(/mm/, s(m, 2)) .replace(/m/, m) - .replace(/yyyy/, y) + .replace(/yyyy/, yfull) + .replace(/yy/, yshort) .replace(/SS/, s(S, 2)) .replace(/MM/, s(M, 2)) .replace(/HH/, s(H, 2)) diff --git a/app/assets/javascripts/app/lib/app_post/image_service.coffee b/app/assets/javascripts/app/lib/app_post/image_service.coffee index eb8eba5c6..5b0656e74 100644 --- a/app/assets/javascripts/app/lib/app_post/image_service.coffee +++ b/app/assets/javascripts/app/lib/app_post/image_service.coffee @@ -20,37 +20,53 @@ class App.ImageService imageWidth = imageObject.width imageHeight = imageObject.height console.log('ImageService', 'current size', imageWidth, imageHeight) + console.log('ImageService', 'sizeFactor', sizeFactor) if y is 'auto' && x is 'auto' x = imageWidth y = imageHeight + # set max x/y + if x isnt 'auto' && x > imageWidth + x = imageWidth + + if y isnt 'auto' && y > imageHeight + y = imageHeight + # get auto dimensions - if y is 'auto' + if y is 'auto'# && (y * factor) >= imageHeight factor = imageWidth / x y = imageHeight / factor - if x is 'auto' + if x is 'auto'# && (y * factor) >= imageWidth factor = imageWidth / y x = imageHeight / factor + canvas = document.createElement('canvas') + # check if resize is needed resize = false - if x < imageWidth || y < imageHeight + if (x < imageWidth && (x * sizeFactor < imageWidth)) || (y < imageHeight && (y * sizeFactor < imageHeight)) resize = true x = x * sizeFactor y = y * sizeFactor + + # set dimensions + canvas.width = x + canvas.height = y + + # draw image on canvas and set image dimensions + context = canvas.getContext('2d') + context.drawImage(imageObject, 0, 0, x, y) + else - x = imageWidth - y = imageHeight - # create canvas and set dimensions - canvas = document.createElement('canvas') - canvas.width = x - canvas.height = y + # set dimensions + canvas.width = imageWidth + canvas.height = imageHeight - # draw image on canvas and set image dimensions - context = canvas.getContext('2d') - context.drawImage(imageObject, 0, 0, x, y) + # draw image on canvas and set image dimensions + context = canvas.getContext('2d') + context.drawImage(imageObject, 0, 0, imageWidth, imageHeight) # set quallity based on image size if quallity == 'auto' diff --git a/app/assets/javascripts/app/lib/app_post/utils.coffee b/app/assets/javascripts/app/lib/app_post/utils.coffee index 6b7c21eef..134deb96e 100644 --- a/app/assets/javascripts/app/lib/app_post/utils.coffee +++ b/app/assets/javascripts/app/lib/app_post/utils.coffee @@ -5,6 +5,8 @@ class App.Utils 'TD': ['abbr', 'align', 'axis', 'colspan', 'headers', 'rowspan', 'valign', 'width', 'style'] 'TH': ['abbr', 'align', 'axis', 'colspan', 'headers', 'rowspan', 'scope', 'sorted', 'valign', 'width', 'style'] 'TR': ['width', 'style'] + 'A': ['href', 'hreflang', 'name', 'rel'] + 'IMG': ['align', 'alt', 'border', 'height', 'src', 'srcset', 'width', 'style'] @mapCss: 'TABLE': [ @@ -14,15 +16,9 @@ class App.Utils 'text-align', 'border', 'border-top', 'border-right', 'border-bottom', 'border-left', 'border-collapse', 'border-style', 'border-spacing', - 'border-top-width', - 'border-right-width', - 'border-bottom-width', - 'border-left-width', - - 'border-top-color', - 'border-right-color', - 'border-bottom-color', - 'border-left-color', + 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width', + 'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color', + 'border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style', ] 'TH': [ 'background', 'background-color', 'color', 'font-size', 'vertical-align', @@ -31,15 +27,10 @@ class App.Utils 'text-align', 'border', 'border-top', 'border-right', 'border-bottom', 'border-left', 'border-collapse', 'border-style', 'border-spacing', - 'border-top-width', - 'border-right-width', - 'border-bottom-width', - 'border-left-width', + 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width', + 'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color', + 'border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style', - 'border-top-color', - 'border-right-color', - 'border-bottom-color', - 'border-left-color', ] 'TR': [ 'background', 'background-color', 'color', 'font-size', 'vertical-align', @@ -48,15 +39,10 @@ class App.Utils 'text-align', 'border', 'border-top', 'border-right', 'border-bottom', 'border-left', 'border-collapse', 'border-style', 'border-spacing', - 'border-top-width', - 'border-right-width', - 'border-bottom-width', - 'border-left-width', + 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width', + 'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color', + 'border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style', - 'border-top-color', - 'border-right-color', - 'border-bottom-color', - 'border-left-color', ] 'TD': [ 'background', 'background-color', 'color', 'font-size', 'vertical-align', @@ -65,15 +51,13 @@ class App.Utils 'text-align', 'border', 'border-top', 'border-right', 'border-bottom', 'border-left', 'border-collapse', 'border-style', 'border-spacing', - 'border-top-width', - 'border-right-width', - 'border-bottom-width', - 'border-left-width', + 'border-top-width', 'border-right-width', 'border-bottom-width', 'border-left-width', + 'border-top-color', 'border-right-color', 'border-bottom-color', 'border-left-color', + 'border-top-style', 'border-right-style', 'border-bottom-style', 'border-left-style', - 'border-top-color', - 'border-right-color', - 'border-bottom-color', - 'border-left-color', + ] + 'IMG': [ + 'width', 'height', ] # textCleand = App.Utils.textCleanup(rawText) @@ -230,7 +214,7 @@ class App.Utils # remove comments @_removeComments(html) - # remove work markup + # remove word markup @_removeWordMarkup(html) # remove tags, keep content @@ -251,7 +235,7 @@ class App.Utils # remove comments @_removeComments(html) - # remove work markup + # remove word markup @_removeWordMarkup(html) # remove tags, keep content @@ -275,11 +259,11 @@ class App.Utils # remove comments @_removeComments(html) - # remove work markup + # remove word markup @_removeWordMarkup(html) # remove tags, keep content - html.find('a, font, small, time, form, label').replaceWith( -> + html.find('font, small, time, form, label').replaceWith( -> $(@).contents() ) @@ -303,7 +287,7 @@ class App.Utils ) # remove tags & content - html.find('font, img, svg, input, select, button, style, applet, embed, noframes, canvas, script, frame, iframe, meta, link, title, head, fieldset').remove() + html.find('font, svg, input, select, button, style, applet, embed, noframes, canvas, script, frame, iframe, meta, link, title, head, fieldset').remove() # remove style and class @_cleanAttributes(html) @@ -906,6 +890,29 @@ class App.Utils text = text.replace(/http(s|):\/\/[-A-Za-z0-9+&@#\/%?=~_\|!:,.;]+[-A-Za-z0-9+&@#\/%=~_|]/img, placeholder) text.length + @parseAddressListLocal: (line) -> + recipients = emailAddresses.parseAddressList(line) + result = [] + if !_.isEmpty(recipients) + for recipient in recipients + if recipient && recipient.address + result.push recipient.address + return result + + # workaround for email-addresses.js issue with this kind of + # mail headers "From: invalid sender, realname " + # email-addresses.js is returning null because it can't parse the + # whole header + if _.isEmpty(recipients) && line.match('@') + recipients = line.split(',') + re = /(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/ + for recipient in recipients + if recipient && recipient.match('@') + localResult = recipient.match(re) + if localResult && localResult[0] + result.push localResult[0] + result + @getRecipientArticle: (ticket, article, article_created_by, type, email_addresses = [], all) -> # empty form @@ -954,16 +961,18 @@ class App.Utils # check if article sender is local senderIsLocal = false if !_.isEmpty(article.from) - senders = emailAddresses.parseAddressList(article.from) - if senders && senders[0] && senders[0].address - senderIsLocal = isLocalAddress(senders[0].address) + senders = App.Utils.parseAddressListLocal(article.from) + if senders + for sender in senders + if sender && sender.match('@') + senderIsLocal = isLocalAddress(sender) # check if article recipient is local recipientIsLocal = false if !_.isEmpty(article.to) - recipients = emailAddresses.parseAddressList(article.to) - if recipients && recipients[0] && recipients[0].address - recipientIsLocal = isLocalAddress(recipients[0].address) + recipients = App.Utils.parseAddressListLocal(article.to) + if recipients && recipients[0] + recipientIsLocal = isLocalAddress(recipients[0]) # sender is local if senderIsLocal @@ -987,14 +996,14 @@ class App.Utils # filter for uniq recipients recipientAddresses = {} - addAddresses = (addressLine, line) -> lineNew = '' - recipients = emailAddresses.parseAddressList(addressLine) + recipients = App.Utils.parseAddressListLocal(addressLine) + if !_.isEmpty(recipients) for recipient in recipients - if !_.isEmpty(recipient.address) - localRecipientAddress = recipient.address.toString().toLowerCase() + if !_.isEmpty(recipient) + localRecipientAddress = recipient.toString().toLowerCase() # check if address is not local if !isLocalAddress(localRecipientAddress) diff --git a/app/assets/javascripts/app/lib/base/ba-linkify.js b/app/assets/javascripts/app/lib/base/ba-linkify.js index 293dd67af..911456236 100644 --- a/app/assets/javascripts/app/lib/base/ba-linkify.js +++ b/app/assets/javascripts/app/lib/base/ba-linkify.js @@ -161,7 +161,14 @@ window.linkify = (function(){ } // Push massaged link onto the array - parts.push([ link, href ]); + // 2018-10-30: me only link urls, not mailto link + //parts.push([ link, href ]); + if ( href && href.substr && href.substr(0,7) != 'mailto:') { + parts.push([ link, href ]); + } + else { + parts.push([ link, undefined ]); + } }; // Push remaining non-link text onto the array. diff --git a/app/assets/javascripts/app/lib/base/email-addresses.js b/app/assets/javascripts/app/lib/base/email-addresses.js index 4f36f700b..ce7b30d85 100644 --- a/app/assets/javascripts/app/lib/base/email-addresses.js +++ b/app/assets/javascripts/app/lib/base/email-addresses.js @@ -1,6 +1,6 @@ // email-addresses.js - RFC 5322 email address parser -// v 2.0.1 +// v 3.0.1 // // http://tools.ietf.org/html/rfc5322 // @@ -186,27 +186,7 @@ function parse5322(opts) { // "First Last" -> "First Last" // "First Last" -> "First Last" function collapseWhitespace(s) { - function isWhitespace(c) { - return c === ' ' || - c === '\t' || - c === '\r' || - c === '\n'; - } - var i, str; - str = ""; - for (i = 0; i < s.length; i += 1) { - if (!isWhitespace(s[i]) || !isWhitespace(s[i + 1])) { - str += s[i]; - } - } - - if (isWhitespace(str[0])) { - str = str.substring(1); - } - if (isWhitespace(str[str.length - 1])) { - str = str.substring(0, str.length - 1); - } - return str; + return s.replace(/([ \t]|\r\n)+/g, ' ').replace(/^\s*/, '').replace(/\s*$/, ''); } // UTF-8 pseudo-production (RFC 6532) @@ -597,10 +577,14 @@ function parse5322(opts) { return wrap('domain', function domainCheckTLD() { var result = or(obsDomain, dotAtom, domainLiteral)(); if (opts.rejectTLD) { - if (result.semantic.indexOf('.') < 0) { + if (result && result.semantic && result.semantic.indexOf('.') < 0) { return null; } } + // strip all whitespace from domains + if (result) { + result.semantic = result.semantic.replace(/\s+/g, ''); + } return result; }()); } @@ -612,6 +596,36 @@ function parse5322(opts) { )()); } + // 3.6.2 Originator Fields + // Below we only parse the field body, not the name of the field + // like "From:", "Sender:", or "Reply-To:". Other libraries that + // parse email headers can parse those and defer to these productions + // for the "RFC 5322" part. + + // RFC 6854 2.1. Replacement of RFC 5322, Section 3.6.2. Originator Fields + // from = "From:" (mailbox-list / address-list) CRLF + function fromSpec() { + return wrap('from', or( + mailboxList, + addressList + )()); + } + + // RFC 6854 2.1. Replacement of RFC 5322, Section 3.6.2. Originator Fields + // sender = "Sender:" (mailbox / address) CRLF + function senderSpec() { + return wrap('sender', or( + mailbox, + address + )()); + } + + // RFC 6854 2.1. Replacement of RFC 5322, Section 3.6.2. Originator Fields + // reply-to = "Reply-To:" address-list CRLF + function replyToSpec() { + return wrap('reply-to', addressList()); + } + // 4.1. Miscellaneous Obsolete Tokens // obs-NO-WS-CTL = %d1-8 / ; US-ASCII control @@ -766,92 +780,186 @@ function parse5322(opts) { // ast analysis function findNode(name, root) { - var i, queue, node; + var i, stack, node; if (root === null || root === undefined) { return null; } - queue = [root]; - while (queue.length > 0) { - node = queue.shift(); + stack = [root]; + while (stack.length > 0) { + node = stack.pop(); if (node.name === name) { return node; } - for (i = 0; i < node.children.length; i += 1) { - queue.push(node.children[i]); + for (i = node.children.length - 1; i >= 0; i -= 1) { + stack.push(node.children[i]); } } return null; } function findAllNodes(name, root) { - var i, queue, node, result; + var i, stack, node, result; if (root === null || root === undefined) { return null; } - queue = [root]; + stack = [root]; result = []; - while (queue.length > 0) { - node = queue.shift(); + while (stack.length > 0) { + node = stack.pop(); if (node.name === name) { result.push(node); } - for (i = 0; i < node.children.length; i += 1) { - queue.push(node.children[i]); + for (i = node.children.length - 1; i >= 0; i -= 1) { + stack.push(node.children[i]); + } + } + return result; + } + + function findAllNodesNoChildren(names, root) { + var i, stack, node, result, namesLookup; + if (root === null || root === undefined) { return null; } + stack = [root]; + result = []; + namesLookup = {}; + for (i = 0; i < names.length; i += 1) { + namesLookup[names[i]] = true; + } + + while (stack.length > 0) { + node = stack.pop(); + if (node.name in namesLookup) { + result.push(node); + // don't look at children (hence findAllNodesNoChildren) + } else { + for (i = node.children.length - 1; i >= 0; i -= 1) { + stack.push(node.children[i]); + } } } return result; } function giveResult(ast) { - function grabSemantic(n) { - return n !== null ? n.semantic : null; - } - var i, ret, addresses, addr, name, aspec, local, domain; + var addresses, groupsAndMailboxes, i, groupOrMailbox, result; if (ast === null) { return null; } - ret = { ast: ast }; - addresses = findAllNodes('address', ast); - ret.addresses = []; - for (i = 0; i < addresses.length; i += 1) { - addr = addresses[i]; - name = findNode('display-name', addr); - aspec = findNode('addr-spec', addr); - local = findNode('local-part', aspec); - domain = findNode('domain', aspec); - ret.addresses.push({ - node: addr, - parts: { - name: name, - address: aspec, - local: local, - domain: domain - }, - name: grabSemantic(name), - address: grabSemantic(aspec), - local: grabSemantic(local), - domain: grabSemantic(domain) - }); - } + addresses = []; - if (opts.simple) { - ret = ret.addresses; - for (i = 0; i < ret.length; i += 1) { - delete ret[i].node; + // An address is a 'group' (i.e. a list of mailboxes) or a 'mailbox'. + groupsAndMailboxes = findAllNodesNoChildren(['group', 'mailbox'], ast); + for (i = 0; i < groupsAndMailboxes.length; i += 1) { + groupOrMailbox = groupsAndMailboxes[i]; + if (groupOrMailbox.name === 'group') { + addresses.push(giveResultGroup(groupOrMailbox)); + } else if (groupOrMailbox.name === 'mailbox') { + addresses.push(giveResultMailbox(groupOrMailbox)); } } - return ret; + + result = { + ast: ast, + addresses: addresses, + }; + if (opts.simple) { + result = simplifyResult(result); + } + if (opts.oneResult) { + return oneResult(result); + } + if (opts.simple) { + return result && result.addresses; + } else { + return result; + } + } + + function giveResultGroup(group) { + var i; + var groupName = findNode('display-name', group); + var groupResultMailboxes = []; + var mailboxes = findAllNodesNoChildren(['mailbox'], group); + for (i = 0; i < mailboxes.length; i += 1) { + groupResultMailboxes.push(giveResultMailbox(mailboxes[i])); + } + return { + node: group, + parts: { + name: groupName, + }, + type: group.name, // 'group' + name: grabSemantic(groupName), + addresses: groupResultMailboxes, + }; + } + + function giveResultMailbox(mailbox) { + var name = findNode('display-name', mailbox); + var aspec = findNode('addr-spec', mailbox); + var comments = findAllNodes('cfws', mailbox); + + var local = findNode('local-part', aspec); + var domain = findNode('domain', aspec); + return { + node: mailbox, + parts: { + name: name, + address: aspec, + local: local, + domain: domain, + comments: comments + }, + type: mailbox.name, // 'mailbox' + name: grabSemantic(name), + address: grabSemantic(aspec), + local: grabSemantic(local), + domain: grabSemantic(domain), + groupName: grabSemantic(mailbox.groupName), + }; + } + + function grabSemantic(n) { + return n !== null && n !== undefined ? n.semantic : null; + } + + function simplifyResult(result) { + var i; + if (result && result.addresses) { + for (i = 0; i < result.addresses.length; i += 1) { + delete result.addresses[i].node; + } + } + return result; + } + + function oneResult(result) { + if (!result) { return null; } + if (!opts.partial && result.addresses.length > 1) { return null; } + return result.addresses && result.addresses[0]; } ///////////////////////////////////////////////////// - var parseString, pos, len, parsed; + var parseString, pos, len, parsed, startProduction; opts = handleOpts(opts, {}); if (opts === null) { return null; } parseString = opts.input; + startProduction = { + 'address': address, + 'address-list': addressList, + 'angle-addr': angleAddr, + 'from': fromSpec, + 'group': group, + 'mailbox': mailbox, + 'mailbox-list': mailboxList, + 'reply-to': replyToSpec, + 'sender': senderSpec, + }[opts.startAt] || addressList; + if (!opts.strict) { initialize(); opts.strict = true; - parsed = addressList(parseString); + parsed = startProduction(parseString); if (opts.partial || !inStr()) { return giveResult(parsed); } @@ -859,46 +967,51 @@ function parse5322(opts) { } initialize(); - parsed = addressList(parseString); + parsed = startProduction(parseString); if (!opts.partial && inStr()) { return null; } return giveResult(parsed); } function parseOneAddressSimple(opts) { - var result; - - opts = handleOpts(opts, { + return parse5322(handleOpts(opts, { + oneResult: true, rfc6532: true, - simple: true - }); - if (opts === null) { return null; } - - result = parse5322(opts); - - if ((!result) || - (!opts.partial && - (opts.simple && result.length > 1) || - (!opts.simple && result.addresses.length > 1))) { - return null; - } - - return opts.simple ? - result && result[0] : - result && result.addresses && result.addresses[0]; + simple: true, + startAt: 'address-list', + })); } function parseAddressListSimple(opts) { - var result; - - opts = handleOpts(opts, { + return parse5322(handleOpts(opts, { rfc6532: true, - simple: true - }); - if (opts === null) { return null; } + simple: true, + startAt: 'address-list', + })); +} - result = parse5322(opts); +function parseFromSimple(opts) { + return parse5322(handleOpts(opts, { + rfc6532: true, + simple: true, + startAt: 'from', + })); +} - return opts.simple ? result : result.addresses; +function parseSenderSimple(opts) { + return parse5322(handleOpts(opts, { + oneResult: true, + rfc6532: true, + simple: true, + startAt: 'sender', + })); +} + +function parseReplyToSimple(opts) { + return parse5322(handleOpts(opts, { + rfc6532: true, + simple: true, + startAt: 'reply-to', + })); } function handleOpts(opts, defs) { @@ -926,24 +1039,28 @@ function handleOpts(opts, defs) { if (!defs) { return null; } defaults = { - rfc6532: false, + oneResult: false, partial: false, + rejectTLD: false, + rfc6532: false, simple: false, + startAt: 'address-list', strict: false, - rejectTLD: false }; for (o in defaults) { if (isNullUndef(opts[o])) { opts[o] = !isNullUndef(defs[o]) ? defs[o] : defaults[o]; } - opts[o] = !!opts[o]; } return opts; } parse5322.parseOneAddress = parseOneAddressSimple; parse5322.parseAddressList = parseAddressListSimple; +parse5322.parseFrom = parseFromSimple; +parse5322.parseSender = parseSenderSimple; +parse5322.parseReplyTo = parseReplyToSimple; // in Zammad context, go back to non CommonJS // if (typeof module !== 'undefined' && typeof module.exports !== 'undefined') { diff --git a/app/assets/javascripts/app/lib/base/jquery.contenteditable.js b/app/assets/javascripts/app/lib/base/jquery.contenteditable.js index a82a036dc..85618ba36 100644 --- a/app/assets/javascripts/app/lib/base/jquery.contenteditable.js +++ b/app/assets/javascripts/app/lib/base/jquery.contenteditable.js @@ -289,15 +289,16 @@ var result = e.target.result var img = document.createElement('img') img.src = result + maxWidth = _this.$element.width() || 500 + scaleFactor = 2 + //scaleFactor = 1 + //if (window.isRetina && window.isRetina()) { + // scaleFactor = 2 + //} - insert = function(dataUrl, width, height, isRetina) { + insert = function(dataUrl, width, height, isResized) { //console.log('dataUrl', dataUrl) - - // adapt image if we are on retina devices - if (!isRetina && window.isRetina && window.isRetina()) { - width = width / 2 - height = height / 2 - } + //console.log('scaleFactor', scaleFactor, isResized, maxWidth, width, height) _this.log('image inserted') result = dataUrl if (_this.options.imageWidth == 'absolute') { @@ -310,7 +311,7 @@ } // resize if to big - App.ImageService.resize(img.src, 460, 'auto', 2, 'image/jpeg', 'auto', insert) + App.ImageService.resize(img.src, maxWidth, 'auto', scaleFactor, 'image/jpeg', 'auto', insert) } reader.readAsDataURL(imageFile) imageInserted = true @@ -416,17 +417,18 @@ var result = e.target.result var img = document.createElement('img') img.src = result + maxWidth = _this.$element.width() || 500 + scaleFactor = 2 + //scaleFactor = 1 + //if (window.isRetina && window.isRetina()) { + // scaleFactor = 2 + //} //Insert the image at the carat - insert = function(dataUrl, width, height, isRetina) { - - // adapt image if we are on retina devices - if (!isRetina && window.isRetina && window.isRetina()) { - width = width / 2 - height = height / 2 - } + insert = function(dataUrl, width, height, isResized) { //console.log('dataUrl', dataUrl) + //console.log('scaleFactor', scaleFactor, isResized, maxWidth, width, height) _this.log('image inserted') result = dataUrl if (_this.options.imageWidth == 'absolute') { @@ -454,7 +456,7 @@ } // resize if to big - App.ImageService.resize(img.src, 460, 'auto', 2, 'image/jpeg', 'auto', insert) + App.ImageService.resize(img.src, maxWidth, 'auto', scaleFactor, 'image/jpeg', 'auto', insert) }) reader.readAsDataURL(file) } diff --git a/app/assets/javascripts/app/lib/base/word_filter.js b/app/assets/javascripts/app/lib/base/word_filter.js index 9300d07f9..cd73b5b2e 100644 --- a/app/assets/javascripts/app/lib/base/word_filter.js +++ b/app/assets/javascripts/app/lib/base/word_filter.js @@ -74,8 +74,9 @@ window.word_filter = function(editor){ } }) - $('[style]', editor).removeAttr('style'); - $('[align]', editor).removeAttr('align'); + // style and align is handled by utils.coffee it self, don't clean it here + //$('[style]', editor).removeAttr('style'); + //$('[align]', editor).removeAttr('align'); $('span', editor).replaceWith(function() {return $(this).contents();}); $('span:empty', editor).remove(); $("[class^='Mso']", editor).removeAttr('class'); diff --git a/app/assets/javascripts/app/lib/bootstrap/bootstrap-datepicker.js b/app/assets/javascripts/app/lib/bootstrap/bootstrap-datepicker.js index cf25e7b64..c08105f0b 100755 --- a/app/assets/javascripts/app/lib/bootstrap/bootstrap-datepicker.js +++ b/app/assets/javascripts/app/lib/bootstrap/bootstrap-datepicker.js @@ -26,6 +26,7 @@ - allow custom template as options parameter - fix that place method doesn't think that the container is the window, but rather the real window is the window - added rerender method to show correct today if task is longer open the 24 hours + - scroll into view */ (function(factory){ @@ -515,7 +516,9 @@ ) ) this.setValue(); - this._trigger('hide'); + // 2018-01-22 trigger locale hide event - conflicts with modal hide + //this._trigger('hide'); + this._trigger('hide.bs.datepicker'); return this; }, @@ -757,6 +760,16 @@ zIndex: zIndex }); } + + // adjust scroll of scrollParent + var scrollParent = this.picker.scrollParent(); + var bottomEdge = offset.top + height + this.picker.outerHeight(); + var scrollBottomEdge = scrollParent.scrollTop() + scrollParent.height(); + + if(bottomEdge > scrollBottomEdge){ + scrollParent.scrollTop(scrollParent.scrollTop() + (bottomEdge - scrollBottomEdge) + 10); + } + return this; }, diff --git a/app/assets/javascripts/app/models/_application_model.coffee b/app/assets/javascripts/app/models/_application_model.coffee index cf41a2c23..660af96df 100644 --- a/app/assets/javascripts/app/models/_application_model.coffee +++ b/app/assets/javascripts/app/models/_application_model.coffee @@ -387,15 +387,17 @@ set new attributes of model (remove already available attributes) => return if _.isEmpty(@SUBSCRIPTION_COLLECTION) App.Log.debug('Model', "server notify collection change #{@className}") - @fetchFull( - -> - clear: true - ) + callback = => + @fetchFull( + -> + clear: true + ) + App.Delay.set(callback, 200, "full-#{@className}") "Collection::Subscribe::#{@className}" ) - key = @className + '-' + Math.floor( Math.random() * 99999 ) + key = "#{@className}-#{Math.floor(Math.random() * 99999)}" @SUBSCRIPTION_COLLECTION[key] = callback # fetch init collection diff --git a/app/assets/javascripts/app/models/chat.coffee b/app/assets/javascripts/app/models/chat.coffee index 4a93284bb..ddd0f7eaa 100644 --- a/app/assets/javascripts/app/models/chat.coffee +++ b/app/assets/javascripts/app/models/chat.coffee @@ -1,16 +1,260 @@ class App.Chat extends App.Model - @configure 'Chat', 'name', 'active', 'public', 'max_queue', 'note' + @configure 'Chat', 'name', 'active', 'public', 'max_queue', 'block_ip', 'block_country', 'note' @extend Spine.Model.Ajax @url: @apiPath + '/chats' + @countries: + AF: 'Afghanistan' + AL: 'Albania' + DZ: 'Algeria' + AS: 'American Samoa' + AD: 'Andorra' + AO: 'Angola' + AI: 'Anguilla' + AQ: 'Antarctica' + AG: 'Antigua And Barbuda' + AR: 'Argentina' + AM: 'Armenia' + AW: 'Aruba' + AU: 'Australia' + AT: 'Austria' + AZ: 'Azerbaijan' + BS: 'Bahamas' + BH: 'Bahrain' + BD: 'Bangladesh' + BB: 'Barbados' + BY: 'Belarus' + BE: 'Belgium' + BZ: 'Belize' + BJ: 'Benin' + BM: 'Bermuda' + BT: 'Bhutan' + BO: 'Bolivia' + BA: 'Bosnia And Herzegovina' + BW: 'Botswana' + BV: 'Bouvet Island' + BR: 'Brazil' + IO: 'British Indian Ocean Territory' + BN: 'Brunei Darussalam' + BG: 'Bulgaria' + BF: 'Burkina Faso' + BI: 'Burundi' + KH: 'Cambodia' + CM: 'Cameroon' + CA: 'Canada' + CV: 'Cape Verde' + KY: 'Cayman Islands' + CF: 'Central African Republic' + TD: 'Chad' + CL: 'Chile' + CN: 'China' + CX: 'Christmas Island' + CC: 'Cocos (keeling) Islands' + CO: 'Colombia' + KM: 'Comoros' + CG: 'Congo' + CD: 'Congo, The Democratic Republic Of The' + CK: 'Cook Islands' + CR: 'Costa Rica' + CI: 'Cote D\'ivoire' + HR: 'Croatia' + CU: 'Cuba' + CY: 'Cyprus' + CZ: 'Czech Republic' + DK: 'Denmark' + DJ: 'Djibouti' + DM: 'Dominica' + DO: 'Dominican Republic' + TP: 'East Timor' + EC: 'Ecuador' + EG: 'Egypt' + SV: 'El Salvador' + GQ: 'Equatorial Guinea' + ER: 'Eritrea' + EE: 'Estonia' + ET: 'Ethiopia' + FK: 'Falkland Islands (malvinas)' + FO: 'Faroe Islands' + FJ: 'Fiji' + FI: 'Finland' + FR: 'France' + GF: 'French Guiana' + PF: 'French Polynesia' + TF: 'French Southern Territories' + GA: 'Gabon' + GM: 'Gambia' + GE: 'Georgia' + DE: 'Germany' + GH: 'Ghana' + GI: 'Gibraltar' + GR: 'Greece' + GL: 'Greenland' + GD: 'Grenada' + GP: 'Guadeloupe' + GU: 'Guam' + GT: 'Guatemala' + GN: 'Guinea' + GW: 'Guinea-bissau' + GY: 'Guyana' + HT: 'Haiti' + HM: 'Heard Island And Mcdonald Islands' + VA: 'Holy See (vatican City State)' + HN: 'Honduras' + HK: 'Hong Kong' + HU: 'Hungary' + IS: 'Iceland' + IN: 'India' + ID: 'Indonesia' + IR: 'Iran, Islamic Republic Of' + IQ: 'Iraq' + IE: 'Ireland' + IL: 'Israel' + IT: 'Italy' + JM: 'Jamaica' + JP: 'Japan' + JO: 'Jordan' + KZ: 'Kazakstan' + KE: 'Kenya' + KI: 'Kiribati' + KP: 'Korea, Democratic People\'s Republic Of' + KR: 'Korea, Republic Of' + KV: 'Kosovo' + KW: 'Kuwait' + KG: 'Kyrgyzstan' + LA: 'Lao People\'s Democratic Republic' + LV: 'Latvia' + LB: 'Lebanon' + LS: 'Lesotho' + LR: 'Liberia' + LY: 'Libyan Arab Jamahiriya' + LI: 'Liechtenstein' + LT: 'Lithuania' + LU: 'Luxembourg' + MO: 'Macau' + MK: 'Macedonia, The Former Yugoslav Republic Of' + MG: 'Madagascar' + MW: 'Malawi' + MY: 'Malaysia' + MV: 'Maldives' + ML: 'Mali' + MT: 'Malta' + MH: 'Marshall Islands' + MQ: 'Martinique' + MR: 'Mauritania' + MU: 'Mauritius' + YT: 'Mayotte' + MX: 'Mexico' + FM: 'Micronesia, Federated States Of' + MD: 'Moldova, Republic Of' + MC: 'Monaco' + MN: 'Mongolia' + MS: 'Montserrat' + ME: 'Montenegro' + MA: 'Morocco' + MZ: 'Mozambique' + MM: 'Myanmar' + NA: 'Namibia' + NR: 'Nauru' + NP: 'Nepal' + NL: 'Netherlands' + AN: 'Netherlands Antilles' + NC: 'New Caledonia' + NZ: 'New Zealand' + NI: 'Nicaragua' + NE: 'Niger' + NG: 'Nigeria' + NU: 'Niue' + NF: 'Norfolk Island' + MP: 'Northern Mariana Islands' + NO: 'Norway' + OM: 'Oman' + PK: 'Pakistan' + PW: 'Palau' + PS: 'Palestinian Territory, Occupied' + PA: 'Panama' + PG: 'Papua New Guinea' + PY: 'Paraguay' + PE: 'Peru' + PH: 'Philippines' + PN: 'Pitcairn' + PL: 'Poland' + PT: 'Portugal' + PR: 'Puerto Rico' + QA: 'Qatar' + RE: 'Reunion' + RO: 'Romania' + RU: 'Russian Federation' + RW: 'Rwanda' + SH: 'Saint Helena' + KN: 'Saint Kitts And Nevis' + LC: 'Saint Lucia' + PM: 'Saint Pierre And Miquelon' + VC: 'Saint Vincent And The Grenadines' + WS: 'Samoa' + SM: 'San Marino' + ST: 'Sao Tome And Principe' + SA: 'Saudi Arabia' + SN: 'Senegal' + RS: 'Serbia' + SC: 'Seychelles' + SL: 'Sierra Leone' + SG: 'Singapore' + SK: 'Slovakia' + SI: 'Slovenia' + SB: 'Solomon Islands' + SO: 'Somalia' + ZA: 'South Africa' + GS: 'South Georgia And The South Sandwich Islands' + ES: 'Spain' + LK: 'Sri Lanka' + SD: 'Sudan' + SR: 'Suriname' + SJ: 'Svalbard And Jan Mayen' + SZ: 'Swaziland' + SE: 'Sweden' + CH: 'Switzerland' + SY: 'Syrian Arab Republic' + TW: 'Taiwan, Province Of China' + TJ: 'Tajikistan' + TZ: 'Tanzania, United Republic Of' + TH: 'Thailand' + TG: 'Togo' + TK: 'Tokelau' + TO: 'Tonga' + TT: 'Trinidad And Tobago' + TN: 'Tunisia' + TR: 'Turkey' + TM: 'Turkmenistan' + TC: 'Turks And Caicos Islands' + TV: 'Tuvalu' + UG: 'Uganda' + UA: 'Ukraine' + AE: 'United Arab Emirates' + GB: 'United Kingdom' + US: 'United States' + UM: 'United States Minor Outlying Islands' + UY: 'Uruguay' + UZ: 'Uzbekistan' + VU: 'Vanuatu' + VE: 'Venezuela' + VN: 'Viet Nam' + VG: 'Virgin Islands, British' + VI: 'Virgin Islands, U.s.' + WF: 'Wallis And Futuna' + EH: 'Western Sahara' + YE: 'Yemen' + ZM: 'Zambia' + ZW: 'Zimbabwe' @configure_attributes = [ { name: 'name', display: 'Name', tag: 'input', type: 'text', limit: 100, null: false }, { name: 'note', display: 'Note', tag: 'textarea', limit: 250, null: true }, - #{ name: 'public', display: 'Public', tag: 'boolean', default: true }, - { name: 'max_queue', display: 'Max. clients in waitlist', tag: 'input', default: 2 }, + { name: 'max_queue', display: 'Max. clients in waitlist', tag: 'input', default: 2 }, + { name: 'block_ip', display: 'Blocked IPs (separated by ;)', tag: 'input', default: '', null: true }, + { name: 'block_country', display: 'Blocked countries', tag: 'column_select', multiple: true, null: true, default: '', options: @countries, seperator: ';' }, { name: 'active', display: 'Active', tag: 'active', default: true }, { name: 'created_by_id', display: 'Created by', relation: 'User', readonly: 1 }, { name: 'created_at', display: 'Created', tag: 'datetime', readonly: 1 }, { name: 'updated_by_id', display: 'Updated by', relation: 'User', readonly: 1 }, { name: 'updated_at', display: 'Updated', tag: 'datetime', readonly: 1 }, ] + diff --git a/app/assets/javascripts/app/models/chat_sessions.coffee b/app/assets/javascripts/app/models/chat_sessions.coffee new file mode 100644 index 000000000..d32fccd18 --- /dev/null +++ b/app/assets/javascripts/app/models/chat_sessions.coffee @@ -0,0 +1,32 @@ +class App.ChatSession extends App.Model + @configure 'ChatSession', 'name', 'note' + @extend Spine.Model.Ajax + @url: @apiPath + '/chat_sessions' + + @configure_attributes = [ + { name: 'name', display: 'Name', tag: 'input', type: 'text', limit: 100, 'null': false } + { name: 'state', display: 'State', readonly: 1 } + { name: 'created_by_id', display: 'Created by', relation: 'User', readonly: 1 } + { name: 'created_at', display: 'Created', tag: 'datetime', readonly: 1 } + { name: 'updated_by_id', display: 'Updated by', relation: 'User', readonly: 1 } + { name: 'updated_at', display: 'Updated', tag: 'datetime', readonly: 1 } + ] + + @configure_overview = [ + 'name', + 'state', + 'created_at', + ] + + uiUrl: -> + "#customer_chat/session/#{@id}" + + searchResultAttributes: -> + displayName = '' + if !_.isEmpty(@name) + displayName = @displayName() + display: "##{@id} #{displayName}" + id: @id + class: 'chat_session chat_session-popover' + url: @uiUrl() + icon: 'chat' diff --git a/app/assets/javascripts/app/models/overview.coffee b/app/assets/javascripts/app/models/overview.coffee index 85581873c..d6ad48a3c 100644 --- a/app/assets/javascripts/app/models/overview.coffee +++ b/app/assets/javascripts/app/models/overview.coffee @@ -1,5 +1,5 @@ class App.Overview extends App.Model - @configure 'Overview', 'name', 'prio', 'condition', 'order', 'group_by', 'view', 'user_ids', 'organization_shared', 'role_ids', 'order', 'group_by', 'active', 'updated_at' + @configure 'Overview', 'name', 'prio', 'condition', 'order', 'group_by', 'view', 'user_ids', 'organization_shared', 'role_ids', 'active' @extend Spine.Model.Ajax @url: @apiPath + '/overviews' @configure_attributes = [ diff --git a/app/assets/javascripts/app/models/ticket.coffee b/app/assets/javascripts/app/models/ticket.coffee index ac9d93df1..87865f956 100644 --- a/app/assets/javascripts/app/models/ticket.coffee +++ b/app/assets/javascripts/app/models/ticket.coffee @@ -200,6 +200,26 @@ class App.Ticket extends App.Model result = true if objectValue.toString().match(contains_regex) else if condition.operator == 'contains not' result = true if !objectValue.toString().match(contains_regex) + else if condition.operator == 'contains all' + result = true + for loopConditionValue in conditionValue + if !_.contains(objectValue, loopConditionValue) + result = false + else if condition.operator == 'contains one' + result = false + for loopConditionValue in conditionValue + if _.contains(objectValue, loopConditionValue) + result = true + else if condition.operator == 'contains all not' + result = true + for loopObjectValue in objectValue + if _.contains(conditionValue, loopObjectValue) + result = false + else if condition.operator == 'contains one not' + result = false + for loopObjectValue in objectValue + if !_.contains(conditionValue, loopObjectValue) + result = true else if condition.operator == 'is' result = true if objectValue.toString().trim().toLowerCase() is loopConditionValue.toString().trim().toLowerCase() else if condition.operator == 'is not' @@ -224,3 +244,19 @@ class App.Ticket extends App.Model throw "Unknown operator: #{condition.operator}" result + + editable: (permission = 'change') -> + user_id = App.Session.get('id') + return true if user_id is @customer_id + group_ids = App.Session.get('group_ids') + if group_ids + return true if group_ids[@group_id] && (_.include(group_ids[@group_id], permission) || _.include(group_ids[@group_id], 'full')) + role_ids = App.Session.get('role_ids') + if role_ids + for role_id in role_ids + if App.Role.exists(role_id) + role = App.Role.find(role_id) + if role.group_ids + return true if role.group_ids[@group_id] && (_.include(role.group_ids[@group_id], permission) || _.include(role.group_ids[@group_id], 'full')) + false + diff --git a/app/assets/javascripts/app/views/agent_ticket_create.jst.eco b/app/assets/javascripts/app/views/agent_ticket_create.jst.eco index 1f4804fc9..669cca9fe 100644 --- a/app/assets/javascripts/app/views/agent_ticket_create.jst.eco +++ b/app/assets/javascripts/app/views/agent_ticket_create.jst.eco @@ -3,28 +3,31 @@
    -
  • <%- @Icon('received-calls', 'tab-icon') %> <%- @T('Received Call') %>
  • -
  • <%- @Icon('outbound-calls', 'tab-icon') %> <%- @T('Outbound Call') %>
  • - -
  • +
  • <%- @Icon('email', 'tab-icon') %> <%- @T('Send Email') %>
+ <% if !_.isEmpty(@C('ui_ticket_create_notes')): %> + <% for type, note of @C('ui_ticket_create_notes'): %> + + <% end %> + <% end %> +
@@ -46,7 +49,6 @@
-
\ No newline at end of file +
<%- @T('Turn chat into ticket') %>
+ \ No newline at end of file diff --git a/app/assets/javascripts/app/views/customer_chat/chat_window.jst.eco b/app/assets/javascripts/app/views/customer_chat/chat_window.jst.eco index 1fef2ed9b..de2f271da 100644 --- a/app/assets/javascripts/app/views/customer_chat/chat_window.jst.eco +++ b/app/assets/javascripts/app/views/customer_chat/chat_window.jst.eco @@ -7,9 +7,7 @@
- <%= @name %>
-
<%- @Icon('info') %>
-
+ <%= @name %> #<%= @session.id %>
<%- @T('disconnect') %>
@@ -24,6 +22,25 @@
+
diff --git a/app/assets/javascripts/app/views/customer_chat/chat_window_info.jst.eco b/app/assets/javascripts/app/views/customer_chat/chat_window_info.jst.eco deleted file mode 100644 index a7c3b1ff3..000000000 --- a/app/assets/javascripts/app/views/customer_chat/chat_window_info.jst.eco +++ /dev/null @@ -1,17 +0,0 @@ -
-
    -<% if @session: %> -
  • <%- @T('Created at') %>: <%- @Ttimestamp(@session.created_at) %> -<% end %> -<% if @session && @session.preferences: %> - <% if @session.preferences.geo_ip: %> -
  • GeoIP: <%= @session.preferences.geo_ip.country_name %> <%= @session.preferences.geo_ip.city_name %> - <% end %> - <% if @session.preferences.remote_ip: %> -
  • IP: <%= @session.preferences.remote_ip %> - <% end %> - <% if @session.preferences.dns_name: %> -
  • DNS: <%= @session.preferences.dns_name %> - <% end %> -<% end %> -
\ No newline at end of file diff --git a/app/assets/javascripts/app/views/generic/attachment_item.jst.eco b/app/assets/javascripts/app/views/generic/attachment_item.jst.eco index dfa927c95..d4de0ce4a 100644 --- a/app/assets/javascripts/app/views/generic/attachment_item.jst.eco +++ b/app/assets/javascripts/app/views/generic/attachment_item.jst.eco @@ -1,7 +1,7 @@
-
<%= @fileName %>
-
<%= @fileSize %>
-
+
<%= @filename %>
+
<%= @humanFileSize(@size) %>
+
<%- @Icon('diagonal-cross') %><%- @T('Delete File') %>
\ No newline at end of file diff --git a/app/assets/javascripts/app/views/generic/column_select.jst.eco b/app/assets/javascripts/app/views/generic/column_select.jst.eco index 301dd0415..196bcb833 100644 --- a/app/assets/javascripts/app/views/generic/column_select.jst.eco +++ b/app/assets/javascripts/app/views/generic/column_select.jst.eco @@ -1,3 +1,6 @@ +<% if @attribute.seperator: %> + +<% else: %> +<% end %>
<%- @T('Nothing selected') %>
<% for option in @attribute.options: %> diff --git a/app/assets/javascripts/app/views/generic/sidebar_tabs.jst.eco b/app/assets/javascripts/app/views/generic/sidebar_tabs.jst.eco index d1bd32527..f41f1fb33 100644 --- a/app/assets/javascripts/app/views/generic/sidebar_tabs.jst.eco +++ b/app/assets/javascripts/app/views/generic/sidebar_tabs.jst.eco @@ -1,7 +1,7 @@ <% for item in @items: %>