From e54fd4a36ae3c543431d62198ab92186ee703983 Mon Sep 17 00:00:00 2001 From: Florian Liebe Date: Mon, 11 Mar 2024 08:34:17 +0100 Subject: [PATCH] Fixes #5010 - WhatsApp-Business Channel (third iteration). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Florian Liebe Co-authored-by: Tobias Schäfer Co-authored-by: Dusan Vuckovic Co-authored-by: Martin Gruner Co-authored-by: Mantas Masalskis Co-authored-by: Benjamin Scharf Co-authored-by: Rolf Schmidt Co-authored-by: Dominik Klein --- .../app/controllers/_channel/whatsapp.coffee | 14 ++ .../app/controllers/ticket_zoom.coffee | 44 +++- .../app/controllers/ticket_zoom/alert.coffee | 19 ++ .../article_action/whatsapp_reply.coffee | 29 ++- .../ticket_zoom/article_new.coffee | 230 ++++++++++++++++-- .../ticket_zoom/article_view.coffee | 24 ++ .../controllers/ticket_zoom/channel.coffee | 32 +++ .../app/lib/app_post/html5_upload.coffee | 1 + .../javascripts/app/lib/base/html5Upload.js | 5 + .../javascripts/app/views/ticket_zoom.jst.eco | 1 + .../app/views/ticket_zoom/alert.jst.eco | 3 + .../views/ticket_zoom/article_view.jst.eco | 9 +- .../views/whatsapp/account_cloud_api.jst.eco | 6 +- .../whatsapp/account_phone_number.jst.eco | 16 +- .../app/views/whatsapp/index.jst.eco | 1 + .../app/views/widget/http_log_show.jst.eco | 4 +- app/assets/stylesheets/zammad.scss | 30 +++ app/controllers/attachments_controller.rb | 7 +- .../concerns/creates_ticket_articles.rb | 2 +- app/controllers/ticket_articles_controller.rb | 13 + app/controllers/upload_caches_controller.rb | 7 +- .../Form/fields/FieldFile/FieldFileInput.vue | 36 ++- .../FieldFile/__tests__/FieldFile.spec.ts | 2 +- .../__tests__/useInstandValidation.spec.ts | 49 ++++ .../composables/useInstantValidation.ts | 85 +++++++ .../FieldFile/features/multipleFilesError.ts | 36 +++ .../components/Form/fields/FieldFile/index.ts | 7 +- .../form/theme/global/getCoreMobileClasses.ts | 3 + .../initializeGlobalComponentStyles.ts | 10 + .../ticket/__tests__/mocks/detail-view.ts | 8 +- .../__tests__/ticket-detail-view.spec.ts | 146 +++++++++++ .../TicketDetailView/ArticleBubble.vue | 7 + .../ArticleWhatsappMediaBadge.vue | 103 ++++++++ .../TicketDetailView/ArticlesList.vue | 1 + .../TicketDetailViewTitle.vue | 135 +++++----- .../ArticleWhatsappMediaBadge.spec.ts | 56 +++++ .../__tests__/TicketDetailViewTitle.spec.ts | 32 +++ .../ticket/composable/useTicketEditForm.ts | 31 ++- .../fragments/ticketArticleAttributes.api.ts | 3 + .../fragments/ticketArticleAttributes.graphql | 3 + .../graphql/fragments/ticketAttributes.api.ts | 1 + .../fragments/ticketAttributes.graphql | 1 + .../pages/ticket/views/TicketDetailView.vue | 9 +- .../components/CommonAlert/CommonAlert.vue | 2 +- app/frontend/shared/components/Form/Form.vue | 12 + .../plugins/__tests__/action-whatsapp.spec.ts | 49 ++++ .../ticket-article/action/plugins/types.ts | 8 + .../ticket-article/action/plugins/whatsapp.ts | 26 +- .../ticketArticleRetryMediaDownload.api.ts | 22 ++ .../ticketArticleRetryMediaDownload.graphql | 8 + .../ticketArticleRetryMediaDownload.mocks.ts | 12 + .../__tests__/channel-whatsapp.spec.ts | 129 ++++++++++ .../ticket/channel/plugins/__tests__/utils.ts | 64 +++++ .../entities/ticket/channel/plugins/index.ts | 26 ++ .../entities/ticket/channel/plugins/types.ts | 16 ++ .../ticket/channel/plugins/whatsapp.ts | 48 ++++ .../ticket/composables/useTicketChannel.ts | 21 ++ .../composables/useTicketPreferences.ts | 13 + .../__tests__/createValidationPlugin.spec.ts | 9 +- .../form/core/createValidationPlugin.ts | 4 +- .../__tests__/requiredValidation.spec.ts | 21 +- .../form/plugins/global/requiredValidation.ts | 65 +++-- .../form/validation/rules/captionLength.ts | 88 +++++++ .../form/validation/rules/contentRequired.ts | 21 ++ .../shared/form/validation/rules/fileSizes.ts | 54 ++++ .../shared/form/validation/rules/fileTypes.ts | 46 ++++ app/frontend/shared/graphql/types.ts | 58 ++++- app/frontend/shared/utils/files.ts | 165 +++++++++++++ app/graphql/gql/mutations/base_mutation.rb | 4 + .../ticket/article/retry_media_download.rb | 28 +++ .../gql/types/enum/channel/area_type.rb | 10 + .../ticket/article/media_error_state_type.rb | 13 + app/graphql/gql/types/ticket/article_type.rb | 5 + app/graphql/gql/types/ticket_type.rb | 7 + app/graphql/graphql_introspection.json | 162 ++++++++++++ app/jobs/scheduled_whatsapp_reminder_job.rb | 43 ++++ app/models/channel.rb | 40 ++- app/models/channel/driver/sms/base.rb | 11 +- app/models/ticket/article.rb | 2 + app/models/user.rb | 13 +- app/services/service/channel/whatsapp/base.rb | 3 +- .../service/channel/whatsapp/update.rb | 3 +- .../ticket/article/type/base_deliver.rb | 19 +- config/routes/ticket.rb | 21 +- i18n/zammad.pot | 173 ++++++++++++- lib/validations/ticket_article_validator.rb | 34 +++ .../ticket_article_validator/backend.rb | 27 ++ .../ticket_article_validator/default.rb | 11 + .../whatsapp_message.rb | 95 ++++++++ lib/whatsapp.rb | 38 +++ lib/whatsapp/client.rb | 28 ++- lib/whatsapp/incoming/message.rb | 21 -- lib/whatsapp/retry/media.rb | 72 ++++++ lib/whatsapp/webhook/message.rb | 78 +++++- lib/whatsapp/webhook/message/media.rb | 36 +-- lib/whatsapp/webhook/message/status.rb | 99 ++++++++ .../webhook/message/status/delivered.rb | 9 + lib/whatsapp/webhook/message/status/failed.rb | 72 ++++++ lib/whatsapp/webhook/message/status/read.rb | 9 + lib/whatsapp/webhook/message/status/sent.rb | 20 ++ lib/whatsapp/webhook/payload.rb | 26 +- spec/factories/channel.rb | 8 +- spec/factories/ticket.rb | 3 +- spec/factories/ticket/article.rb | 58 ++++- .../article/retry_media_download_spec.rb | 65 +++++ .../scheduled_whatsapp_reminder_job_spec.rb | 93 +++++++ .../ticket_article_validator/default_spec.rb | 31 +++ .../whatsapp_message_spec.rb | 140 +++++++++++ spec/lib/whatsapp/incoming/message_spec.rb | 40 --- spec/lib/whatsapp/retry/media_spec.rb | 59 +++++ .../whatsapp/webhook/message/audio_spec.rb | 21 +- .../whatsapp/webhook/message/document_spec.rb | 28 ++- .../whatsapp/webhook/message/image_spec.rb | 21 +- .../whatsapp/webhook/message/location_spec.rb | 23 +- .../webhook/message/status/delivered_spec.rb | 97 ++++++++ .../webhook/message/status/failed_spec.rb | 119 +++++++++ .../webhook/message/status/read_spec.rb | 97 ++++++++ .../webhook/message/status/sent_spec.rb | 103 ++++++++ .../whatsapp/webhook/message/sticker_spec.rb | 21 +- .../webhook/message/text_reminder_spec.rb | 115 +++++++++ .../lib/whatsapp/webhook/message/text_spec.rb | 23 +- .../whatsapp/webhook/message/video_spec.rb | 22 +- spec/lib/whatsapp/webhook/payload_spec.rb | 106 +++++++- .../enqueue_communicate_whatsapp_job_spec.rb | 2 +- spec/models/ticket/perform_changes_spec.rb | 4 +- spec/models/user_spec.rb | 41 ++++ .../service/channel/whatsapp/create_spec.rb | 1 - .../service/channel/whatsapp/update_spec.rb | 2 - spec/support/capybara/field_actions.rb | 23 ++ spec/system/channels/whatsapp_spec.rb | 12 +- .../system/ticket/zoom/whatsapp_reply_spec.rb | 118 ++++++++- 131 files changed, 4561 insertions(+), 415 deletions(-) create mode 100644 app/assets/javascripts/app/controllers/ticket_zoom/alert.coffee create mode 100644 app/assets/javascripts/app/controllers/ticket_zoom/channel.coffee create mode 100644 app/assets/javascripts/app/views/ticket_zoom/alert.jst.eco create mode 100644 app/frontend/apps/mobile/components/Form/fields/FieldFile/__tests__/useInstandValidation.spec.ts create mode 100644 app/frontend/apps/mobile/components/Form/fields/FieldFile/composables/useInstantValidation.ts create mode 100644 app/frontend/apps/mobile/components/Form/fields/FieldFile/features/multipleFilesError.ts create mode 100644 app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/ArticleWhatsappMediaBadge.vue create mode 100644 app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/__tests__/ArticleWhatsappMediaBadge.spec.ts create mode 100644 app/frontend/shared/entities/ticket-article/action/plugins/__tests__/action-whatsapp.spec.ts create mode 100644 app/frontend/shared/entities/ticket-article/graphql/mutations/ticketArticleRetryMediaDownload.api.ts create mode 100644 app/frontend/shared/entities/ticket-article/graphql/mutations/ticketArticleRetryMediaDownload.graphql create mode 100644 app/frontend/shared/entities/ticket-article/graphql/mutations/ticketArticleRetryMediaDownload.mocks.ts create mode 100644 app/frontend/shared/entities/ticket/channel/plugins/__tests__/channel-whatsapp.spec.ts create mode 100644 app/frontend/shared/entities/ticket/channel/plugins/__tests__/utils.ts create mode 100644 app/frontend/shared/entities/ticket/channel/plugins/index.ts create mode 100644 app/frontend/shared/entities/ticket/channel/plugins/types.ts create mode 100644 app/frontend/shared/entities/ticket/channel/plugins/whatsapp.ts create mode 100644 app/frontend/shared/entities/ticket/composables/useTicketChannel.ts create mode 100644 app/frontend/shared/entities/ticket/composables/useTicketPreferences.ts create mode 100644 app/frontend/shared/form/validation/rules/captionLength.ts create mode 100644 app/frontend/shared/form/validation/rules/contentRequired.ts create mode 100644 app/frontend/shared/form/validation/rules/fileSizes.ts create mode 100644 app/frontend/shared/form/validation/rules/fileTypes.ts create mode 100644 app/graphql/gql/mutations/ticket/article/retry_media_download.rb create mode 100644 app/graphql/gql/types/enum/channel/area_type.rb create mode 100644 app/graphql/gql/types/ticket/article/media_error_state_type.rb create mode 100644 app/jobs/scheduled_whatsapp_reminder_job.rb create mode 100644 lib/validations/ticket_article_validator.rb create mode 100644 lib/validations/ticket_article_validator/backend.rb create mode 100644 lib/validations/ticket_article_validator/default.rb create mode 100644 lib/validations/ticket_article_validator/whatsapp_message.rb create mode 100644 lib/whatsapp.rb delete mode 100644 lib/whatsapp/incoming/message.rb create mode 100644 lib/whatsapp/retry/media.rb create mode 100644 lib/whatsapp/webhook/message/status.rb create mode 100644 lib/whatsapp/webhook/message/status/delivered.rb create mode 100644 lib/whatsapp/webhook/message/status/failed.rb create mode 100644 lib/whatsapp/webhook/message/status/read.rb create mode 100644 lib/whatsapp/webhook/message/status/sent.rb create mode 100644 spec/graphql/gql/mutations/ticket/article/retry_media_download_spec.rb create mode 100644 spec/jobs/scheduled_whatsapp_reminder_job_spec.rb create mode 100644 spec/lib/validations/ticket_article_validator/default_spec.rb create mode 100644 spec/lib/validations/ticket_article_validator/whatsapp_message_spec.rb delete mode 100644 spec/lib/whatsapp/incoming/message_spec.rb create mode 100644 spec/lib/whatsapp/retry/media_spec.rb create mode 100644 spec/lib/whatsapp/webhook/message/status/delivered_spec.rb create mode 100644 spec/lib/whatsapp/webhook/message/status/failed_spec.rb create mode 100644 spec/lib/whatsapp/webhook/message/status/read_spec.rb create mode 100644 spec/lib/whatsapp/webhook/message/status/sent_spec.rb create mode 100644 spec/lib/whatsapp/webhook/message/text_reminder_spec.rb diff --git a/app/assets/javascripts/app/controllers/_channel/whatsapp.coffee b/app/assets/javascripts/app/controllers/_channel/whatsapp.coffee index 15cb75b8e7b5..9650211c5112 100644 --- a/app/assets/javascripts/app/controllers/_channel/whatsapp.coffee +++ b/app/assets/javascripts/app/controllers/_channel/whatsapp.coffee @@ -32,6 +32,11 @@ class ChannelWhatsapp extends App.ControllerSubContent channels: channels ) + new App.HttpLog( + el: @$('.js-log') + facility: 'WhatsApp::Business' + ) + new: (e) => e.preventDefault() @@ -166,6 +171,14 @@ class WhatsappAccountPhoneNumberModal extends App.ControllerModal preselected_group_id = if @channel then @channel.group_id else 1 + content.find('.js-reminderActive').replaceWith App.UiElement.switch.render( + name: 'reminder_active' + null: false + default: true + display: __('Automatic reminders') + value: if _.isUndefined(@channel?.options?.reminder_active) then true else @channel.options.reminder_active + ) + content.find('.js-messagesGroup').replaceWith App.UiElement.tree_select.render( name: 'group_id' multiple: false @@ -181,6 +194,7 @@ class WhatsappAccountPhoneNumberModal extends App.ControllerModal multiple: false value: @channel?.options?.phone_number_id || @params.available_phone_numbers?[0]?.value options: @params.available_phone_numbers?.map (elem) -> { name: elem.label, value: elem.value } + rejectNonExistentValues: true ) content diff --git a/app/assets/javascripts/app/controllers/ticket_zoom.coffee b/app/assets/javascripts/app/controllers/ticket_zoom.coffee index d5cf272b55d4..0fd9c231b619 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom.coffee @@ -2,9 +2,10 @@ class App.TicketZoom extends App.Controller @include App.TicketNavigable elements: - '.main': 'main' - '.ticketZoom': 'ticketZoom' - '.scrollPageHeader': 'scrollPageHeader' + '.main': 'main' + '.ticketZoom': 'ticketZoom' + '.scrollPageHeader': 'scrollPageHeader' + '.scrollPageAlert': 'scrollPageAlert' events: 'click .js-submit': 'submit' @@ -304,6 +305,10 @@ class App.TicketZoom extends App.Controller offset = element.offset() if offset position = offset.top + + # Subtract possible top padding of the parent container. + position -= parseInt(element.parent().css('paddingTop') or 0, 10) + Math.abs(position) hide: => @@ -394,14 +399,20 @@ class App.TicketZoom extends App.Controller positionPageHeaderUpdate: => headerHeight = @scrollPageHeader.outerHeight() - mainScrollHeigth = @main.prop('scrollHeight') - mainHeigth = @main.height() + alertHeight = if @isPageAlertVisible() then @scrollPageAlert.outerHeight() else 0 + mainScrollHeight = @main.prop('scrollHeight') + mainHeight = @main.height() scroll = @main.scrollTop() - # if page header is not possible to use - mainScrollHeigth to low - hide page header - if not mainScrollHeigth > mainHeigth + headerHeight + # if page header is not possible to use - mainScrollHeight to low - hide page header + if not mainScrollHeight > mainHeight + headerHeight @scrollPageHeader.css('transform', "translateY(#{-headerHeight}px)") + + if alertHeight + @scrollPageAlert.css('transform', 'translateY(0)') + @main.css('paddingTop', "#{alertHeight}px") + return if scroll > headerHeight @@ -414,8 +425,15 @@ class App.TicketZoom extends App.Controller # translateY: headerHeight .. 0 @scrollPageHeader.css('transform', "translateY(#{scroll - headerHeight}px)") + if alertHeight + @scrollPageAlert.css('transform', "translateY(#{scroll}px)") + @main.css('paddingTop', "#{scroll + alertHeight}px") + @scrollHeaderPos = scroll + isPageAlertVisible: => + not @scrollPageAlert.hasClass('hide') + pendingTimeReminderReached: => App.TaskManager.touch(@taskKey) @@ -502,6 +520,14 @@ class App.TicketZoom extends App.Controller ticket_id: @ticket_id ) + # Check if the alert should be shown. + # Normally, this is a concern of the associated channel, so we only render it if it's known. + if @ticket.preferences?.channel_id + new App.TicketZoomAlert( + el: elLocal.find('.js-ticketAlertContainer') + object_id: @ticket_id + ) + new App.TicketZoomSetting( el: elLocal.find('.js-settingContainer') ticket_id: @ticket_id @@ -898,7 +924,9 @@ class App.TicketZoom extends App.Controller @autosaveStart() return - if articleParams && articleParams.body + # New article body required. + # But WhatsApp messages with some attachments go without adjacent text. + if articleParams && (articleParams.body || @articleNew?.checkBodyAllowEmpty()) article = new App.TicketArticle article.load(articleParams) errors = article.validate() diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/alert.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/alert.coffee new file mode 100644 index 000000000000..9f0a01528d19 --- /dev/null +++ b/app/assets/javascripts/app/controllers/ticket_zoom/alert.coffee @@ -0,0 +1,19 @@ +class App.TicketZoomAlert extends App.ControllerObserver + model: 'Ticket' + observe: + state_id: true + preferences: true + globalRerender: false + + render: (ticket) => + alert = new App.TicketZoomChannel(ticket).channelAlert() + + if not alert + @html '' + @el.addClass('hide') + return + + element = App.view('ticket_zoom/alert')(alert: alert) + + @html element + @el.removeClass('hide') diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/article_action/whatsapp_reply.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/article_action/whatsapp_reply.coffee index 56afbb6ddaef..dbe650ef3caa 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/article_action/whatsapp_reply.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/article_action/whatsapp_reply.coffee @@ -3,6 +3,7 @@ class WhatsappReply return actions if !ticket.editable() return actions if ticket.currentView() is 'customer' return actions if article.type.name isnt 'whatsapp message' + return actions if !@canUseWhatsapp(ticket) actions.push { name: __('reply') @@ -40,6 +41,8 @@ class WhatsappReply return articleTypes if !ticket || !ticket.create_article_type_id + return articleTypes if !@canUseWhatsapp(ticket) + articleTypeCreate = App.TicketArticleType.find(ticket.create_article_type_id).name return articleTypes if articleTypeCreate isnt 'whatsapp message' @@ -49,9 +52,28 @@ class WhatsappReply icon: 'whatsapp' attributes: [] internal: false, - features: ['body:limit', 'attachment'] + features: ['body:limit', 'attachment', 'attachments:limit', 'attachments:size', 'body:ensureNoCaption', 'body:allowNoCaption'] maxTextLength: 4096 warningTextLength: -1 + attachmentsLimit: 1 + attachmentsSize: [ + { size: 16 * 1024 * 1024, label: __('Audio file'), content_types: ['audio/aac', 'audio/mp4', 'audio/mpeg', 'audio/amr', 'audio/ogg'] }, + { size: 5 * 1024 * 1024, label: __('Image file'), content_types: ['image/jpeg', 'image/png'] }, + { size: 16 * 1024 * 1024, label: __('Video file'), content_types: ['video/mp4', 'video/3gp'] }, + { size: 500 * 1024, label: __('Sticker file'), content_types: ['image/webp'] }, + { size: 100 * 1024 * 1024, label: __('Document file'), content_types: ['text/plain', 'application/pdf', 'application/vnd.ms-powerpoint', 'application/msword', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'] }, + ], + bodyEnsureNoCaption: (attachmentsTypes) -> + base = __('%s is sent without text caption') + + if _.intersection(attachmentsTypes, ['audio/aac', 'audio/mp4', 'audio/mpeg', 'audio/amr', 'audio/ogg']).length + App.i18n.translateContent base, App.i18n.translateContent(__('Audio file')) + else if _.intersection(attachmentsTypes, ['image/webp']).length + App.i18n.translateContent base, App.i18n.translateContent(__('Sticker file')) + else + false + bodyAllowNoCaption: (attachments) -> + attachments.length > 0 } articleTypes @@ -64,4 +86,9 @@ class WhatsappReply params + @canUseWhatsapp: (ticket) -> + alert = new App.TicketZoomChannel(ticket).channelAlert() + + alert?.type and alert.type != 'danger' + App.Config.set('300-WhatsappReply', WhatsappReply, 'TicketZoomArticleAction') 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 0be849000731..cb929b2f6419 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/article_new.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/article_new.coffee @@ -14,6 +14,7 @@ class App.TicketZoomArticleNew extends App.Controller '.js-percentage': 'progressText' '.js-cancel': 'cancelContainer' '.textBubble': 'textBubble' + '.textBubble-footer': 'textBubbleFooter' '.editControls-item': 'editControlItem' '.js-letterCount': 'letterCount' '.js-signature': 'signature' @@ -220,6 +221,7 @@ class App.TicketZoomArticleNew extends App.Controller dropContainer: @$('.article-add') cancelContainer: @cancelContainer inputField: @$('.article-attachment input') + canUploadFiles: @canUploadFiles onFileStartCallback: => @richTextUploadStartCallback?() @@ -269,7 +271,10 @@ class App.TicketZoomArticleNew extends App.Controller params: => params = @formParam( @$('.article-add') ) - if params.body + needsNoCaption = @checkBodyEnsureNoCaption() + allowsNoCaption = @checkBodyAllowNoCaption() + + if params.body || needsNoCaption || allowsNoCaption params.from = @Session.get().displayName() params.ticket_id = @ticket_id params.form_id = @form_id @@ -313,14 +318,22 @@ class App.TicketZoomArticleNew extends App.Controller params.preferences ||= {} params.preferences.security = @paramsSecurity() + if needsNoCaption + params.body = '' + else if allowsNoCaption + params.body ||= '' + params validate: => params = @params() + return false if !@validateBodyLimit(params.body) + return false if !@validateAttachmentsLimit() + return false if !@validateAttachmentsSize() + # check if attachment exists but no body - attachmentCount = @$('.article-add .textBubble .attachments .attachment').length - if !params.body && attachmentCount > 0 + if !@validateBodyPresence(params.body) new App.ControllerModal( head: __('Text missing') buttonCancel: __('Cancel') @@ -333,6 +346,7 @@ class App.TicketZoomArticleNew extends App.Controller ) return false + attachmentCount = @$('.article-add .textBubble .attachments .attachment').length # check attachment if params.body && attachmentCount < 1 matchingWord = App.Utils.checkAttachmentReference(params.body) @@ -347,6 +361,24 @@ class App.TicketZoomArticleNew extends App.Controller true + validateBodyPresence: (body) => + body || @checkBodyAllowEmpty() || @attachments.length == 0 + + validateBodyLimit: (body) => + return true if !@maxTextLength + + App.Utils.textLengthWithUrl(body) <= @maxTextLength + + validateAttachmentsLimit: => + return true if !@attachmentsLimit + + @attachments.length <= @attachmentsLimit + + validateAttachmentsSize: => + return true if !@attachmentsSize + + !@errorExistingAttachmentsSize() + changeType: (e) -> $(e.target).addClass('active').siblings('.active').removeClass('active') @@ -432,8 +464,13 @@ class App.TicketZoomArticleNew extends App.Controller @setArticleInternal(false) # show/hide attributes/features - @maxTextLength = undefined - @warningTextLength = undefined + @maxTextLength = undefined + @warningTextLength = undefined + @attachmentsLimit = undefined + @attachmentsSize = undefined + @bodyEnsureNoCaption = undefined + @bodyAllowNoCaption = undefined + for articleType in @articleTypes if articleType.name is type @$('.form-group').addClass('hide') @@ -441,26 +478,35 @@ class App.TicketZoomArticleNew extends App.Controller @$("[name=#{name}]").closest('.form-group').removeClass('hide') @$('.article-attachment, .attachments, .js-textSizeLimit').addClass('hide') for name in articleType.features - if name is 'attachment' - @$('.article-attachment, .attachments').removeClass('hide') - if name is 'body:initials' - @updateInitials() - if name is 'body:limit' - @maxTextLength = articleType.maxTextLength - @warningTextLength = articleType.warningTextLength - @delay(@updateLetterCount, 600) - @$('.js-textSizeLimit').removeClass('hide') - if name is 'security' - if @securityEnabled() - @securityOptionsShow() - - # add observer to change options - @$('.js-to, .js-cc').on('change', => + switch name + when 'attachment' + @$('.article-attachment, .attachments').removeClass('hide') + when 'body:initials' + @updateInitials() + when 'body:limit' + @maxTextLength = articleType.maxTextLength + @warningTextLength = articleType.warningTextLength + @delay(@updateLetterCount, 600) + @$('.js-textSizeLimit').removeClass('hide') + when 'security' + if @securityEnabled() + @securityOptionsShow() + + # add observer to change options + @$('.js-to, .js-cc').on('change', => + @updateSecurityOptions() + ) + + @updateSecurityType() @updateSecurityOptions() - ) - - @updateSecurityType() - @updateSecurityOptions() + when 'attachments:limit' + @attachmentsLimit = articleType.attachmentsLimit + when 'attachments:size' + @attachmentsSize = articleType.attachmentsSize + when 'body:ensureNoCaption' + @bodyEnsureNoCaption = articleType.bodyEnsureNoCaption + when 'body:allowNoCaption' + @bodyAllowNoCaption = articleType.bodyAllowNoCaption # convert remote src images to data uri @$('[data-name=body] img').each( (i,image) -> @@ -492,6 +538,8 @@ class App.TicketZoomArticleNew extends App.Controller if localConfig && localConfig.setArticleTypePost localConfig.setArticleTypePost(@type, @ticket, @, signaturePosition) + @evaluateAttachmentsList() + isScrolledToBottom: -> return @el.scrollParent().scrollTop() + @el.scrollParent().height() is @el.scrollParent().prop('scrollHeight') @@ -619,6 +667,14 @@ class App.TicketZoomArticleNew extends App.Controller duration: 300 easing: 'easeOutQuad' + @textBubbleFooter.velocity + properties: + opacity: 0 + options: + duration: 300 + easing: 'easeOutQuad' + complete: => @textBubbleFooter.css(opacity: 1) + @attachmentPlaceholder.velocity properties: translateX: 0 @@ -667,6 +723,7 @@ class App.TicketZoomArticleNew extends App.Controller renderAttachment: (file) => @attachmentsHolder.append(App.view('generic/attachment_item')(file)) + @evaluateAttachmentsList() bindAttachmentDelete: => @attachmentsHolder.on('click', '.js-delete', (e) => @@ -692,6 +749,7 @@ class App.TicketZoomArticleNew extends App.Controller element.empty() @richTextUploadDeleteCallback?(@attachments) + @evaluateAttachmentsList() ) importDraftAttachments: (options) => @@ -743,3 +801,127 @@ class App.TicketZoomArticleNew extends App.Controller @toggleButton(event) @updateSecurityOptions(true) + + canUploadFiles: (files) => + if @errorAttachmentsLimit(files) + new App.ErrorModal( + head: __('Cannot upload file') + contentInline: @errorAttachmentsLimitMessage() + container: @el.closest('.content') + ) + + return false + + if file = @errorNewAttachmentsSize(files) + new App.ErrorModal( + head: __('Cannot upload file') + contentInline: @errorAttachmentsSizeMessage(file) + container: @el.closest('.content') + ) + + return false + + true + + errorAttachmentsLimit: (newFiles = []) => + return false if !@attachmentsLimit + + futureFilesCount = @attachments.length + newFiles.length + + @attachmentsLimit < futureFilesCount + + errorAttachmentsLimitMessage: => + App.i18n.translateContent(__('Only %s attachment allowed.'), @attachmentsLimit) + + errorNewAttachmentsSize: (files) => + return false if !@attachmentsSize + + Array.from(files).find (file) => + config = @attachmentSizeByFile(file) + + return true if !config + + config && file.size > config.size + + errorExistingAttachmentsSize: => + return false if !@attachmentsSize + + @attachments.find (file) => + config = @attachmentSizeByFile(file) + + return true if !config + + fileSize = parseInt(file.size) + + config && fileSize > config.size + + errorAttachmentsSizeMessage: (file) => + sizeConfig = @attachmentSizeByFile(file) + + if !sizeConfig + return App.i18n.translateContent( + __('File format is not allowed: %s'), + @attachmentContentType(file) + ) + + + App.i18n.translateContent( + __('File is too big. %s has to be %s or smaller.'), + App.i18n.translateContent(sizeConfig?.label), + App.Utils.humanFileSize(sizeConfig?.size) + ) + + attachmentSizeByFile: (file) => + contentType = @attachmentContentType(file) + + @attachmentsSize.find (elem) -> elem.content_types.includes(contentType) + + attachmentContentType: (file) -> + file.type || file.contentType || file.preferences?['Content-Type'] || file.preferences?['Mime-Type'] + + checkBodyEnsureNoCaption: => + @bodyEnsureNoCaption?(@attachments.map (file) => @attachmentContentType(file)) + + checkBodyAllowNoCaption: => + @bodyAllowNoCaption?(@attachments) + + checkBodyAllowEmpty: => + !!@checkBodyEnsureNoCaption() || @checkBodyAllowNoCaption() + + evaluateAttachmentsList: => + @toggleBodyEnsureNoCaption @checkBodyEnsureNoCaption() + + @attachmentsHolder.find('.alert--danger').remove() + + @attachmentInputHolder + .find('input') + .attr('disabled', @attachmentsLimit == @attachments.length) + .attr('multiple', @attachmentsLimit != 1) + + if @errorAttachmentsLimit() + $('
') + .text(@errorAttachmentsLimitMessage()) + .prependTo(@attachmentsHolder) + + return + + tooBigFile = @errorExistingAttachmentsSize() + + return if !tooBigFile + + $('
') + .text(@errorAttachmentsSizeMessage(tooBigFile)) + .prependTo(@attachmentsHolder) + + toggleBodyEnsureNoCaption: (noCaption) => + @textarea + .attr('contenteditable', !noCaption) + .toggleClass('text-muted', noCaption) + + @attachmentsHolder.find('.alert--warning').remove() + + return if !noCaption + + $('
') + .text(noCaption) + .prependTo(@attachmentsHolder) 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 98e9aa8785f5..4ae3567007f0 100644 --- a/app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee +++ b/app/assets/javascripts/app/controllers/ticket_zoom/article_view.coffee @@ -91,6 +91,7 @@ class ArticleViewItem extends App.ControllerObserver 'click .attachments img': 'imageView' 'click .file-calendar .js-preview': 'calendarView' 'click .js-securityRetryProcess': 'retrySecurityProcess' + 'click .js-retryWhatsAppAttachmentDownload': 'retryWhatsAppAttachmentDownload' constructor: -> super @@ -347,6 +348,29 @@ class ArticleViewItem extends App.ControllerObserver msg: App.i18n.translateContent('The retried security process failed!') ) + retryWhatsAppAttachmentDownload: (e) -> + e.preventDefault() + e.stopPropagation() + + article_id = $(e.target).closest('.ticket-article-item').data('id') + + @ajax( + id: 'retryWhatsAppAttachmentDownload' + type: 'POST' + url: "#{@apiPath}/ticket_articles/#{article_id}/retry_whatsapp_attachment_download" + processData: true + success: (data, status, xhr) => + @notify + type: 'success' + msg: App.i18n.translateContent('Downloading attachments…') + + error: (data, status, xhr) => + details = data.responseJSON || {} + @notify + type: 'error' + msg: App.i18n.translateContent(details.error) + ) + stopPropagation: (e) -> e.stopPropagation() diff --git a/app/assets/javascripts/app/controllers/ticket_zoom/channel.coffee b/app/assets/javascripts/app/controllers/ticket_zoom/channel.coffee new file mode 100644 index 000000000000..90f6e3f59f4c --- /dev/null +++ b/app/assets/javascripts/app/controllers/ticket_zoom/channel.coffee @@ -0,0 +1,32 @@ +class App.TicketZoomChannel + + constructor: (ticket) -> + @ticket = ticket + + channelAlert: => + # TODO: Add a frontend module layer here for other channels, if the need arises. + @whatsappAlert() if _.has(@ticket.preferences, 'whatsapp') + + whatsappAlert: => + lastWhatsappTimestamp = @ticket.preferences.whatsapp.timestamp_incoming + + # In case the customer service window is not open yet, or the ticket is closed, hide the alert. + return null if not lastWhatsappTimestamp or /^(closed|merged|removed)$/.test(@ticket.state.name) + + # Determine the end of the customer service window and set the appropriate alert text and type. + timeWindowEnd = new Date(lastWhatsappTimestamp * 1000) + timeWindowEnd.setHours(timeWindowEnd.getHours() + 24) + + # If time window is already closed, return an error alert. + if timeWindowEnd <= new Date() + return { + text: __('The 24 hour customer service window is now closed, no further WhatsApp messages can be sent.') + type: 'danger' + } + + # Otherwise, return a warning alert with a "humanized" end time of the window. + return { + text: __('You have a 24 hour window to send WhatsApp messages in this conversation. The customer service window closes %s.') + textPlaceholder: App.PrettyDate.humanTime(timeWindowEnd) + type: 'warning' + } diff --git a/app/assets/javascripts/app/lib/app_post/html5_upload.coffee b/app/assets/javascripts/app/lib/app_post/html5_upload.coffee index bd29d4114655..691b8201f489 100644 --- a/app/assets/javascripts/app/lib/app_post/html5_upload.coffee +++ b/app/assets/javascripts/app/lib/app_post/html5_upload.coffee @@ -26,6 +26,7 @@ class App.Html5Upload extends App.Controller key: @key data: @data onFileAdded: @onFileAdded + canUploadFiles: @canUploadFiles ) @inputField.attr('data-initialized', true) diff --git a/app/assets/javascripts/app/lib/base/html5Upload.js b/app/assets/javascripts/app/lib/base/html5Upload.js index fd21dd04262b..ef36030eb767 100644 --- a/app/assets/javascripts/app/lib/base/html5Upload.js +++ b/app/assets/javascripts/app/lib/base/html5Upload.js @@ -12,6 +12,7 @@ var self = this; self.dropContainer = options.dropContainer; self.inputField = options.inputField; + self.canUploadFiles = options.canUploadFiles; self.cancelContainer = options.cancelContainer; self.uploadsQueue = []; self._xhrs = []; @@ -158,6 +159,10 @@ upload, i; + if(this.canUploadFiles && !this.canUploadFiles(files)) { + return false; + } + for (i = 0; i < len; i += 1) { file = files[i]; if (file.size === 0) { diff --git a/app/assets/javascripts/app/views/ticket_zoom.jst.eco b/app/assets/javascripts/app/views/ticket_zoom.jst.eco index 04f896e48fed..9d0223ce9389 100644 --- a/app/assets/javascripts/app/views/ticket_zoom.jst.eco +++ b/app/assets/javascripts/app/views/ticket_zoom.jst.eco @@ -10,6 +10,7 @@
+
diff --git a/app/assets/javascripts/app/views/ticket_zoom/alert.jst.eco b/app/assets/javascripts/app/views/ticket_zoom/alert.jst.eco new file mode 100644 index 000000000000..2018b6353d78 --- /dev/null +++ b/app/assets/javascripts/app/views/ticket_zoom/alert.jst.eco @@ -0,0 +1,3 @@ + diff --git a/app/assets/javascripts/app/views/ticket_zoom/article_view.jst.eco b/app/assets/javascripts/app/views/ticket_zoom/article_view.jst.eco index a12076e94d19..624e05f5650e 100644 --- a/app/assets/javascripts/app/views/ticket_zoom/article_view.jst.eco +++ b/app/assets/javascripts/app/views/ticket_zoom/article_view.jst.eco @@ -74,7 +74,7 @@
-<% if encryptionSuccess || signSuccess || encryptionWarning || signWarning: %> +<% if encryptionSuccess || signSuccess || encryptionWarning || signWarning || @article.preferences?.whatsapp?.media_error: %> <% end %>
diff --git a/app/assets/javascripts/app/views/whatsapp/account_cloud_api.jst.eco b/app/assets/javascripts/app/views/whatsapp/account_cloud_api.jst.eco index 4986fee10193..23ba2419e28b 100644 --- a/app/assets/javascripts/app/views/whatsapp/account_cloud_api.jst.eco +++ b/app/assets/javascripts/app/views/whatsapp/account_cloud_api.jst.eco @@ -10,7 +10,7 @@
- disabled<% else: %>required autocomplete="off"<% end %>> +
@@ -19,7 +19,7 @@
- +
@@ -28,7 +28,7 @@
- +
diff --git a/app/assets/javascripts/app/views/whatsapp/account_phone_number.jst.eco b/app/assets/javascripts/app/views/whatsapp/account_phone_number.jst.eco index 18ea54964f0b..1bab1c3378b4 100644 --- a/app/assets/javascripts/app/views/whatsapp/account_phone_number.jst.eco +++ b/app/assets/javascripts/app/views/whatsapp/account_phone_number.jst.eco @@ -22,20 +22,24 @@
- +
- +
+

+ <%- @T('This text will be sent as an immediate reply to new incoming WhatsApp messages.') %> +

-
- -
- +
+

+ <%- @T('Send out automatic reminders to customers asking them for reply if the 24-hour window is about to expire.') %> + <%- @T('To find out more about the 24-hour customer service window see %l.', 'https://admin-docs.zammad.org/en/latest/channels/whatsapp/index.html#limitations') %> +

diff --git a/app/assets/javascripts/app/views/whatsapp/index.jst.eco b/app/assets/javascripts/app/views/whatsapp/index.jst.eco index a9cdfcdefa85..08b2574365ff 100644 --- a/app/assets/javascripts/app/views/whatsapp/index.jst.eco +++ b/app/assets/javascripts/app/views/whatsapp/index.jst.eco @@ -45,5 +45,6 @@
<% end %> +
<% end %> diff --git a/app/assets/javascripts/app/views/widget/http_log_show.jst.eco b/app/assets/javascripts/app/views/widget/http_log_show.jst.eco index 55bcdb033315..018cc14be9f4 100644 --- a/app/assets/javascripts/app/views/widget/http_log_show.jst.eco +++ b/app/assets/javascripts/app/views/widget/http_log_show.jst.eco @@ -15,10 +15,10 @@ <%= @record.status %> <%- @T('Request') %> - <%- App.Utils.text2html(JSON.stringify(@record.request.content)) %> + <%- App.Utils.text2html(JSON.stringify(@record.request.content, null, 2)) %> <%- @T('Response') %> - <%- App.Utils.text2html(JSON.stringify(@record.response.content)) %> + <%- App.Utils.text2html(JSON.stringify(@record.response.content, null, 2)) %> <%- @T('Created at') %> <%- @datetime(@record.created_at) %> diff --git a/app/assets/stylesheets/zammad.scss b/app/assets/stylesheets/zammad.scss index 4fd6bfdf572d..b329b877f5bb 100644 --- a/app/assets/stylesheets/zammad.scss +++ b/app/assets/stylesheets/zammad.scss @@ -6891,6 +6891,23 @@ a.list-group-item.active > .badge, @include bidi-style(padding-left, 20px, padding-right, 0); } +.scrollPageAlert { + @extend .zIndex-5; + + position: absolute; + top: 0; + left: 0; + right: 0; + transform: translateY(0); + border-bottom: 1px solid var(--border); + + & > .alert { + margin-bottom: 0; + border-radius: 0; + text-align: center; + } +} + .ticket-article, .article-new { max-width: 1080px; @@ -7175,6 +7192,14 @@ a.list-group-item.active > .badge, border-radius: 0 0 4px 4px; } +.textBubble-footer:has(+ .attachments .attachment) { + margin-bottom: -10px; +} + +.textBubble-footer:not(.is-open .textBubble-footer) { + visibility: hidden; +} + .textBubble-control { display: flex; } @@ -7819,6 +7844,11 @@ a.list-group-item.active > .badge, pointer-events: auto; @extend .u-highlight; + + &:has(input[type='file'][disabled]) { + color: var(--text-muted-alt); + pointer-events: none; + } } .attachmentUpload { diff --git a/app/controllers/attachments_controller.rb b/app/controllers/attachments_controller.rb index d14220838c03..566261236f37 100644 --- a/app/controllers/attachments_controller.rb +++ b/app/controllers/attachments_controller.rb @@ -46,9 +46,10 @@ def create render json: { success: true, data: { - id: store.id, - filename: file.original_filename, - size: store.size, + id: store.id, + filename: file.original_filename, + size: store.size, + contentType: store.preferences['Content-Type'] } } end diff --git a/app/controllers/concerns/creates_ticket_articles.rb b/app/controllers/concerns/creates_ticket_articles.rb index 11537ad7f873..d92569c3042d 100644 --- a/app/controllers/concerns/creates_ticket_articles.rb +++ b/app/controllers/concerns/creates_ticket_articles.rb @@ -12,7 +12,7 @@ def article_create(ticket, params) subtype = params.delete(:subtype) # check min. params - raise Exceptions::UnprocessableEntity, __("Need at least an 'article body' field.") if params[:body].blank? + raise Exceptions::UnprocessableEntity, __("Need at least an 'article body' field.") if params[:body].nil? # fill default values if params[:type_id].blank? && params[:type].blank? diff --git a/app/controllers/ticket_articles_controller.rb b/app/controllers/ticket_articles_controller.rb index a5c9ea1f206e..8b9c29807489 100644 --- a/app/controllers/ticket_articles_controller.rb +++ b/app/controllers/ticket_articles_controller.rb @@ -266,6 +266,19 @@ def retry_security_process render json: result end + def retry_whatsapp_attachment_download + article = Ticket::Article.find(params[:id]) + authorize!(article, :update?) + + retry_media = Whatsapp::Retry::Media.new(article:) + retry_media.process + + render json: {}, status: :ok + rescue => e + logger.error e + render json: { error: __('The retried attachment download failed.') }, status: :unprocessable_entity + end + private def render_calendar_preview diff --git a/app/controllers/upload_caches_controller.rb b/app/controllers/upload_caches_controller.rb index 2d91b1e9b02f..7960a4f1e0af 100644 --- a/app/controllers/upload_caches_controller.rb +++ b/app/controllers/upload_caches_controller.rb @@ -25,9 +25,10 @@ def update render json: { success: true, data: { - id: store.id, # TODO: rename? - filename: file.original_filename, - size: store.size, + id: store.id, # TODO: rename? + filename: file.original_filename, + size: store.size, + contentType: store.preferences['Content-Type'] } } end diff --git a/app/frontend/apps/mobile/components/Form/fields/FieldFile/FieldFileInput.vue b/app/frontend/apps/mobile/components/Form/fields/FieldFile/FieldFileInput.vue index 02bf25e26e1d..e87e8bb5539a 100644 --- a/app/frontend/apps/mobile/components/Form/fields/FieldFile/FieldFileInput.vue +++ b/app/frontend/apps/mobile/components/Form/fields/FieldFile/FieldFileInput.vue @@ -1,17 +1,18 @@ + + diff --git a/app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/ArticlesList.vue b/app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/ArticlesList.vue index 496acb852c59..fc9c57c113dc 100644 --- a/app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/ArticlesList.vue +++ b/app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/ArticlesList.vue @@ -66,6 +66,7 @@ const markSeen = (id: string) => { :internal="row.article.internal" :content-type="row.article.contentType" :position="row.article.sender?.name !== 'Customer' ? 'left' : 'right'" + :media-error="row.article.mediaErrorState?.error" :security="row.article.securityState" :ticket-internal-id="ticket.internalId" :article-id="row.article.id" diff --git a/app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/TicketDetailViewTitle.vue b/app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/TicketDetailViewTitle.vue index c92c8173d5b4..bec9ce6f37cd 100644 --- a/app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/TicketDetailViewTitle.vue +++ b/app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/TicketDetailViewTitle.vue @@ -10,6 +10,7 @@ import CommonTicketStateIndicator from '#shared/components/CommonTicketStateIndi import type { TicketById } from '#shared/entities/ticket/types.ts' import { useLocaleStore } from '#shared/stores/locale.ts' import { useTicketView } from '#shared/entities/ticket/composables/useTicketView.ts' +import { useTicketChannel } from '#shared/entities/ticket/composables/useTicketChannel.ts' interface Props { ticket: TicketById @@ -17,9 +18,13 @@ interface Props { const props = defineProps() +const ticketReactive = toRef(props, 'ticket') + // TODO: Maybe in the future we can hide additional information (e.g. the priority) directly with the graphql query for // the ticket details (similar to the ticket list). -const { isTicketAgent } = useTicketView(toRef(props, 'ticket')) +const { isTicketAgent, isTicketEditable } = useTicketView(ticketReactive) + +const { hasChannelAlert, channelAlert } = useTicketChannel(ticketReactive) const locale = useLocaleStore() @@ -33,69 +38,75 @@ const customer = computed(() => { diff --git a/app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/__tests__/ArticleWhatsappMediaBadge.spec.ts b/app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/__tests__/ArticleWhatsappMediaBadge.spec.ts new file mode 100644 index 000000000000..cac89991390c --- /dev/null +++ b/app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/__tests__/ArticleWhatsappMediaBadge.spec.ts @@ -0,0 +1,56 @@ +// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ + +import { convertToGraphQLId } from '#shared/graphql/utils.ts' +import { mockTicketArticleRetryMediaDownloadMutation } from '#shared/entities/ticket-article/graphql/mutations/ticketArticleRetryMediaDownload.mocks.ts' +import { renderComponent } from '#tests/support/components/index.ts' +import ArticleWhatsappMediaBadge, { + type Props, +} from '../ArticleWhatsappMediaBadge.vue' + +const renderBadge = (propsData: Props) => { + return renderComponent(ArticleWhatsappMediaBadge, { + props: propsData, + }) +} + +describe('rendering media error badge for Whatsapp', () => { + it('renders media error badge', async () => { + const view = renderBadge({ + articleId: convertToGraphQLId('Ticket::Article', 1), + mediaError: true, + }) + + expect(view.getByIconName('update')).toBeInTheDocument() + expect( + view.getByRole('button', { name: 'Media Download Error' }), + ).toBeInTheDocument() + + await view.events.click(view.getByRole('button')) + + expect(view.getByRole('button', { name: 'Try again' })).toBeInTheDocument() + + mockTicketArticleRetryMediaDownloadMutation({ + ticketArticleRetryMediaDownload: { + success: true, + }, + }) + + await view.events.click(view.getByRole('button', { name: 'Try again' })) + + expect( + view.queryByRole('button', { name: 'Try again' }), + ).not.toBeInTheDocument() + }) + + it('renders no media error badge if download was fine', async () => { + const view = renderBadge({ + articleId: convertToGraphQLId('Ticket::Article', 1), + mediaError: false, + }) + + expect(view.queryByIconName('update')).not.toBeInTheDocument() + expect( + view.queryByRole('button', { name: 'Media Download Error' }), + ).not.toBeInTheDocument() + }) +}) diff --git a/app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/__tests__/TicketDetailViewTitle.spec.ts b/app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/__tests__/TicketDetailViewTitle.spec.ts index 420ea4dd354e..fbb148e08a5c 100644 --- a/app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/__tests__/TicketDetailViewTitle.spec.ts +++ b/app/frontend/apps/mobile/pages/ticket/components/TicketDetailView/__tests__/TicketDetailViewTitle.spec.ts @@ -1,5 +1,6 @@ // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ +import { EnumChannelArea } from '#shared/graphql/types.ts' import { renderComponent } from '#tests/support/components/index.ts' import { mockPermissions } from '#tests/support/mock-permissions.ts' import { defaultTicket } from '#mobile/pages/ticket/__tests__/mocks/detail-view.ts' @@ -63,4 +64,35 @@ describe('TicketDetailViewTitle.vue', () => { view.queryByText('escalation', { exact: false }), ).not.toBeInTheDocument() }) + + it('shows a channel alert, if applicable', () => { + mockPermissions(['ticket.agent']) + + const testDate = new Date() + + const { ticket: currentTicket } = defaultTicket( + {}, + { + whatsapp: { + timestamp_incoming: + testDate.setMinutes(testDate.getMinutes() - 30).valueOf() / 1000, + }, + }, + ) + + currentTicket.initialChannel = EnumChannelArea.WhatsAppBusiness + + const view = renderComponent(TicketDetailViewTitle, { + props: { + ticket: currentTicket, + }, + router: true, + }) + + const alert = view.getByText( + 'You have a 24 hour window to send WhatsApp messages in this conversation. The customer service window closes in 23 hours.', + ) + + expect(alert).toHaveClass('common-alert-warning') + }) }) diff --git a/app/frontend/apps/mobile/pages/ticket/composable/useTicketEditForm.ts b/app/frontend/apps/mobile/pages/ticket/composable/useTicketEditForm.ts index b9c6977b16b7..457ba697e9a7 100644 --- a/app/frontend/apps/mobile/pages/ticket/composable/useTicketEditForm.ts +++ b/app/frontend/apps/mobile/pages/ticket/composable/useTicketEditForm.ts @@ -2,12 +2,12 @@ import { FormHandlerExecution } from '#shared/components/Form/types.ts' import { createArticleTypes } from '#shared/entities/ticket-article/action/plugins/index.ts' -import type { AppSpecificTicketArticleType } from '#shared/entities/ticket-article/action/plugins/types.ts' -import type { TicketById } from '#shared/entities/ticket/types.ts' import { useTicketView } from '#shared/entities/ticket/composables/useTicketView.ts' +import { computed, shallowRef } from 'vue' import { EnumObjectManagerObjects } from '#shared/graphql/types.ts' +import type { AppSpecificTicketArticleType } from '#shared/entities/ticket-article/action/plugins/types.ts' +import type { TicketById } from '#shared/entities/ticket/types.ts' import type { ComputedRef, Ref } from 'vue' -import { computed, shallowRef } from 'vue' import type { ChangedField, ReactiveFormSchemData, @@ -26,7 +26,6 @@ export const useTicketEditForm = (ticket: Ref) => { const recipientContact = computed( () => currentArticleType.value?.options?.recipientContact, ) - const editorType = computed(() => currentArticleType.value?.contentType) const editorMeta = computed(() => { @@ -54,13 +53,13 @@ export const useTicketEditForm = (ticket: Ref) => { ( acc: Record< string, - ComputedRef< - undefined | string | Array<[rule: string, ...args: unknown[]]> - > + ComputedRef> >, field, ) => { - acc[field] = computed(() => currentArticleType.value?.validation?.[field]) + acc[field] = computed( + () => currentArticleType.value?.validation?.[field] || null, + ) return acc }, {}, @@ -198,7 +197,12 @@ export const useTicketEditForm = (ticket: Ref) => { name: 'attachments', validation: validations.attachments, props: { - multiple: true, + multiple: computed((): boolean => + typeof currentArticleType.value?.options?.multipleUploads === + 'boolean' + ? currentArticleType.value?.options?.multipleUploads + : true, + ), }, }, ], @@ -237,14 +241,10 @@ export const useTicketEditForm = (ticket: Ref) => { changedField?: ChangedField, ) => { if (!schemaData.fields.articleType) return false - if ( + return !( execution === FormHandlerExecution.FieldChange && (!changedField || changedField.name !== 'articleType') - ) { - return false - } - - return true + ) } const handleArticleType: FormHandlerFunction = ( @@ -273,7 +273,6 @@ export const useTicketEditForm = (ticket: Ref) => { const newType = ticketArticleTypes.value.find( (type) => type.value === changedField?.newValue, ) - if (!newType) return if (!formNode.context?._open) { diff --git a/app/frontend/apps/mobile/pages/ticket/graphql/fragments/ticketArticleAttributes.api.ts b/app/frontend/apps/mobile/pages/ticket/graphql/fragments/ticketArticleAttributes.api.ts index 596bdfafdc05..529ff9cfdea0 100644 --- a/app/frontend/apps/mobile/pages/ticket/graphql/fragments/ticketArticleAttributes.api.ts +++ b/app/frontend/apps/mobile/pages/ticket/graphql/fragments/ticketArticleAttributes.api.ts @@ -85,5 +85,8 @@ export const TicketArticleAttributesFragmentDoc = gql` signingSuccess type } + mediaErrorState { + error + } } `; \ No newline at end of file diff --git a/app/frontend/apps/mobile/pages/ticket/graphql/fragments/ticketArticleAttributes.graphql b/app/frontend/apps/mobile/pages/ticket/graphql/fragments/ticketArticleAttributes.graphql index 2e15c98f6cb9..1bcbc959d769 100644 --- a/app/frontend/apps/mobile/pages/ticket/graphql/fragments/ticketArticleAttributes.graphql +++ b/app/frontend/apps/mobile/pages/ticket/graphql/fragments/ticketArticleAttributes.graphql @@ -81,4 +81,7 @@ fragment ticketArticleAttributes on TicketArticle { signingSuccess type } + mediaErrorState { + error + } } diff --git a/app/frontend/apps/mobile/pages/ticket/graphql/fragments/ticketAttributes.api.ts b/app/frontend/apps/mobile/pages/ticket/graphql/fragments/ticketAttributes.api.ts index 9955ecfe3a47..6dcfea9e8d22 100644 --- a/app/frontend/apps/mobile/pages/ticket/graphql/fragments/ticketAttributes.api.ts +++ b/app/frontend/apps/mobile/pages/ticket/graphql/fragments/ticketAttributes.api.ts @@ -91,5 +91,6 @@ export const TicketAttributesFragmentDoc = gql` firstResponseEscalationAt closeEscalationAt updateEscalationAt + initialChannel } ${ObjectAttributeValuesFragmentDoc}`; \ No newline at end of file diff --git a/app/frontend/apps/mobile/pages/ticket/graphql/fragments/ticketAttributes.graphql b/app/frontend/apps/mobile/pages/ticket/graphql/fragments/ticketAttributes.graphql index 3f97f0be3ec8..0eb86d045fb8 100644 --- a/app/frontend/apps/mobile/pages/ticket/graphql/fragments/ticketAttributes.graphql +++ b/app/frontend/apps/mobile/pages/ticket/graphql/fragments/ticketAttributes.graphql @@ -86,4 +86,5 @@ fragment ticketAttributes on Ticket { firstResponseEscalationAt closeEscalationAt updateEscalationAt + initialChannel } diff --git a/app/frontend/apps/mobile/pages/ticket/views/TicketDetailView.vue b/app/frontend/apps/mobile/pages/ticket/views/TicketDetailView.vue index 614c5fc71b0c..cb46e3bbca30 100644 --- a/app/frontend/apps/mobile/pages/ticket/views/TicketDetailView.vue +++ b/app/frontend/apps/mobile/pages/ticket/views/TicketDetailView.vue @@ -122,7 +122,6 @@ const saveTicketForm = async (formData: FormSubmitData) => { if (updateFormData) { formData = updateFormData(formData) } - try { const result = await editTicket(formData) @@ -132,7 +131,7 @@ const saveTicketForm = async (formData: FormSubmitData) => { message: __('Ticket updated successfully.'), }) - // Reset article form after ticket update and reseted form. + // Reset article form after ticket update and reset form. return () => { newTicketArticlePresent.value = false closeArticleReplyDialog().then(() => { @@ -269,7 +268,7 @@ const showReplyButton = computed(() => { return canUpdateTicket.value }) -const showSrollDown = computed(() => { +const showScrollDown = computed(() => { if (articleReplyDialog.isOpened.value) return false return scrollDownState.value @@ -289,7 +288,7 @@ const showBottomBanner = computed(() => { return ( (canUpdateTicket.value && isDirty.value) || showReplyButton.value || - showSrollDown.value + showScrollDown.value ) }) @@ -331,7 +330,7 @@ const showBottomBanner = computed(() => { :new-article-present="newTicketArticlePresent" :can-reply="showReplyButton" :can-save="canUpdateTicket && isDirty" - :can-scroll-down="showSrollDown" + :can-scroll-down="showScrollDown" :hidden="!showBottomBanner" @reply="showArticleReplyDialog" @save="submitForm" diff --git a/app/frontend/shared/components/CommonAlert/CommonAlert.vue b/app/frontend/shared/components/CommonAlert/CommonAlert.vue index 124af7beceb7..48302283801a 100644 --- a/app/frontend/shared/components/CommonAlert/CommonAlert.vue +++ b/app/frontend/shared/components/CommonAlert/CommonAlert.vue @@ -41,7 +41,7 @@ const dismissed = ref(false)