diff --git a/Gemfile.lock b/Gemfile.lock index d9908f5e1..8315a5374 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -172,6 +172,8 @@ GEM bundler (>= 1.2.0, < 3) thor (~> 1.0) byebug (11.1.3) + childprocess (5.1.0) + logger (~> 1.5) climate_control (1.2.0) coderay (1.1.3) commonmarker (0.23.10) @@ -433,10 +435,12 @@ GEM json (>= 1.8) rexml language_server-protocol (3.17.0.5) - launchy (2.5.2) + launchy (3.1.1) addressable (~> 2.8) - letter_opener (1.8.1) - launchy (>= 2.2, < 3) + childprocess (~> 5.0) + logger (~> 1.6) + letter_opener (1.10.0) + launchy (>= 2.2, < 4) line-bot-api (1.28.0) lint_roller (1.1.0) liquid (5.4.0) @@ -563,7 +567,7 @@ GEM method_source (~> 1.0) pry-rails (0.3.9) pry (>= 0.10.4) - public_suffix (6.0.0) + public_suffix (6.0.2) puma (6.4.3) nio4r (~> 2.0) pundit (2.3.0) diff --git a/app/assets/javascripts/secretField.js b/app/assets/javascripts/secretField.js index 463109812..da2327eff 100644 --- a/app/assets/javascripts/secretField.js +++ b/app/assets/javascripts/secretField.js @@ -10,7 +10,8 @@ function toggleSecretField(e) { if (!textElement) return; if (textElement.dataset.secretMasked === 'false') { - textElement.textContent = '•'.repeat(10); + const maskedLength = secretField.dataset.secretText?.length || 10; + textElement.textContent = '•'.repeat(maskedLength); textElement.dataset.secretMasked = 'true'; toggler.querySelector('svg use').setAttribute('xlink:href', '#eye-show'); @@ -32,3 +33,13 @@ function copySecretField(e) { navigator.clipboard.writeText(secretField.dataset.secretText); } + +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('.cell-data__secret-field').forEach(field => { + const span = field.querySelector('[data-secret-masked]'); + if (span && span.dataset.secretMasked === 'true') { + const len = field.dataset.secretText?.length || 10; + span.textContent = '•'.repeat(len); + } + }); +}); diff --git a/app/assets/stylesheets/administrate/components/_cells.scss b/app/assets/stylesheets/administrate/components/_cells.scss index b5a079976..ae2d603cd 100644 --- a/app/assets/stylesheets/administrate/components/_cells.scss +++ b/app/assets/stylesheets/administrate/components/_cells.scss @@ -46,17 +46,25 @@ .cell-data__secret-field { align-items: center; + color: $hint-grey; display: flex; span { - flex: 1; + flex: 0 0 auto; } - button { - margin-left: 5px; + [data-secret-toggler], + [data-secret-copier] { + background: transparent; + border: 0; + color: inherit; + margin-left: 0.5rem; + padding: 0; svg { fill: currentColor; + height: 1.25rem; + width: 1.25rem; } } } diff --git a/app/builders/v2/reports/label_summary_builder.rb b/app/builders/v2/reports/label_summary_builder.rb new file mode 100644 index 000000000..caa5a04d8 --- /dev/null +++ b/app/builders/v2/reports/label_summary_builder.rb @@ -0,0 +1,103 @@ +class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder + attr_reader :account, :params + + # rubocop:disable Lint/MissingSuper + # the parent class has no initialize + def initialize(account:, params:) + @account = account + @params = params + + timezone_offset = (params[:timezone_offset] || 0).to_f + @timezone = ActiveSupport::TimeZone[timezone_offset]&.name + end + # rubocop:enable Lint/MissingSuper + + def build + labels = account.labels.to_a + return [] if labels.empty? + + report_data = collect_report_data + labels.map { |label| build_label_report(label, report_data) } + end + + private + + def collect_report_data + conversation_filter = build_conversation_filter + use_business_hours = use_business_hours? + + { + conversation_counts: fetch_conversation_counts(conversation_filter), + resolved_counts: fetch_resolved_counts(conversation_filter), + resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours), + first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours), + reply_metrics: fetch_metrics(conversation_filter, 'reply_time', use_business_hours) + } + end + + def build_label_report(label, report_data) + { + id: label.id, + name: label.title, + conversations_count: report_data[:conversation_counts][label.title] || 0, + avg_resolution_time: report_data[:resolution_metrics][label.title] || 0, + avg_first_response_time: report_data[:first_response_metrics][label.title] || 0, + avg_reply_time: report_data[:reply_metrics][label.title] || 0, + resolved_conversations_count: report_data[:resolved_counts][label.title] || 0 + } + end + + def use_business_hours? + ActiveModel::Type::Boolean.new.cast(params[:business_hours]) + end + + def build_conversation_filter + conversation_filter = { account_id: account.id } + conversation_filter[:created_at] = range if range.present? + + conversation_filter + end + + def fetch_conversation_counts(conversation_filter) + fetch_counts(conversation_filter) + end + + def fetch_resolved_counts(conversation_filter) + # since the base query is ActsAsTaggableOn, + # the status :resolved won't automatically be converted to integer status + fetch_counts(conversation_filter.merge(status: Conversation.statuses[:resolved])) + end + + def fetch_counts(conversation_filter) + ActsAsTaggableOn::Tagging + .joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id') + .joins('INNER JOIN tags ON taggings.tag_id = tags.id') + .where( + taggable_type: 'Conversation', + context: 'labels', + conversations: conversation_filter + ) + .select('tags.name, COUNT(taggings.*) AS count') + .group('tags.name') + .each_with_object({}) { |record, hash| hash[record.name] = record.count } + end + + def fetch_metrics(conversation_filter, event_name, use_business_hours) + ReportingEvent + .joins('INNER JOIN conversations ON reporting_events.conversation_id = conversations.id') + .joins('INNER JOIN taggings ON taggings.taggable_id = conversations.id') + .joins('INNER JOIN tags ON taggings.tag_id = tags.id') + .where( + conversations: conversation_filter, + name: event_name, + taggings: { taggable_type: 'Conversation', context: 'labels' } + ) + .group('tags.name') + .order('tags.name') + .select( + 'tags.name', + use_business_hours ? 'AVG(reporting_events.value_in_business_hours) as avg_value' : 'AVG(reporting_events.value) as avg_value' + ) + .each_with_object({}) { |record, hash| hash[record.name] = record.avg_value.to_f } + end +end diff --git a/app/controllers/api/v1/accounts/google/authorizations_controller.rb b/app/controllers/api/v1/accounts/google/authorizations_controller.rb index 1140a214b..87a7cfa3f 100644 --- a/app/controllers/api/v1/accounts/google/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/google/authorizations_controller.rb @@ -1,32 +1,23 @@ -class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController include GoogleConcern - before_action :check_authorization def create - email = params[:authorization][:email] redirect_url = google_client.auth_code.authorize_url( { redirect_uri: "#{base_url}/google/callback", - scope: 'email profile https://mail.google.com/', + scope: scope, response_type: 'code', prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it access_type: 'offline', # the default is 'online' + state: state, client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil) } ) if redirect_url - cache_key = "google::#{email.downcase}" - ::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes) render json: { success: true, url: redirect_url } else render json: { success: false }, status: :unprocessable_entity end end - - private - - def check_authorization - raise Pundit::NotAuthorizedError unless Current.account_user.administrator? - end end diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 61d16b2ca..e7b3b197b 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -81,11 +81,15 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def create_channel - return unless %w[web_widget api email line telegram whatsapp sms].include?(permitted_params[:channel][:type]) + return unless allowed_channel_types.include?(permitted_params[:channel][:type]) account_channels_method.create!(permitted_params(channel_type_from_params::EDITABLE_ATTRS)[:channel].except(:type)) end + def allowed_channel_types + %w[web_widget api email line telegram whatsapp sms] + end + def update_inbox_working_hours @inbox.update_working_hours(params.permit(working_hours: Inbox::OFFISABLE_ATTRS)[:working_hours]) if params[:working_hours] end diff --git a/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb index eace4411a..053c29731 100644 --- a/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/instagram/authorizations_controller.rb @@ -1,7 +1,6 @@ -class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController include InstagramConcern include Instagram::IntegrationHelper - before_action :check_authorization def create # https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/business-login#step-1--get-authorization @@ -21,10 +20,4 @@ class Api::V1::Accounts::Instagram::AuthorizationsController < Api::V1::Accounts render json: { success: false }, status: :unprocessable_entity end end - - private - - def check_authorization - raise Pundit::NotAuthorizedError unless Current.account_user.administrator? - end end diff --git a/app/controllers/api/v1/accounts/integrations/linear_controller.rb b/app/controllers/api/v1/accounts/integrations/linear_controller.rb index c66f06909..bfdfff058 100644 --- a/app/controllers/api/v1/accounts/integrations/linear_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/linear_controller.rb @@ -1,5 +1,5 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController - before_action :fetch_conversation, only: [:link_issue, :linked_issues] + before_action :fetch_conversation, only: [:create_issue, :link_issue, :unlink_issue, :linked_issues] before_action :fetch_hook, only: [:destroy] def destroy @@ -31,6 +31,12 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas if issue[:error] render json: { error: issue[:error] }, status: :unprocessable_entity else + Linear::ActivityMessageService.new( + conversation: @conversation, + action_type: :issue_created, + issue_data: { id: issue[:data][:identifier] }, + user: Current.user + ).perform render json: issue[:data], status: :ok end end @@ -42,17 +48,30 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas if issue[:error] render json: { error: issue[:error] }, status: :unprocessable_entity else + Linear::ActivityMessageService.new( + conversation: @conversation, + action_type: :issue_linked, + issue_data: { id: issue_id }, + user: Current.user + ).perform render json: issue[:data], status: :ok end end def unlink_issue link_id = permitted_params[:link_id] + issue_id = permitted_params[:issue_id] issue = linear_processor_service.unlink_issue(link_id) if issue[:error] render json: { error: issue[:error] }, status: :unprocessable_entity else + Linear::ActivityMessageService.new( + conversation: @conversation, + action_type: :issue_unlinked, + issue_data: { id: issue_id }, + user: Current.user + ).perform render json: issue[:data], status: :ok end end diff --git a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb index df563094a..a300b5f59 100644 --- a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb @@ -1,28 +1,19 @@ -class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::BaseController +class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController include MicrosoftConcern - before_action :check_authorization def create - email = params[:authorization][:email] redirect_url = microsoft_client.auth_code.authorize_url( { redirect_uri: "#{base_url}/microsoft/callback", - scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile', + scope: scope, + state: state, prompt: 'consent' } ) if redirect_url - cache_key = "microsoft::#{email.downcase}" - ::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes) render json: { success: true, url: redirect_url } else render json: { success: false }, status: :unprocessable_entity end end - - private - - def check_authorization - raise Pundit::NotAuthorizedError unless Current.account_user.administrator? - end end diff --git a/app/controllers/api/v1/accounts/oauth_authorization_controller.rb b/app/controllers/api/v1/accounts/oauth_authorization_controller.rb new file mode 100644 index 000000000..feb218b59 --- /dev/null +++ b/app/controllers/api/v1/accounts/oauth_authorization_controller.rb @@ -0,0 +1,23 @@ +class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseController + before_action :check_authorization + + protected + + def scope + '' + end + + def state + Current.account.to_sgid(expires_in: 15.minutes).to_s + end + + def base_url + ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + end + + private + + def check_authorization + raise Pundit::NotAuthorizedError unless Current.account_user.administrator? + end +end diff --git a/app/controllers/api/v2/accounts/summary_reports_controller.rb b/app/controllers/api/v2/accounts/summary_reports_controller.rb index 989952cfd..f31a53c7e 100644 --- a/app/controllers/api/v2/accounts/summary_reports_controller.rb +++ b/app/controllers/api/v2/accounts/summary_reports_controller.rb @@ -1,6 +1,6 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController before_action :check_authorization - before_action :prepare_builder_params, only: [:agent, :team, :inbox] + before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label] def agent render_report_with(V2::Reports::AgentSummaryBuilder) @@ -14,6 +14,10 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr render_report_with(V2::Reports::InboxSummaryBuilder) end + def label + render_report_with(V2::Reports::LabelSummaryBuilder) + end + private def check_authorization diff --git a/app/controllers/concerns/google_concern.rb b/app/controllers/concerns/google_concern.rb index 474b14aec..13de7ced3 100644 --- a/app/controllers/concerns/google_concern.rb +++ b/app/controllers/concerns/google_concern.rb @@ -14,7 +14,7 @@ module GoogleConcern private - def base_url - ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + def scope + 'email profile https://mail.google.com/' end end diff --git a/app/controllers/concerns/microsoft_concern.rb b/app/controllers/concerns/microsoft_concern.rb index 507b9f8a3..c3e0994bd 100644 --- a/app/controllers/concerns/microsoft_concern.rb +++ b/app/controllers/concerns/microsoft_concern.rb @@ -15,7 +15,7 @@ module MicrosoftConcern private - def base_url - ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + def scope + 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile email' end end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index a2cb466f1..6a4ce2461 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -15,7 +15,7 @@ class DashboardController < ActionController::Base private def ensure_html_format - head :not_acceptable unless request.format.html? + render json: { error: 'Please use API routes instead of dashboard routes for JSON requests' }, status: :not_acceptable if request.format.json? end def set_global_config diff --git a/app/controllers/oauth_callback_controller.rb b/app/controllers/oauth_callback_controller.rb index 9aa73956a..be0fa5008 100644 --- a/app/controllers/oauth_callback_controller.rb +++ b/app/controllers/oauth_callback_controller.rb @@ -6,7 +6,6 @@ class OauthCallbackController < ApplicationController ) handle_response - ::Redis::Alfred.delete(cache_key) rescue StandardError => e ChatwootExceptionTracker.new(e).capture_exception redirect_to '/' @@ -64,10 +63,6 @@ class OauthCallbackController < ApplicationController raise NotImplementedError end - def cache_key - "#{provider_name}::#{users_data['email'].downcase}" - end - def create_channel_with_inbox ActiveRecord::Base.transaction do channel_email = Channel::Email.create!(email: users_data['email'], account: account) @@ -86,12 +81,17 @@ class OauthCallbackController < ApplicationController decoded_token[0] end - def account_id - ::Redis::Alfred.get(cache_key) + def account_from_signed_id + raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank? + + account = GlobalID::Locator.locate_signed(params[:state]) + raise 'Invalid or expired state' if account.nil? + + account end def account - @account ||= Account.find(account_id) + @account ||= account_from_signed_id end # Fallback name, for when name field is missing from users_data diff --git a/app/controllers/public/api/v1/portals/articles_controller.rb b/app/controllers/public/api/v1/portals/articles_controller.rb index 32a147d34..02023559b 100644 --- a/app/controllers/public/api/v1/portals/articles_controller.rb +++ b/app/controllers/public/api/v1/portals/articles_controller.rb @@ -7,7 +7,11 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B def index @articles = @portal.articles.published.includes(:category, :author) + + @articles = @articles.where(locale: permitted_params[:locale]) if permitted_params[:locale].present? + @articles_count = @articles.count + search_articles order_by_sort_param limit_results diff --git a/app/helpers/api/v2/accounts/reports_helper.rb b/app/helpers/api/v2/accounts/reports_helper.rb index 22c51b6ef..23694d08d 100644 --- a/app/helpers/api/v2/accounts/reports_helper.rb +++ b/app/helpers/api/v2/accounts/reports_helper.rb @@ -36,9 +36,13 @@ module Api::V2::Accounts::ReportsHelper end def generate_labels_report - Current.account.labels.map do |label| - label_report = report_builder({ type: :label, id: label.id }).short_summary - [label.title] + generate_readable_report_metrics(label_report) + reports = V2::Reports::LabelSummaryBuilder.new( + account: Current.account, + params: build_params({}) + ).build + + reports.map do |report| + [report[:name]] + generate_readable_report_metrics(report) end end diff --git a/app/javascript/dashboard/api/auth.js b/app/javascript/dashboard/api/auth.js index c909660b2..e26d8b4b5 100644 --- a/app/javascript/dashboard/api/auth.js +++ b/app/javascript/dashboard/api/auth.js @@ -49,13 +49,7 @@ export default { } return false; }, - profileUpdate({ - password, - password_confirmation, - displayName, - avatar, - ...profileAttributes - }) { + profileUpdate({ displayName, avatar, ...profileAttributes }) { const formData = new FormData(); Object.keys(profileAttributes).forEach(key => { const hasValue = profileAttributes[key] === undefined; @@ -64,16 +58,22 @@ export default { } }); formData.append('profile[display_name]', displayName || ''); - if (password && password_confirmation) { - formData.append('profile[password]', password); - formData.append('profile[password_confirmation]', password_confirmation); - } if (avatar) { formData.append('profile[avatar]', avatar); } return axios.put(endPoints('profileUpdate').url, formData); }, + profilePasswordUpdate({ currentPassword, password, passwordConfirmation }) { + return axios.put(endPoints('profileUpdate').url, { + profile: { + current_password: currentPassword, + password, + password_confirmation: passwordConfirmation, + }, + }); + }, + updateUISettings({ uiSettings }) { return axios.put(endPoints('profileUpdate').url, { profile: { ui_settings: uiSettings }, diff --git a/app/javascript/dashboard/api/endPoints.js b/app/javascript/dashboard/api/endPoints.js index 5409aac60..ecd3f0170 100644 --- a/app/javascript/dashboard/api/endPoints.js +++ b/app/javascript/dashboard/api/endPoints.js @@ -51,6 +51,7 @@ const endPoints = { resendConfirmation: { url: '/api/v1/profile/resend_confirmation', }, + resetAccessToken: { url: '/api/v1/profile/reset_access_token', }, diff --git a/app/javascript/dashboard/api/integrations/linear.js b/app/javascript/dashboard/api/integrations/linear.js index 2ac0940aa..bb327b7e8 100644 --- a/app/javascript/dashboard/api/integrations/linear.js +++ b/app/javascript/dashboard/api/integrations/linear.js @@ -33,9 +33,11 @@ class LinearAPI extends ApiClient { ); } - unlinkIssue(linkId) { + unlinkIssue(linkId, issueIdentifier, conversationId) { return axios.post(`${this.url}/unlink_issue`, { link_id: linkId, + issue_id: issueIdentifier, + conversation_id: conversationId, }); } diff --git a/app/javascript/dashboard/api/specs/integrations/linear.spec.js b/app/javascript/dashboard/api/specs/integrations/linear.spec.js index e4bf679a6..3f33e3ed9 100644 --- a/app/javascript/dashboard/api/specs/integrations/linear.spec.js +++ b/app/javascript/dashboard/api/specs/integrations/linear.spec.js @@ -91,6 +91,19 @@ describe('#linearAPI', () => { issueData ); }); + + it('creates a valid request with conversation_id', () => { + const issueData = { + title: 'New Issue', + description: 'Issue description', + conversation_id: 123, + }; + LinearAPIClient.createIssue(issueData); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/create_issue', + issueData + ); + }); }); describe('link_issue', () => { @@ -120,6 +133,18 @@ describe('#linearAPI', () => { } ); }); + + it('creates a valid request with title', () => { + LinearAPIClient.link_issue(1, 'ENG-123', 'Sample Issue'); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/link_issue', + { + issue_id: 'ENG-123', + conversation_id: 1, + title: 'Sample Issue', + } + ); + }); }); describe('getLinkedIssue', () => { @@ -164,12 +189,26 @@ describe('#linearAPI', () => { window.axios = originalAxios; }); - it('creates a valid request', () => { - LinearAPIClient.unlinkIssue(1); + it('creates a valid request with link_id only', () => { + LinearAPIClient.unlinkIssue('link123'); expect(axiosMock.post).toHaveBeenCalledWith( '/api/v1/integrations/linear/unlink_issue', { - link_id: 1, + link_id: 'link123', + issue_id: undefined, + conversation_id: undefined, + } + ); + }); + + it('creates a valid request with all parameters', () => { + LinearAPIClient.unlinkIssue('link123', 'ENG-456', 789); + expect(axiosMock.post).toHaveBeenCalledWith( + '/api/v1/integrations/linear/unlink_issue', + { + link_id: 'link123', + issue_id: 'ENG-456', + conversation_id: 789, } ); }); diff --git a/app/javascript/dashboard/api/summaryReports.js b/app/javascript/dashboard/api/summaryReports.js index f772ef86f..fad26bf6f 100644 --- a/app/javascript/dashboard/api/summaryReports.js +++ b/app/javascript/dashboard/api/summaryReports.js @@ -35,6 +35,16 @@ class SummaryReportsAPI extends ApiClient { }, }); } + + getLabelReports({ since, until, businessHours } = {}) { + return axios.get(`${this.url}/label`, { + params: { + since, + until, + business_hours: businessHours, + }, + }); + } } export default new SummaryReportsAPI(); diff --git a/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss b/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss index ffcfca545..1280eb069 100644 --- a/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss +++ b/app/javascript/dashboard/assets/scss/plugins/_multiselect.scss @@ -47,7 +47,7 @@ @apply max-w-full; .multiselect__option { - @apply text-sm font-normal; + @apply text-sm font-normal flex justify-between items-center; span { @apply inline-block overflow-hidden text-ellipsis whitespace-nowrap w-fit; @@ -58,7 +58,7 @@ } &::after { - @apply bottom-0 flex items-center justify-center text-center; + @apply bottom-0 flex items-center justify-center text-center relative px-1 leading-tight; } &.multiselect__option--highlight { @@ -74,7 +74,7 @@ } &.multiselect__option--highlight::after { - @apply bg-transparent; + @apply bg-transparent text-n-slate-12; } &.multiselect__option--selected { diff --git a/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.vue b/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.vue index f00354105..7879411c8 100644 --- a/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.vue +++ b/app/javascript/dashboard/components-next/captain/assistant/ResponseCard.vue @@ -123,7 +123,7 @@ const handleDocumentableClick = () => { @mouseenter="emit('hover', true)" @mouseleave="emit('hover', false)" > -
+
diff --git a/app/javascript/dashboard/components-next/copilot/CopilotLauncher.vue b/app/javascript/dashboard/components-next/copilot/CopilotLauncher.vue index c0766f382..e21c550c0 100644 --- a/app/javascript/dashboard/components-next/copilot/CopilotLauncher.vue +++ b/app/javascript/dashboard/components-next/copilot/CopilotLauncher.vue @@ -19,6 +19,7 @@ const isConversationRoute = computed(() => { 'conversation_through_mentions', 'conversation_through_unattended', 'conversation_through_participating', + 'inbox_view_conversation', ]; return CONVERSATION_ROUTES.includes(route.name); }); diff --git a/app/javascript/dashboard/components-next/icon/provider.js b/app/javascript/dashboard/components-next/icon/provider.js index 9c0a24925..36dd6216e 100644 --- a/app/javascript/dashboard/components-next/icon/provider.js +++ b/app/javascript/dashboard/components-next/icon/provider.js @@ -13,6 +13,7 @@ export function useChannelIcon(inbox) { 'Channel::WebWidget': 'i-ri-global-fill', 'Channel::Whatsapp': 'i-ri-whatsapp-fill', 'Channel::Instagram': 'i-ri-instagram-fill', + 'Channel::Voice': 'i-ri-phone-fill', }; const providerIconMap = { diff --git a/app/javascript/dashboard/components-next/icon/specs/provider.spec.js b/app/javascript/dashboard/components-next/icon/specs/provider.spec.js index df30d7138..5860e30ea 100644 --- a/app/javascript/dashboard/components-next/icon/specs/provider.spec.js +++ b/app/javascript/dashboard/components-next/icon/specs/provider.spec.js @@ -19,6 +19,12 @@ describe('useChannelIcon', () => { expect(icon).toBe('i-ri-whatsapp-fill'); }); + it('returns correct icon for Voice channel', () => { + const inbox = { channel_type: 'Channel::Voice' }; + const { value: icon } = useChannelIcon(inbox); + expect(icon).toBe('i-ri-phone-fill'); + }); + describe('Email channel', () => { it('returns mail icon for generic email channel', () => { const inbox = { channel_type: 'Channel::Email' }; diff --git a/app/javascript/dashboard/components-next/input/Input.vue b/app/javascript/dashboard/components-next/input/Input.vue index ea6eb0417..ed6d7a20b 100644 --- a/app/javascript/dashboard/components-next/input/Input.vue +++ b/app/javascript/dashboard/components-next/input/Input.vue @@ -1,51 +1,21 @@ diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue index 4f9751c4e..933e117a9 100644 --- a/app/javascript/dashboard/components/widgets/ChannelItem.vue +++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue @@ -41,6 +41,10 @@ export default { ); } + if (key === 'voice') { + return this.enabledFeatures.channel_voice; + } + return [ 'website', 'twilio', @@ -50,6 +54,7 @@ export default { 'telegram', 'line', 'instagram', + 'voice', ].includes(key); }, }, diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue index ec05b0eb4..7a3489265 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue @@ -11,8 +11,6 @@ import SLACardLabel from './components/SLACardLabel.vue'; import wootConstants from 'dashboard/constants/globals'; import { conversationListPageURL } from 'dashboard/helper/URLHelper'; import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers'; -import { FEATURE_FLAGS } from 'dashboard/featureFlags'; -import Linear from './linear/index.vue'; import { useInbox } from 'dashboard/composables/useInbox'; import { useI18n } from 'vue-i18n'; @@ -36,12 +34,6 @@ const { isAWebWidgetInbox } = useInbox(); const currentChat = computed(() => store.getters.getSelectedChat); const accountId = computed(() => store.getters.getCurrentAccountId); -const isFeatureEnabledonAccount = computed( - () => store.getters['accounts/isFeatureEnabledonAccount'] -); -const appIntegrations = computed( - () => store.getters['integrations/getAppIntegrations'] -); const chatMetadata = computed(() => props.chat.meta); @@ -92,16 +84,6 @@ const hasMultipleInboxes = computed( ); const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id); - -const isLinearIntegrationEnabled = computed(() => - appIntegrations.value.find( - integration => integration.id === 'linear' && !!integration.hooks.length - ) -); - -const isLinearFeatureEnabled = computed(() => - isFeatureEnabledonAccount.value(accountId.value, FEATURE_FLAGS.LINEAR) -);