Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a813a67e6 | ||
|
|
a08d44b82d | ||
|
|
5dc888c573 | ||
|
|
3817fad3fe | ||
|
|
159c810117 | ||
|
|
21457368d7 | ||
|
|
ce400a36d7 | ||
|
|
d20de25da1 | ||
|
|
c31d693add | ||
|
|
31497d9c63 | ||
|
|
a35c3e4c06 | ||
|
|
344e8d5016 | ||
|
|
3e27e28848 | ||
|
|
38af08534c |
@@ -21,7 +21,6 @@ gem 'telephone_number'
|
||||
gem 'time_diff'
|
||||
gem 'tzinfo-data'
|
||||
gem 'valid_email2'
|
||||
gem 'octokit'
|
||||
# compress javascript config.assets.js_compressor
|
||||
gem 'uglifier'
|
||||
##-- used for single column multiple binary flags in notification settings/feature flagging --##
|
||||
|
||||
+2
-9
@@ -591,13 +591,10 @@ GEM
|
||||
rack (>= 1.2, < 4)
|
||||
snaky_hash (~> 2.0)
|
||||
version_gem (~> 1.1)
|
||||
octokit (10.0.0)
|
||||
faraday (>= 1, < 3)
|
||||
sawyer (~> 0.9)
|
||||
oj (3.16.10)
|
||||
bigdecimal (>= 3.0)
|
||||
ostruct (>= 0.2)
|
||||
omniauth (2.1.3)
|
||||
omniauth (2.1.4)
|
||||
hashie (>= 3.4.6)
|
||||
logger
|
||||
rack (>= 2.2.3)
|
||||
@@ -656,7 +653,7 @@ GEM
|
||||
rack (>= 2.0.0)
|
||||
rack-mini-profiler (3.2.0)
|
||||
rack (>= 1.2.0)
|
||||
rack-protection (4.1.1)
|
||||
rack-protection (4.2.1)
|
||||
base64 (>= 0.1.0)
|
||||
logger (>= 1.6.0)
|
||||
rack (>= 3.0.0, < 4)
|
||||
@@ -820,9 +817,6 @@ GEM
|
||||
sprockets (> 3.0)
|
||||
sprockets-rails
|
||||
tilt
|
||||
sawyer (0.9.2)
|
||||
addressable (>= 2.3.5)
|
||||
faraday (>= 0.17.3, < 3)
|
||||
scout_apm (5.3.3)
|
||||
parser
|
||||
scss_lint (0.60.0)
|
||||
@@ -1063,7 +1057,6 @@ DEPENDENCIES
|
||||
net-smtp (~> 0.3.4)
|
||||
newrelic-sidekiq-metrics (>= 1.6.2)
|
||||
newrelic_rpm
|
||||
octokit
|
||||
omniauth (>= 2.1.2)
|
||||
omniauth-google-oauth2 (>= 1.1.3)
|
||||
omniauth-oauth2
|
||||
|
||||
@@ -36,6 +36,10 @@
|
||||
"REDIS_OPENSSL_VERIFY_MODE":{
|
||||
"description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues",
|
||||
"value": "none"
|
||||
},
|
||||
"NODE_OPTIONS": {
|
||||
"description": "Increase V8 heap for Vite build to avoid OOM",
|
||||
"value": "--max-old-space-size=4096"
|
||||
}
|
||||
},
|
||||
"formation": {
|
||||
|
||||
@@ -23,7 +23,7 @@ class Messages::MessageBuilder
|
||||
process_emails
|
||||
# When the message has no quoted content, it will just be rendered as a regular message
|
||||
# The frontend is equipped to handle this case
|
||||
process_email_content if @account.feature_enabled?(:quoted_email_reply)
|
||||
process_email_content
|
||||
@message.save!
|
||||
@message
|
||||
end
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController
|
||||
before_action :type_matches?
|
||||
|
||||
def create
|
||||
if type_matches?
|
||||
::BulkActionsJob.perform_later(
|
||||
account: @current_account,
|
||||
user: current_user,
|
||||
params: permitted_params
|
||||
)
|
||||
case normalized_type
|
||||
when 'Conversation'
|
||||
enqueue_conversation_job
|
||||
head :ok
|
||||
when 'Contact'
|
||||
enqueue_contact_job
|
||||
head :ok
|
||||
else
|
||||
render json: { success: false }, status: :unprocessable_entity
|
||||
@@ -16,11 +14,34 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
|
||||
|
||||
private
|
||||
|
||||
def type_matches?
|
||||
['Conversation'].include?(params[:type])
|
||||
def normalized_type
|
||||
params[:type].to_s.camelize
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
def enqueue_conversation_job
|
||||
::BulkActionsJob.perform_later(
|
||||
account: @current_account,
|
||||
user: current_user,
|
||||
params: conversation_params
|
||||
)
|
||||
end
|
||||
|
||||
def enqueue_contact_job
|
||||
Contacts::BulkActionJob.perform_later(
|
||||
@current_account.id,
|
||||
current_user.id,
|
||||
contact_params
|
||||
)
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
|
||||
end
|
||||
|
||||
def contact_params
|
||||
params.require(:ids)
|
||||
permitted = params.permit(:type, ids: [], labels: [add: []])
|
||||
permitted[:ids] = permitted[:ids].map(&:to_i) if permitted[:ids].present?
|
||||
permitted
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
class Api::V1::Accounts::Integrations::GithubController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_hook
|
||||
before_action :ensure_hook_exists, except: [:destroy]
|
||||
|
||||
def destroy
|
||||
@hook.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
def repositories
|
||||
repositories = github_service.repositories
|
||||
filtered_repos = repositories.map do |repo|
|
||||
{
|
||||
full_name: repo[:full_name] || repo.full_name,
|
||||
private: repo[:private] || repo.private
|
||||
}
|
||||
end
|
||||
render json: filtered_repos
|
||||
end
|
||||
|
||||
def search_repositories
|
||||
repositories = github_service.search_repositories(params[:q])
|
||||
filtered_repos = repositories.map do |repo|
|
||||
{
|
||||
full_name: repo[:full_name] || repo.full_name,
|
||||
private: repo[:private] || repo.private
|
||||
}
|
||||
end
|
||||
render json: filtered_repos
|
||||
end
|
||||
|
||||
def assignees
|
||||
assignees = github_service.assignees(repo_full_name)
|
||||
filtered_assignees = assignees.map do |assignee|
|
||||
{
|
||||
login: assignee.login,
|
||||
avatar_url: assignee.avatar_url
|
||||
}
|
||||
end
|
||||
render json: filtered_assignees
|
||||
end
|
||||
|
||||
def labels
|
||||
labels = github_service.labels(repo_full_name)
|
||||
filtered_labels = labels.map do |label|
|
||||
{
|
||||
name: label.name,
|
||||
color: label.color
|
||||
}
|
||||
end
|
||||
render json: filtered_labels
|
||||
end
|
||||
|
||||
def search_issues
|
||||
issues = github_service.search_issues(repo_full_name, params[:q])
|
||||
filtered_issues = issues.map do |issue|
|
||||
{
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
html_url: issue.html_url
|
||||
}
|
||||
end
|
||||
render json: filtered_issues
|
||||
end
|
||||
|
||||
def create_issue
|
||||
issue_data = github_service.create_issue(
|
||||
repo_full_name,
|
||||
params[:title],
|
||||
params[:body],
|
||||
issue_options
|
||||
)
|
||||
|
||||
github_issue = create_linked_issue(issue_data)
|
||||
render json: issue_response(github_issue), status: :created
|
||||
end
|
||||
|
||||
def link_issue
|
||||
issue_data = github_service.issue(repo_full_name, params[:issue_number])
|
||||
github_issue = create_linked_issue(issue_data)
|
||||
render json: issue_response(github_issue), status: :created
|
||||
end
|
||||
|
||||
def linked_issues
|
||||
issues = GithubIssue.where(conversation_id: params[:conversation_id])
|
||||
|
||||
render json: issues.map do |issue|
|
||||
{
|
||||
id: issue.id,
|
||||
issue_number: issue.issue_number,
|
||||
title: issue.issue_title,
|
||||
html_url: issue.html_url,
|
||||
repo_full_name: issue.repo_full_name,
|
||||
linked_by: {
|
||||
id: issue.linked_by.id,
|
||||
name: issue.linked_by.name
|
||||
},
|
||||
created_at: issue.created_at
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def unlink_issue
|
||||
github_issue = GithubIssue.find(params[:id])
|
||||
github_issue.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_hook
|
||||
@hook = Integrations::Hook.where(account: Current.account).find_by(app_id: 'github')
|
||||
end
|
||||
|
||||
def ensure_hook_exists
|
||||
render json: { error: 'GitHub integration not configured' }, status: :unauthorized unless @hook
|
||||
end
|
||||
|
||||
def github_service
|
||||
@github_service ||= Github::GithubService.new(hook: @hook)
|
||||
end
|
||||
|
||||
def repo_full_name
|
||||
params[:repo_full_name] || "#{params[:owner]}/#{params[:repo]}"
|
||||
end
|
||||
|
||||
def issue_options
|
||||
options = {}
|
||||
options[:assignees] = params[:assignees] if params[:assignees].present?
|
||||
options[:labels] = params[:labels] if params[:labels].present?
|
||||
options
|
||||
end
|
||||
|
||||
def create_linked_issue(issue_data)
|
||||
GithubIssue.create!(
|
||||
conversation_id: params[:conversation_id],
|
||||
account: Current.account,
|
||||
repo_full_name: repo_full_name,
|
||||
issue_number: issue_data.number,
|
||||
issue_title: issue_data.title,
|
||||
html_url: issue_data.html_url,
|
||||
linked_by: Current.user
|
||||
)
|
||||
end
|
||||
|
||||
def issue_response(github_issue)
|
||||
{
|
||||
id: github_issue.id,
|
||||
issue_number: github_issue.issue_number,
|
||||
title: github_issue.issue_title,
|
||||
html_url: github_issue.html_url,
|
||||
repo_full_name: github_issue.repo_full_name
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -1,160 +0,0 @@
|
||||
class Github::CallbacksController < ApplicationController
|
||||
include Github::IntegrationHelper
|
||||
|
||||
def show
|
||||
# Validate account context early for all flows that require it
|
||||
account if params[:code].present?
|
||||
|
||||
if params[:installation_id].present? && params[:code].present?
|
||||
# Both installation and OAuth code present - handle both
|
||||
handle_installation_with_oauth
|
||||
elsif params[:installation_id].present?
|
||||
# Only installation_id present - redirect to OAuth
|
||||
handle_installation
|
||||
else
|
||||
# Only OAuth code present - handle authorization
|
||||
handle_authorization
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Github callback error: #{e.message}")
|
||||
redirect_to fallback_redirect_uri
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_installation_with_oauth
|
||||
# Handle both installation and OAuth in one go
|
||||
installation_id = params[:installation_id]
|
||||
|
||||
@response = oauth_client.auth_code.get_token(
|
||||
params[:code],
|
||||
redirect_uri: "#{base_url}/github/callback"
|
||||
)
|
||||
|
||||
handle_response(installation_id)
|
||||
end
|
||||
|
||||
def handle_installation
|
||||
if params[:setup_action] == 'install'
|
||||
installation_id = params[:installation_id]
|
||||
|
||||
redirect_to build_oauth_url(installation_id)
|
||||
else
|
||||
Rails.logger.error("Unknown setup_action: #{params[:setup_action]}")
|
||||
redirect_to github_integration_settings_url
|
||||
end
|
||||
end
|
||||
|
||||
def handle_authorization
|
||||
@response = oauth_client.auth_code.get_token(
|
||||
params[:code],
|
||||
redirect_uri: "#{base_url}/github/callback"
|
||||
)
|
||||
|
||||
handle_response
|
||||
end
|
||||
|
||||
def build_oauth_url(installation_id)
|
||||
GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
|
||||
|
||||
# Store installation_id in session for later use
|
||||
session[:github_installation_id] = installation_id
|
||||
|
||||
# For now, redirect to a page that will initiate OAuth with proper account context
|
||||
# This is a temporary solution until we have a proper account-agnostic setup
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/1/settings/integrations/github?setup_action=install&installation_id=#{installation_id}"
|
||||
end
|
||||
|
||||
def oauth_client
|
||||
app_id = GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
|
||||
app_secret = GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil)
|
||||
|
||||
OAuth2::Client.new(
|
||||
app_id,
|
||||
app_secret,
|
||||
{
|
||||
site: 'https://github.com',
|
||||
token_url: '/login/oauth/access_token',
|
||||
authorize_url: '/login/oauth/authorize'
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def handle_response(installation_id = nil)
|
||||
settings = build_hook_settings(installation_id)
|
||||
hook = create_integration_hook(settings)
|
||||
hook.save!
|
||||
|
||||
cleanup_session_data
|
||||
redirect_to github_redirect_uri
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Github callback error: #{e.message}")
|
||||
redirect_to fallback_redirect_uri
|
||||
end
|
||||
|
||||
def build_hook_settings(installation_id)
|
||||
settings = {
|
||||
token_type: parsed_body['token_type'],
|
||||
scope: parsed_body['scope']
|
||||
}
|
||||
|
||||
settings[:installation_id] = installation_id || session[:github_installation_id]
|
||||
settings.compact
|
||||
end
|
||||
|
||||
def create_integration_hook(settings)
|
||||
account.hooks.new(
|
||||
access_token: parsed_body['access_token'],
|
||||
status: 'enabled',
|
||||
app_id: 'github',
|
||||
settings: settings
|
||||
)
|
||||
end
|
||||
|
||||
def cleanup_session_data
|
||||
session.delete(:github_installation_id)
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= account_from_state
|
||||
end
|
||||
|
||||
def account_from_state
|
||||
raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank?
|
||||
|
||||
# Try signed GlobalID first (installation flow)
|
||||
account = GlobalID::Locator.locate_signed(params[:state])
|
||||
return account if account
|
||||
|
||||
# Fallback to JWT token (direct OAuth flow)
|
||||
account_id = verify_github_token(params[:state])
|
||||
return Account.find(account_id) if account_id
|
||||
|
||||
raise 'Invalid or expired state'
|
||||
rescue StandardError
|
||||
raise ActionController::BadRequest, 'Invalid account context'
|
||||
end
|
||||
|
||||
def github_redirect_uri
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{account.id}/settings/integrations/github"
|
||||
end
|
||||
|
||||
def github_integration_settings_url
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/1/settings/integrations/github"
|
||||
end
|
||||
|
||||
def fallback_redirect_uri
|
||||
github_redirect_uri
|
||||
rescue StandardError
|
||||
# Fallback if no account context available
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/app/settings/integrations"
|
||||
end
|
||||
|
||||
def parsed_body
|
||||
@parsed_body ||= @response.response.parsed
|
||||
end
|
||||
|
||||
def base_url
|
||||
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||
end
|
||||
end
|
||||
@@ -42,8 +42,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT],
|
||||
'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION],
|
||||
'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET],
|
||||
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI],
|
||||
'github' => %w[GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET]
|
||||
'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI]
|
||||
}
|
||||
|
||||
@allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS])
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
module Github::IntegrationHelper
|
||||
# Generates a signed JWT token for Github integration
|
||||
#
|
||||
# @param account_id [Integer] The account ID to encode in the token
|
||||
# @return [String, nil] The encoded JWT token or nil if client secret is missing
|
||||
def generate_github_token(account_id)
|
||||
return if github_client_secret.blank?
|
||||
|
||||
JWT.encode(github_token_payload(account_id), github_client_secret, 'HS256')
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Failed to generate Github token: #{e.message}")
|
||||
nil
|
||||
end
|
||||
|
||||
def github_token_payload(account_id)
|
||||
{
|
||||
sub: account_id,
|
||||
iat: Time.current.to_i
|
||||
}
|
||||
end
|
||||
|
||||
# Verifies and decodes a Github JWT token
|
||||
#
|
||||
# @param token [String] The JWT token to verify
|
||||
# @return [Integer, nil] The account ID from the token or nil if invalid
|
||||
def verify_github_token(token)
|
||||
return if token.blank? || github_client_secret.blank?
|
||||
|
||||
github_decode_token(token, github_client_secret)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def github_client_secret
|
||||
@github_client_secret ||= GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil)
|
||||
end
|
||||
|
||||
def github_decode_token(token, secret)
|
||||
JWT.decode(token, secret, true, {
|
||||
algorithm: 'HS256',
|
||||
verify_expiration: true
|
||||
}).first['sub']
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("Unexpected error verifying Github token: #{e.message}")
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -8,6 +8,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
|
||||
import Flag from 'dashboard/components-next/flag/Flag.vue';
|
||||
import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import countries from 'shared/constants/countries';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -20,9 +21,17 @@ const props = defineProps({
|
||||
availabilityStatus: { type: String, default: null },
|
||||
isExpanded: { type: Boolean, default: false },
|
||||
isUpdating: { type: Boolean, default: false },
|
||||
selectable: { type: Boolean, default: false },
|
||||
isSelected: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggle', 'updateContact', 'showContact']);
|
||||
const emit = defineEmits([
|
||||
'toggle',
|
||||
'updateContact',
|
||||
'showContact',
|
||||
'select',
|
||||
'avatarHover',
|
||||
]);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -88,111 +97,148 @@ const onClickExpand = () => {
|
||||
};
|
||||
|
||||
const onClickViewDetails = () => emit('showContact', props.id);
|
||||
|
||||
const toggleSelect = checked => {
|
||||
emit('select', checked);
|
||||
};
|
||||
|
||||
const handleAvatarHover = isHovered => {
|
||||
emit('avatarHover', isHovered);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout :key="id" layout="row">
|
||||
<div class="flex items-center justify-start flex-1 gap-4">
|
||||
<Avatar
|
||||
:name="name"
|
||||
:src="thumbnail"
|
||||
:size="48"
|
||||
:status="availabilityStatus"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
/>
|
||||
<div class="flex flex-col gap-0.5 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<span class="text-base font-medium truncate text-n-slate-12">
|
||||
{{ name }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span
|
||||
v-if="additionalAttributes?.companyName"
|
||||
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
|
||||
/>
|
||||
<span
|
||||
v-if="additionalAttributes?.companyName"
|
||||
class="text-sm truncate text-n-slate-11"
|
||||
>
|
||||
{{ additionalAttributes.companyName }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1">
|
||||
<div v-if="email" class="truncate max-w-72" :title="email">
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ email }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
|
||||
{{ phoneNumber }}
|
||||
</span>
|
||||
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<span
|
||||
v-if="countryDetails"
|
||||
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11"
|
||||
<div class="relative">
|
||||
<CardLayout
|
||||
:key="id"
|
||||
layout="row"
|
||||
:class="{
|
||||
'outline-n-weak !bg-n-slate-3 dark:!bg-n-solid-3': isSelected,
|
||||
}"
|
||||
>
|
||||
<div class="flex items-center justify-start flex-1 gap-4">
|
||||
<div
|
||||
class="relative"
|
||||
@mouseenter="handleAvatarHover(true)"
|
||||
@mouseleave="handleAvatarHover(false)"
|
||||
>
|
||||
<Avatar
|
||||
:name="name"
|
||||
:src="thumbnail"
|
||||
:size="48"
|
||||
:status="availabilityStatus"
|
||||
hide-offline-status
|
||||
rounded-full
|
||||
>
|
||||
<Flag :country="countryDetails.countryCode" class="size-3.5" />
|
||||
{{ formattedLocation }}
|
||||
</span>
|
||||
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
|
||||
variant="link"
|
||||
size="xs"
|
||||
@click="onClickViewDetails"
|
||||
/>
|
||||
<template v-if="selectable" #overlay="{ size }">
|
||||
<label
|
||||
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px] border border-n-weak"
|
||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="isSelected"
|
||||
@change="event => toggleSelect(event.target.checked)"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
</Avatar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
:class="{ 'rotate-180': isExpanded }"
|
||||
@click="onClickExpand"
|
||||
/>
|
||||
|
||||
<template #after>
|
||||
<div
|
||||
class="transition-all duration-500 ease-in-out grid overflow-hidden"
|
||||
:class="
|
||||
isExpanded
|
||||
? 'grid-rows-[1fr] opacity-100'
|
||||
: 'grid-rows-[0fr] opacity-0'
|
||||
"
|
||||
>
|
||||
<div class="overflow-hidden">
|
||||
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
|
||||
<ContactsForm
|
||||
ref="contactsFormRef"
|
||||
:contact-data="contactData"
|
||||
@update="handleFormUpdate"
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
:label="
|
||||
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
|
||||
"
|
||||
size="sm"
|
||||
:is-loading="isUpdating"
|
||||
:disabled="isUpdating || isFormInvalid"
|
||||
@click="handleUpdateContact"
|
||||
<div class="flex flex-col gap-0.5 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<span class="text-base font-medium truncate text-n-slate-12">
|
||||
{{ name }}
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span
|
||||
v-if="additionalAttributes?.companyName"
|
||||
class="i-ph-building-light size-4 text-n-slate-10 mb-0.5"
|
||||
/>
|
||||
<span
|
||||
v-if="additionalAttributes?.companyName"
|
||||
class="text-sm truncate text-n-slate-11"
|
||||
>
|
||||
{{ additionalAttributes.companyName }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-start gap-x-3 gap-y-1"
|
||||
>
|
||||
<div v-if="email" class="truncate max-w-72" :title="email">
|
||||
<span class="text-sm text-n-slate-11">
|
||||
{{ email }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="email" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<span v-if="phoneNumber" class="text-sm truncate text-n-slate-11">
|
||||
{{ phoneNumber }}
|
||||
</span>
|
||||
<div v-if="phoneNumber" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<span
|
||||
v-if="countryDetails"
|
||||
class="inline-flex items-center gap-2 text-sm truncate text-n-slate-11"
|
||||
>
|
||||
<Flag :country="countryDetails.countryCode" class="size-3.5" />
|
||||
{{ formattedLocation }}
|
||||
</span>
|
||||
<div v-if="countryDetails" class="w-px h-3 truncate bg-n-slate-6" />
|
||||
<Button
|
||||
:label="t('CONTACTS_LAYOUT.CARD.VIEW_DETAILS')"
|
||||
variant="link"
|
||||
size="xs"
|
||||
@click="onClickViewDetails"
|
||||
/>
|
||||
</div>
|
||||
<ContactDeleteSection
|
||||
:selected-contact="{
|
||||
id: props.id,
|
||||
name: props.name,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</CardLayout>
|
||||
|
||||
<Button
|
||||
icon="i-lucide-chevron-down"
|
||||
variant="ghost"
|
||||
color="slate"
|
||||
size="xs"
|
||||
:class="{ 'rotate-180': isExpanded }"
|
||||
@click="onClickExpand"
|
||||
/>
|
||||
|
||||
<template #after>
|
||||
<div
|
||||
class="transition-all duration-500 ease-in-out grid overflow-hidden"
|
||||
:class="
|
||||
isExpanded
|
||||
? 'grid-rows-[1fr] opacity-100'
|
||||
: 'grid-rows-[0fr] opacity-0'
|
||||
"
|
||||
>
|
||||
<div class="overflow-hidden">
|
||||
<div class="flex flex-col gap-6 p-6 border-t border-n-strong">
|
||||
<ContactsForm
|
||||
ref="contactsFormRef"
|
||||
:contact-data="contactData"
|
||||
@update="handleFormUpdate"
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
:label="
|
||||
t('CONTACTS_LAYOUT.CARD.EDIT_DETAILS_FORM.UPDATE_BUTTON')
|
||||
"
|
||||
size="sm"
|
||||
:is-loading="isUpdating"
|
||||
:disabled="isUpdating || isFormInvalid"
|
||||
@click="handleUpdateContact"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ContactDeleteSection
|
||||
:selected-contact="{
|
||||
id: props.id,
|
||||
name: props.name,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</CardLayout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -10,7 +10,15 @@ import {
|
||||
} from 'shared/helpers/CustomErrors';
|
||||
import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue';
|
||||
|
||||
defineProps({ contacts: { type: Array, required: true } });
|
||||
const props = defineProps({
|
||||
contacts: { type: Array, required: true },
|
||||
selectedContactIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['toggleContact']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
@@ -20,6 +28,9 @@ const route = useRoute();
|
||||
const uiFlags = useMapGetter('contacts/getUIFlags');
|
||||
const isUpdating = computed(() => uiFlags.value.isUpdating);
|
||||
const expandedCardId = ref(null);
|
||||
const hoveredAvatarId = ref(null);
|
||||
|
||||
const selectedIdsSet = computed(() => new Set(props.selectedContactIds || []));
|
||||
|
||||
const updateContact = async updatedData => {
|
||||
try {
|
||||
@@ -58,25 +69,43 @@ const onClickViewDetails = async id => {
|
||||
const toggleExpanded = id => {
|
||||
expandedCardId.value = expandedCardId.value === id ? null : id;
|
||||
};
|
||||
|
||||
const isSelected = id => selectedIdsSet.value.has(id);
|
||||
|
||||
const shouldShowSelection = id => {
|
||||
return hoveredAvatarId.value === id || isSelected(id);
|
||||
};
|
||||
|
||||
const handleSelect = (id, value) => {
|
||||
emit('toggleContact', { id, value });
|
||||
};
|
||||
|
||||
const handleAvatarHover = (id, isHovered) => {
|
||||
hoveredAvatarId.value = isHovered ? id : null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 px-6 pt-4 pb-6">
|
||||
<ContactsCard
|
||||
v-for="contact in contacts"
|
||||
:id="contact.id"
|
||||
:key="contact.id"
|
||||
:name="contact.name"
|
||||
:email="contact.email"
|
||||
:thumbnail="contact.thumbnail"
|
||||
:phone-number="contact.phoneNumber"
|
||||
:additional-attributes="contact.additionalAttributes"
|
||||
:availability-status="contact.availabilityStatus"
|
||||
:is-expanded="expandedCardId === contact.id"
|
||||
:is-updating="isUpdating"
|
||||
@toggle="toggleExpanded(contact.id)"
|
||||
@update-contact="updateContact"
|
||||
@show-contact="onClickViewDetails"
|
||||
/>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div v-for="contact in contacts" :key="contact.id" class="relative">
|
||||
<ContactsCard
|
||||
:id="contact.id"
|
||||
:name="contact.name"
|
||||
:email="contact.email"
|
||||
:thumbnail="contact.thumbnail"
|
||||
:phone-number="contact.phoneNumber"
|
||||
:additional-attributes="contact.additionalAttributes"
|
||||
:availability-status="contact.availabilityStatus"
|
||||
:is-expanded="expandedCardId === contact.id"
|
||||
:is-updating="isUpdating"
|
||||
:selectable="shouldShowSelection(contact.id)"
|
||||
:is-selected="isSelected(contact.id)"
|
||||
@toggle="toggleExpanded(contact.id)"
|
||||
@update-contact="updateContact"
|
||||
@show-contact="onClickViewDetails"
|
||||
@select="value => handleSelect(contact.id, value)"
|
||||
@avatar-hover="value => handleAvatarHover(contact.id, value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -14,6 +14,10 @@ defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
showBackdrop: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -25,14 +29,24 @@ defineProps({
|
||||
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]"
|
||||
>
|
||||
<div
|
||||
v-if="showBackdrop"
|
||||
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
|
||||
>
|
||||
<slot name="empty-state-item" />
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end w-full h-full pb-20 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent"
|
||||
class="flex flex-col items-center justify-end w-full h-full pb-20"
|
||||
:class="{
|
||||
'absolute inset-x-0 bottom-0 bg-gradient-to-t from-n-background from-25% dark:from-n-background to-transparent':
|
||||
showBackdrop,
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center gap-6">
|
||||
<div
|
||||
class="flex flex-col items-center justify-center gap-6"
|
||||
:class="{
|
||||
'mt-48': !showBackdrop,
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center gap-3">
|
||||
<h2
|
||||
class="text-3xl font-medium text-center text-n-slate-12 font-interDisplay"
|
||||
@@ -40,6 +54,7 @@ defineProps({
|
||||
{{ title }}
|
||||
</h2>
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="max-w-xl text-base text-center text-n-slate-11 font-interDisplay tracking-[0.3px]"
|
||||
>
|
||||
{{ subtitle }}
|
||||
|
||||
@@ -114,6 +114,7 @@ const handlePageChange = event => {
|
||||
<slot name="action" />
|
||||
</div>
|
||||
</div>
|
||||
<slot name="subHeader" />
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex-1 px-6 overflow-y-auto">
|
||||
|
||||
@@ -61,23 +61,26 @@ const bulkCheckboxState = computed({
|
||||
>
|
||||
<div
|
||||
v-if="hasSelected"
|
||||
class="flex items-center gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
|
||||
class="flex items-center justify-between gap-3 py-1 ltr:pl-3 rtl:pr-3 ltr:pr-4 rtl:pl-4 rounded-lg bg-n-solid-2 outline outline-1 outline-n-container shadow"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="flex items-center gap-1.5 min-w-0">
|
||||
<Checkbox
|
||||
v-model="bulkCheckboxState"
|
||||
:indeterminate="isIndeterminate"
|
||||
/>
|
||||
<span class="text-sm font-medium text-n-slate-12 tabular-nums">
|
||||
<span
|
||||
class="text-sm font-medium truncate text-n-slate-12 tabular-nums"
|
||||
>
|
||||
{{ selectAllLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-10 tabular-nums">
|
||||
<span class="text-sm text-n-slate-10 truncate tabular-nums">
|
||||
{{ selectedCountLabel }}
|
||||
</span>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<slot name="secondary-actions" />
|
||||
</div>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<div class="flex items-center gap-3">
|
||||
<slot name="actions" :selected-count="selectedCount">
|
||||
<Button
|
||||
|
||||
@@ -9,6 +9,7 @@ import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.v
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
id: {
|
||||
@@ -59,6 +60,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showActions: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
|
||||
@@ -159,73 +164,116 @@ const handleDocumentableClick = () => {
|
||||
<span class="text-n-slate-11 text-sm line-clamp-5">
|
||||
{{ answer }}
|
||||
</span>
|
||||
<div v-if="!compact" class="items-center justify-between hidden lg:flex">
|
||||
<div class="inline-flex items-center">
|
||||
<span
|
||||
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
|
||||
>
|
||||
<i class="i-woot-captain" />
|
||||
{{ assistant?.name || '' }}
|
||||
</span>
|
||||
<div
|
||||
v-if="documentable"
|
||||
class="shrink-0 text-sm text-n-slate-11 inline-flex line-clamp-1 gap-1 ml-3"
|
||||
>
|
||||
<div
|
||||
v-if="!compact"
|
||||
class="flex items-start justify-between flex-col-reverse md:flex-row gap-3"
|
||||
>
|
||||
<Policy v-if="showActions" :permissions="['administrator']">
|
||||
<div class="flex items-center gap-2 sm:gap-5 w-full">
|
||||
<Button
|
||||
v-if="status === 'pending'"
|
||||
:label="$t('CAPTAIN.RESPONSES.OPTIONS.APPROVE')"
|
||||
icon="i-lucide-circle-check-big"
|
||||
sm
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
@click="
|
||||
handleAssistantAction({ action: 'approve', value: 'approve' })
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.OPTIONS.EDIT_RESPONSE')"
|
||||
icon="i-lucide-pencil-line"
|
||||
sm
|
||||
slate
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
@click="
|
||||
handleAssistantAction({
|
||||
action: 'edit',
|
||||
value: 'edit',
|
||||
})
|
||||
"
|
||||
/>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.OPTIONS.DELETE_RESPONSE')"
|
||||
icon="i-lucide-trash"
|
||||
sm
|
||||
ruby
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
@click="
|
||||
handleAssistantAction({ action: 'delete', value: 'delete' })
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</Policy>
|
||||
<div
|
||||
class="flex items-center gap-3"
|
||||
:class="{ 'justify-between w-full': !showActions }"
|
||||
>
|
||||
<div class="inline-flex items-center gap-3 min-w-0">
|
||||
<span
|
||||
v-if="documentable.type === 'Captain::Document'"
|
||||
class="inline-flex items-center gap-1 truncate over"
|
||||
v-if="status === 'approved'"
|
||||
class="text-sm shrink-0 truncate text-n-slate-11 inline-flex items-center gap-1"
|
||||
>
|
||||
<i class="i-ph-files-light text-base" />
|
||||
<span class="max-w-96 truncate" :title="documentable.name">
|
||||
<Icon icon="i-woot-captain" class="size-3.5" />
|
||||
{{ assistant?.name || '' }}
|
||||
</span>
|
||||
<div
|
||||
v-if="documentable"
|
||||
class="text-sm text-n-slate-11 grid grid-cols-[auto_1fr] items-center gap-1 min-w-0"
|
||||
>
|
||||
<Icon
|
||||
v-if="documentable.type === 'Captain::Document'"
|
||||
icon="i-ph-files-light"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="documentable.type === 'User'"
|
||||
icon="i-ph-user-circle-plus"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="documentable.type === 'Conversation'"
|
||||
icon="i-ph-chat-circle-dots"
|
||||
class="size-3.5"
|
||||
/>
|
||||
<span
|
||||
v-if="documentable.type === 'Captain::Document'"
|
||||
class="truncate"
|
||||
:title="documentable.name"
|
||||
>
|
||||
{{ documentable.name }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="documentable.type === 'User'"
|
||||
class="inline-flex items-center gap-1"
|
||||
>
|
||||
<i class="i-ph-user-circle-plus text-base" />
|
||||
<span
|
||||
class="max-w-96 truncate"
|
||||
v-else-if="documentable.type === 'User'"
|
||||
class="truncate"
|
||||
:title="documentable.available_name"
|
||||
>
|
||||
{{ documentable.available_name }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="documentable.type === 'Conversation'"
|
||||
class="inline-flex items-center gap-1 group cursor-pointer"
|
||||
role="button"
|
||||
@click="handleDocumentableClick"
|
||||
>
|
||||
<i class="i-ph-chat-circle-dots text-base" />
|
||||
<span class="group-hover:underline">
|
||||
<span
|
||||
v-else-if="documentable.type === 'Conversation'"
|
||||
class="hover:underline truncate cursor-pointer"
|
||||
role="button"
|
||||
@click="handleDocumentableClick"
|
||||
>
|
||||
{{
|
||||
t(`CAPTAIN.RESPONSES.DOCUMENTABLE.CONVERSATION`, {
|
||||
id: documentable.display_id,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</span>
|
||||
<span v-else />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="status !== 'approved'"
|
||||
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
|
||||
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1"
|
||||
>
|
||||
<i
|
||||
class="i-ph-stack text-base"
|
||||
:title="t('CAPTAIN.RESPONSES.STATUS.TITLE')"
|
||||
/>
|
||||
{{ t(`CAPTAIN.RESPONSES.STATUS.${status.toUpperCase()}`) }}
|
||||
<Icon icon="i-ph-calendar-dot" class="size-3.5" />
|
||||
{{ timestamp }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="shrink-0 text-sm text-n-slate-11 line-clamp-1 inline-flex items-center gap-1 ml-3"
|
||||
>
|
||||
<i class="i-ph-calendar-dot" />
|
||||
{{ timestamp }}
|
||||
</div>
|
||||
</div>
|
||||
</CardLayout>
|
||||
</template>
|
||||
|
||||
+47
-9
@@ -6,16 +6,39 @@ import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCa
|
||||
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
|
||||
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'approved',
|
||||
validator: value => ['approved', 'pending'].includes(value),
|
||||
},
|
||||
hasActiveFilters: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click', 'clearFilters']);
|
||||
|
||||
const isApproved = computed(() => props.variant === 'approved');
|
||||
const isPending = computed(() => props.variant === 'pending');
|
||||
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
};
|
||||
|
||||
const onClearFilters = () => {
|
||||
emit('clearFilters');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FeatureSpotlight
|
||||
v-if="isApproved"
|
||||
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
||||
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/faqs-light.svg"
|
||||
@@ -25,11 +48,16 @@ const onClick = () => {
|
||||
class="mb-8"
|
||||
/>
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE')"
|
||||
:title="
|
||||
isPending
|
||||
? $t('CAPTAIN.RESPONSES.EMPTY_STATE.NO_PENDING_TITLE')
|
||||
: $t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')
|
||||
"
|
||||
:subtitle="isApproved ? $t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE') : ''"
|
||||
:action-perms="['administrator']"
|
||||
:show-backdrop="isApproved"
|
||||
>
|
||||
<template #empty-state-item>
|
||||
<template v-if="isApproved" #empty-state-item>
|
||||
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
|
||||
<ResponseCard
|
||||
v-for="(response, index) in responsesList.slice(0, 5)"
|
||||
@@ -45,11 +73,21 @@ const onClick = () => {
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
|
||||
icon="i-lucide-plus"
|
||||
@click="onClick"
|
||||
/>
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<Button
|
||||
v-if="isApproved"
|
||||
:label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
|
||||
icon="i-lucide-plus"
|
||||
@click="onClick"
|
||||
/>
|
||||
<Button
|
||||
v-else-if="isPending && hasActiveFilters"
|
||||
:label="$t('CAPTAIN.RESPONSES.EMPTY_STATE.CLEAR_SEARCH')"
|
||||
variant="link"
|
||||
size="sm"
|
||||
@click="onClearFilters"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="text-n-slate-12 max-w-80 flex flex-col gap-2.5">
|
||||
<div class="p-3 bg-n-alpha-2 rounded-xl">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button label="Call us" slate class="!text-n-blue-text w-full" />
|
||||
<Button
|
||||
label="Visit our website"
|
||||
slate
|
||||
class="!text-n-blue-text w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
>
|
||||
<div class="px-3 py-2.5">
|
||||
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
|
||||
<div class="pt-2.5 flex flex-col gap-2">
|
||||
<h6 class="font-semibold">{{ message.title }}</h6>
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="Call us to order" link class="hover:!no-underline" />
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="Visit our store" link class="hover:!no-underline" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
>
|
||||
<div class="p-3">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="See options" link class="hover:!no-underline" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 text-n-slate-12 rounded-xl flex flex-col gap-2.5 p-3 max-w-80"
|
||||
>
|
||||
<img :src="message.image_url" class="max-h-44 rounded-lg w-full" />
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup>
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="bg-n-alpha-2 divide-y divide-n-strong text-n-slate-12 rounded-xl max-w-80"
|
||||
>
|
||||
<div class="p-3">
|
||||
<span
|
||||
v-dompurify-html="message.content"
|
||||
class="prose prose-bubble font-medium text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button label="No, that will be all" link class="hover:!no-underline">
|
||||
<template #icon>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
class="stroke-n-blue-text"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
|
||||
stroke-width="1.333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="p-3 flex items-center justify-center">
|
||||
<Button
|
||||
label="I want to talk to an agents"
|
||||
link
|
||||
class="hover:!no-underline"
|
||||
>
|
||||
<template #icon>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
class="stroke-n-blue-text"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M.667 6.654 5.315.667v3.326c7.968 0 8.878 6.46 8.656 10.007l-.005-.027c-.334-1.79-.474-4.658-8.65-4.658v3.327z"
|
||||
stroke-width="1.333"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
message: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-n-alpha-2 text-n-slate-12 rounded-xl p-3 max-w-80">
|
||||
<span v-dompurify-html="message.content" class="prose prose-bubble" />
|
||||
</div>
|
||||
</template>
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import CallToAction from '../../bubbles/Template/CallToAction.vue';
|
||||
|
||||
const message = {
|
||||
content:
|
||||
'We have super cool products going live! Pre-order and customize products. Contact us for more details',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/CallToAction"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Call To Action">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<CallToAction :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import Card from '../../bubbles/Template/Card.vue';
|
||||
|
||||
const message = {
|
||||
title: 'Two in one cake (1 pound)',
|
||||
content: 'Customize your order for special occasions',
|
||||
image_url:
|
||||
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/Card"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Card">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<Card :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import ListPicker from '../../bubbles/Template/ListPicker.vue';
|
||||
|
||||
const message = {
|
||||
content: `Hey there! Thanks for reaching out to us. Could you let us know
|
||||
what you need to help us better assist you? `,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/ListPicker"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="ListPicker">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<ListPicker :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import Media from '../../bubbles/Template/Media.vue';
|
||||
|
||||
const message = {
|
||||
content:
|
||||
'Welcome to our Diwali sale! Get flat 50% off on select items. Hurry now!',
|
||||
image_url:
|
||||
'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=500&h=300&fit=crop',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/Media"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Image Media">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<Media :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<script setup>
|
||||
import QuickReply from '../../bubbles/Template/QuickReply.vue';
|
||||
|
||||
const message = {
|
||||
content: `Hey there! Thanks for reaching out to us. Could you let us know
|
||||
what you need to help us better assist you?`,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/QuickReply"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Quick Replies">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<QuickReply :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup>
|
||||
import Text from '../../bubbles/Template/Text.vue';
|
||||
|
||||
const message = {
|
||||
content: 'Hello John, how may we assist you?',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Story
|
||||
title="Components/Message Bubbles/Template/Text"
|
||||
:layout="{ type: 'grid', width: '600px' }"
|
||||
>
|
||||
<Variant title="Default Text">
|
||||
<div class="p-4 bg-n-background rounded-lg w-full min-w-4xl">
|
||||
<Text :message="message" />
|
||||
</div>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
+92
-159
@@ -1,100 +1,129 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
NextButton,
|
||||
},
|
||||
emits: ['update', 'close', 'assign'],
|
||||
data() {
|
||||
return {
|
||||
query: '',
|
||||
selectedLabels: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ labels: 'labels/getLabels' }),
|
||||
filteredLabels() {
|
||||
return this.labels.filter(label =>
|
||||
label.title.toLowerCase().includes(this.query.toLowerCase())
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isLabelSelected(label) {
|
||||
return this.selectedLabels.includes(label);
|
||||
},
|
||||
assignLabels(key) {
|
||||
this.$emit('update', key);
|
||||
},
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
},
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
const emit = defineEmits(['close', 'assign']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
|
||||
const query = ref('');
|
||||
const selectedLabels = ref([]);
|
||||
|
||||
const filteredLabels = computed(() => {
|
||||
if (!query.value) return labels.value;
|
||||
return labels.value.filter(label =>
|
||||
label.title.toLowerCase().includes(query.value.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
const hasLabels = computed(() => labels.value.length > 0);
|
||||
const hasFilteredLabels = computed(() => filteredLabels.value.length > 0);
|
||||
|
||||
const isLabelSelected = label => {
|
||||
return selectedLabels.value.includes(label);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const handleAssign = () => {
|
||||
if (selectedLabels.value.length > 0) {
|
||||
emit('assign', selectedLabels.value);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-clickaway="onClose" class="labels-container">
|
||||
<div
|
||||
v-on-click-outside="onClose"
|
||||
class="absolute ltr:right-2 rtl:left-2 top-12 origin-top-right z-20 w-60 bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md"
|
||||
role="dialog"
|
||||
aria-labelledby="label-dialog-title"
|
||||
>
|
||||
<div class="triangle">
|
||||
<svg height="12" viewBox="0 0 24 12" width="24">
|
||||
<path d="M20 12l-8-8-12 12" fill-rule="evenodd" stroke-width="1px" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex items-center justify-between header">
|
||||
<span>{{ $t('BULK_ACTION.LABELS.ASSIGN_LABELS') }}</span>
|
||||
<div class="flex items-center justify-between p-2.5">
|
||||
<span class="text-sm font-medium">{{
|
||||
t('BULK_ACTION.LABELS.ASSIGN_LABELS')
|
||||
}}</span>
|
||||
<NextButton ghost xs slate icon="i-lucide-x" @click="onClose" />
|
||||
</div>
|
||||
<div class="labels-list">
|
||||
<header class="labels-list__header">
|
||||
<div
|
||||
class="flex items-center justify-between h-8 gap-2 label-list-search"
|
||||
>
|
||||
<fluent-icon icon="search" class="search-icon" size="16" />
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="$t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
class="reset-base !outline-0 !text-sm label--search_input"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col max-h-60 min-h-0">
|
||||
<header class="py-2 px-2.5">
|
||||
<Input
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
icon-left="i-lucide-search"
|
||||
size="sm"
|
||||
class="w-full"
|
||||
:aria-label="t('BULK_ACTION.SEARCH_INPUT_PLACEHOLDER')"
|
||||
/>
|
||||
</header>
|
||||
<ul class="labels-list__body">
|
||||
<ul
|
||||
v-if="hasLabels"
|
||||
class="flex-1 overflow-y-auto m-0 list-none"
|
||||
role="listbox"
|
||||
:aria-label="t('BULK_ACTION.LABELS.ASSIGN_LABELS')"
|
||||
>
|
||||
<li v-if="!hasFilteredLabels" class="p-2 text-center">
|
||||
<span class="text-sm text-n-slate-11">{{
|
||||
t('BULK_ACTION.LABELS.NO_LABELS_FOUND')
|
||||
}}</span>
|
||||
</li>
|
||||
<li
|
||||
v-for="label in filteredLabels"
|
||||
:key="label.id"
|
||||
class="label__list-item"
|
||||
class="my-1 mx-0 py-0 px-2.5"
|
||||
role="option"
|
||||
:aria-selected="isLabelSelected(label.title)"
|
||||
>
|
||||
<label
|
||||
class="item"
|
||||
:class="{ 'label-selected': isLabelSelected(label.title) }"
|
||||
class="items-center rounded-md cursor-pointer flex py-1 px-2.5 hover:bg-n-slate-3 dark:hover:bg-n-solid-3 has-[:checked]:bg-n-slate-2"
|
||||
>
|
||||
<input
|
||||
v-model="selectedLabels"
|
||||
type="checkbox"
|
||||
:value="label.title"
|
||||
class="label-checkbox"
|
||||
class="my-0 ltr:mr-2.5 rtl:ml-2.5"
|
||||
:aria-label="label.title"
|
||||
/>
|
||||
<span
|
||||
class="overflow-hidden label-title whitespace-nowrap text-ellipsis"
|
||||
class="overflow-hidden flex-grow w-full text-sm whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ label.title }}
|
||||
</span>
|
||||
<span
|
||||
class="label-pill"
|
||||
class="rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak"
|
||||
:style="{ backgroundColor: label.color }"
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
<footer class="labels-list__footer">
|
||||
<div v-else class="p-2 text-center">
|
||||
<span class="text-sm text-n-slate-11">{{
|
||||
t('CONTACTS_BULK_ACTIONS.NO_LABELS_FOUND')
|
||||
}}</span>
|
||||
</div>
|
||||
<footer class="p-2">
|
||||
<NextButton
|
||||
sm
|
||||
type="submit"
|
||||
:label="$t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
|
||||
class="w-full"
|
||||
:label="t('BULK_ACTION.LABELS.ASSIGN_SELECTED_LABELS')"
|
||||
:disabled="!selectedLabels.length"
|
||||
@click="$emit('assign', selectedLabels)"
|
||||
@click="handleAssign"
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -102,107 +131,11 @@ export default {
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.labels-list {
|
||||
@apply flex flex-col max-h-[15rem] min-h-[auto];
|
||||
.triangle {
|
||||
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
|
||||
|
||||
.labels-list__header {
|
||||
@apply bg-n-alpha-3 backdrop-blur-[100px] py-0 px-2.5;
|
||||
svg path {
|
||||
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
|
||||
}
|
||||
|
||||
.labels-list__body {
|
||||
@apply flex-1 overflow-y-auto py-2.5 mx-0;
|
||||
}
|
||||
|
||||
.labels-list__footer {
|
||||
@apply p-2;
|
||||
|
||||
button {
|
||||
@apply w-full;
|
||||
|
||||
.button__content {
|
||||
@apply text-center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label-list-search {
|
||||
@apply bg-n-alpha-black2 py-0 px-2.5 border border-solid border-n-strong rounded-md;
|
||||
|
||||
.search-icon {
|
||||
@apply text-n-slate-10;
|
||||
}
|
||||
|
||||
.label--search_input {
|
||||
@apply border-0 text-xs m-0 dark:bg-transparent bg-transparent h-[unset] w-full;
|
||||
}
|
||||
}
|
||||
|
||||
.labels-container {
|
||||
@apply absolute ltr:right-2 rtl:left-2 top-12 origin-top-right w-auto z-20 max-w-[15rem] min-w-[15rem] bg-n-alpha-3 backdrop-blur-[100px] border-n-weak rounded-lg border border-solid shadow-md;
|
||||
|
||||
.header {
|
||||
@apply p-2.5;
|
||||
|
||||
span {
|
||||
@apply text-sm font-medium;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
@apply max-h-[15rem] overflow-y-auto;
|
||||
|
||||
.label__list-container {
|
||||
@apply h-full;
|
||||
}
|
||||
}
|
||||
|
||||
.triangle {
|
||||
@apply block z-10 absolute text-left -top-3 ltr:right-[--triangle-position] rtl:left-[--triangle-position];
|
||||
|
||||
svg path {
|
||||
@apply fill-n-alpha-3 backdrop-blur-[100px] stroke-n-weak;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
@apply m-0 list-none;
|
||||
}
|
||||
|
||||
.labels-placeholder {
|
||||
@apply p-2;
|
||||
}
|
||||
|
||||
.label__list-item {
|
||||
@apply my-1 mx-0 py-0 px-2.5;
|
||||
|
||||
.item {
|
||||
@apply items-center rounded-md cursor-pointer flex py-1 px-2.5 hover:bg-n-slate-3 dark:hover:bg-n-solid-3;
|
||||
|
||||
&.label-selected {
|
||||
@apply bg-n-slate-2;
|
||||
}
|
||||
|
||||
span {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
.label-checkbox {
|
||||
@apply my-0 ltr:mr-2.5 rtl:ml-2.5;
|
||||
}
|
||||
|
||||
.label-title {
|
||||
@apply flex-grow w-full;
|
||||
}
|
||||
|
||||
.label-pill {
|
||||
@apply rounded-md h-3 w-3 flex-shrink-0 border border-solid border-n-weak;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-container {
|
||||
@apply bg-n-alpha-3 backdrop-blur-[100px] py-0 px-2.5 sticky top-0 z-20;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"LABELS": {
|
||||
"ASSIGN_LABELS": "Assign labels",
|
||||
"NO_LABELS_FOUND": "No labels found for",
|
||||
"NO_LABELS_FOUND": "No labels found",
|
||||
"ASSIGN_SELECTED_LABELS": "Assign selected labels",
|
||||
"ASSIGN_SUCCESFUL": "Labels assigned successfully.",
|
||||
"ASSIGN_FAILED": "Failed to assign labels. Please try again."
|
||||
|
||||
@@ -572,6 +572,16 @@
|
||||
"ACTIVE_EMPTY_STATE_TITLE": "No contacts are active at the moment 🌙"
|
||||
}
|
||||
},
|
||||
"CONTACTS_BULK_ACTIONS": {
|
||||
"ASSIGN_LABELS": "Assign Labels",
|
||||
"ASSIGN_LABELS_SUCCESS": "Labels assigned successfully.",
|
||||
"ASSIGN_LABELS_FAILED": "Failed to assign labels",
|
||||
"DESCRIPTION": "Select the labels you want to add to the selected contacts.",
|
||||
"NO_LABELS_FOUND": "No labels available yet.",
|
||||
"SELECTED_COUNT": "{count} selected",
|
||||
"CLEAR_SELECTION": "Clear selection",
|
||||
"SELECT_ALL": "Select all ({count})"
|
||||
},
|
||||
|
||||
"COMPOSE_NEW_CONVERSATION": {
|
||||
"CONTACT_SEARCH": {
|
||||
|
||||
@@ -15,20 +15,6 @@
|
||||
},
|
||||
"ERROR": "There was an error connecting to Shopify. Please try again or contact support if the issue persists."
|
||||
},
|
||||
"GITHUB": {
|
||||
"TITLE": "Connect Github",
|
||||
"LABEL": "Github Client ID",
|
||||
"PLACEHOLDER": "Enter your Github Client ID",
|
||||
"HELP": "Enter your Github Client ID",
|
||||
"CANCEL": "Cancel",
|
||||
"SUBMIT": "Connect",
|
||||
"DELETE": {
|
||||
"TITLE": "Are you sure you want to delete the integration?",
|
||||
"MESSAGE": "Are you sure you want to delete the integration?",
|
||||
"CONFIRM": "Yes, delete",
|
||||
"CANCEL": "Cancel"
|
||||
}
|
||||
},
|
||||
"HEADER": "Integrations",
|
||||
"DESCRIPTION": "Chatwoot integrates with multiple tools and services to improve your team's efficiency. Explore the list below to configure your favorite apps.",
|
||||
"LEARN_MORE": "Learn more about integrations",
|
||||
@@ -352,7 +338,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"CAPTAIN": {
|
||||
"NAME": "Captain",
|
||||
"HEADER_KNOW_MORE": "Know more",
|
||||
@@ -876,6 +861,7 @@
|
||||
},
|
||||
"RESPONSES": {
|
||||
"HEADER": "FAQs",
|
||||
"PENDING_FAQS": "Pending FAQs",
|
||||
"ADD_NEW": "Create new FAQ",
|
||||
"DOCUMENTABLE": {
|
||||
"CONVERSATION": "Conversation #{id}"
|
||||
@@ -915,6 +901,10 @@
|
||||
"APPROVED": "Approved",
|
||||
"ALL": "All"
|
||||
},
|
||||
"PENDING_BANNER": {
|
||||
"TITLE": "Captain has found some FAQs your customers were looking for.",
|
||||
"ACTION": "Click here to review"
|
||||
},
|
||||
"FORM_DESCRIPTION": "Add a question and its corresponding answer to the knowledge base and select the assistant it should be associated with.",
|
||||
"CREATE": {
|
||||
"TITLE": "Add an FAQ",
|
||||
@@ -946,13 +936,15 @@
|
||||
"APPROVE_SUCCESS_MESSAGE": "The FAQ was marked as approved"
|
||||
},
|
||||
"OPTIONS": {
|
||||
"APPROVE": "Mark as approved",
|
||||
"EDIT_RESPONSE": "Edit FAQ",
|
||||
"DELETE_RESPONSE": "Delete FAQ"
|
||||
"APPROVE": "Approve",
|
||||
"EDIT_RESPONSE": "Edit",
|
||||
"DELETE_RESPONSE": "Delete"
|
||||
},
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No FAQs Found",
|
||||
"NO_PENDING_TITLE": "There are no more pending FAQs to review",
|
||||
"SUBTITLE": "FAQs help your assistant provide quick and accurate answers to questions from your customers. They can be generated automatically from your content or can be added manually.",
|
||||
"CLEAR_SEARCH": "Clear active filters",
|
||||
"FEATURE_SPOTLIGHT": {
|
||||
"TITLE": "Captain FAQ",
|
||||
"NOTE": "Captain FAQs detects common customer questions—whether missing from your knowledge base or frequently asked—and generates relevant FAQs to improve support. You can review each suggestion and decide whether to approve or reject it."
|
||||
|
||||
@@ -10,6 +10,7 @@ import AssistantGuidelinesIndex from './assistants/guidelines/Index.vue';
|
||||
import AssistantScenariosIndex from './assistants/scenarios/Index.vue';
|
||||
import DocumentsIndex from './documents/Index.vue';
|
||||
import ResponsesIndex from './responses/Index.vue';
|
||||
import ResponsesPendingIndex from './responses/Pending.vue';
|
||||
import CustomToolsIndex from './tools/Index.vue';
|
||||
|
||||
export const routes = [
|
||||
@@ -125,6 +126,19 @@ export const routes = [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/responses/pending'),
|
||||
component: ResponsesPendingIndex,
|
||||
name: 'captain_responses_pending',
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
featureFlag: FEATURE_FLAGS.CAPTAIN,
|
||||
installationTypes: [
|
||||
INSTALLATION_TYPES.CLOUD,
|
||||
INSTALLATION_TYPES.ENTERPRISE,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/tools'),
|
||||
component: CustomToolsIndex,
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { OnClickOutside } from '@vueuse/components';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
|
||||
import Banner from 'dashboard/components-next/banner/Banner.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
|
||||
@@ -25,6 +23,7 @@ import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight
|
||||
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
const uiFlags = useMapGetter('captainResponses/getUIFlags');
|
||||
@@ -37,7 +36,6 @@ const selectedResponse = ref(null);
|
||||
const deleteDialog = ref(null);
|
||||
const bulkDeleteDialog = ref(null);
|
||||
|
||||
const selectedStatus = ref('all');
|
||||
const selectedAssistant = ref('all');
|
||||
const dialogType = ref('');
|
||||
const searchQuery = ref('');
|
||||
@@ -45,54 +43,17 @@ const { t } = useI18n();
|
||||
|
||||
const createDialog = ref(null);
|
||||
|
||||
const isStatusFilterOpen = ref(false);
|
||||
const shouldShowDropdown = computed(() => {
|
||||
if (assistants.value.length === 0) return false;
|
||||
|
||||
return !isFetching.value;
|
||||
});
|
||||
|
||||
const statusOptions = computed(() =>
|
||||
['all', 'pending', 'approved'].map(key => ({
|
||||
label: t(`CAPTAIN.RESPONSES.STATUS.${key.toUpperCase()}`),
|
||||
value: key,
|
||||
action: 'filter',
|
||||
}))
|
||||
);
|
||||
|
||||
const filteredResponses = computed(() => {
|
||||
return selectedStatus.value === 'pending'
|
||||
? responses.value.filter(r => r.status === 'pending')
|
||||
: responses.value;
|
||||
});
|
||||
|
||||
const selectedStatusLabel = computed(() => {
|
||||
const status = statusOptions.value.find(
|
||||
option => option.value === selectedStatus.value
|
||||
);
|
||||
return t('CAPTAIN.RESPONSES.FILTER.STATUS', {
|
||||
selected: status ? status.label : '',
|
||||
});
|
||||
});
|
||||
const pendingCount = useMapGetter('captainResponses/getPendingCount');
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteDialog.value.dialogRef.open();
|
||||
};
|
||||
const handleAccept = async () => {
|
||||
try {
|
||||
await store.dispatch('captainResponses/update', {
|
||||
id: selectedResponse.value.id,
|
||||
status: 'approved',
|
||||
});
|
||||
useAlert(t(`CAPTAIN.RESPONSES.EDIT.APPROVE_SUCCESS_MESSAGE`));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.message || t(`CAPTAIN.RESPONSES.EDIT.ERROR_MESSAGE`);
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
selectedResponse.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
dialogType.value = 'create';
|
||||
@@ -105,9 +66,7 @@ const handleEdit = () => {
|
||||
};
|
||||
|
||||
const handleAction = ({ action, id }) => {
|
||||
selectedResponse.value = filteredResponses.value.find(
|
||||
response => id === response.id
|
||||
);
|
||||
selectedResponse.value = responses.value.find(response => id === response.id);
|
||||
nextTick(() => {
|
||||
if (action === 'delete') {
|
||||
handleDelete();
|
||||
@@ -115,9 +74,6 @@ const handleAction = ({ action, id }) => {
|
||||
if (action === 'edit') {
|
||||
handleEdit();
|
||||
}
|
||||
if (action === 'approve') {
|
||||
handleAccept();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -135,17 +91,31 @@ const handleCreateClose = () => {
|
||||
selectedResponse.value = null;
|
||||
};
|
||||
|
||||
const fetchResponses = (page = 1) => {
|
||||
const filterParams = { page };
|
||||
if (selectedStatus.value !== 'all') {
|
||||
filterParams.status = selectedStatus.value;
|
||||
const updateURLWithFilters = (page, search) => {
|
||||
const query = {
|
||||
page: page || 1,
|
||||
};
|
||||
|
||||
if (search) {
|
||||
query.search = search;
|
||||
}
|
||||
|
||||
router.replace({ query });
|
||||
};
|
||||
|
||||
const fetchResponses = (page = 1) => {
|
||||
const filterParams = { page, status: 'approved' };
|
||||
|
||||
if (selectedAssistant.value !== 'all') {
|
||||
filterParams.assistantId = selectedAssistant.value;
|
||||
}
|
||||
if (searchQuery.value) {
|
||||
filterParams.search = searchQuery.value;
|
||||
}
|
||||
|
||||
// Update URL with current filters
|
||||
updateURLWithFilters(page, searchQuery.value);
|
||||
|
||||
store.dispatch('captainResponses/get', filterParams);
|
||||
};
|
||||
|
||||
@@ -155,7 +125,7 @@ const hoveredCard = ref(null);
|
||||
|
||||
const bulkSelectionState = computed(() => {
|
||||
const selectedCount = bulkSelectedIds.value.size;
|
||||
const totalCount = filteredResponses.value?.length || 0;
|
||||
const totalCount = responses.value?.length || 0;
|
||||
|
||||
return {
|
||||
hasSelected: selectedCount > 0,
|
||||
@@ -168,13 +138,13 @@ const bulkCheckbox = computed({
|
||||
get: () => bulkSelectionState.value.allSelected,
|
||||
set: value => {
|
||||
bulkSelectedIds.value = value
|
||||
? new Set(filteredResponses.value.map(r => r.id))
|
||||
? new Set(responses.value.map(r => r.id))
|
||||
: new Set();
|
||||
},
|
||||
});
|
||||
|
||||
const buildSelectedCountLabel = computed(() => {
|
||||
const count = filteredResponses.value?.length || 0;
|
||||
const count = responses.value?.length || 0;
|
||||
return bulkSelectionState.value.allSelected
|
||||
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
|
||||
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
|
||||
@@ -191,7 +161,7 @@ const handleCardSelect = id => {
|
||||
};
|
||||
|
||||
const fetchResponseAfterBulkAction = () => {
|
||||
const hasNoResponsesLeft = filteredResponses.value?.length === 0;
|
||||
const hasNoResponsesLeft = responses.value?.length === 0;
|
||||
const currentPage = responseMeta.value?.page;
|
||||
|
||||
if (hasNoResponsesLeft) {
|
||||
@@ -208,22 +178,6 @@ const fetchResponseAfterBulkAction = () => {
|
||||
bulkSelectedIds.value = new Set();
|
||||
};
|
||||
|
||||
const handleBulkApprove = async () => {
|
||||
try {
|
||||
await store.dispatch(
|
||||
'captainBulkActions/handleBulkApprove',
|
||||
Array.from(bulkSelectedIds.value)
|
||||
);
|
||||
|
||||
fetchResponseAfterBulkAction();
|
||||
useAlert(t('CAPTAIN.RESPONSES.BULK_APPROVE.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message || t('CAPTAIN.RESPONSES.BULK_APPROVE.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onPageChange = page => {
|
||||
// Store current selection state before fetching new page
|
||||
const wasAllPageSelected = bulkSelectionState.value.allSelected;
|
||||
@@ -238,7 +192,7 @@ const onPageChange = page => {
|
||||
};
|
||||
|
||||
const onDeleteSuccess = () => {
|
||||
if (filteredResponses.value?.length === 0 && responseMeta.value?.page > 1) {
|
||||
if (responses.value?.length === 0 && responseMeta.value?.page > 1) {
|
||||
onPageChange(responseMeta.value.page - 1);
|
||||
}
|
||||
};
|
||||
@@ -247,24 +201,31 @@ const onBulkDeleteSuccess = () => {
|
||||
fetchResponseAfterBulkAction();
|
||||
};
|
||||
|
||||
const handleStatusFilterChange = ({ value }) => {
|
||||
selectedStatus.value = value;
|
||||
isStatusFilterOpen.value = false;
|
||||
fetchResponses();
|
||||
};
|
||||
|
||||
const handleAssistantFilterChange = assistant => {
|
||||
selectedAssistant.value = assistant;
|
||||
fetchResponses();
|
||||
fetchResponses(1);
|
||||
};
|
||||
|
||||
const debouncedSearch = debounce(async () => {
|
||||
fetchResponses();
|
||||
fetchResponses(1);
|
||||
}, 500);
|
||||
|
||||
const initializeFromURL = () => {
|
||||
if (route.query.search) {
|
||||
searchQuery.value = route.query.search;
|
||||
}
|
||||
const pageFromURL = parseInt(route.query.page, 10) || 1;
|
||||
fetchResponses(pageFromURL);
|
||||
};
|
||||
|
||||
const navigateToPendingFAQs = () => {
|
||||
router.push({ name: 'captain_responses_pending' });
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('captainAssistants/get');
|
||||
fetchResponses();
|
||||
initializeFromURL();
|
||||
store.dispatch('captainResponses/fetchPendingCount');
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -276,8 +237,8 @@ onMounted(() => {
|
||||
:header-title="$t('CAPTAIN.RESPONSES.HEADER')"
|
||||
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!filteredResponses.length"
|
||||
:show-pagination-footer="!isFetching && !!filteredResponses.length"
|
||||
:is-empty="!responses.length"
|
||||
:show-pagination-footer="!isFetching && !!responses.length"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
@update:current-page="onPageChange"
|
||||
@click="handleCreate"
|
||||
@@ -294,18 +255,10 @@ onMounted(() => {
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #emptyState>
|
||||
<ResponsePageEmptyState @click="handleCreate" />
|
||||
</template>
|
||||
|
||||
<template #paywall>
|
||||
<CaptainPaywall />
|
||||
</template>
|
||||
|
||||
<template #controls>
|
||||
<template #subHeader>
|
||||
<div
|
||||
v-if="shouldShowDropdown"
|
||||
class="mb-4 -mt-3 flex justify-between items-center py-1"
|
||||
class="mb-2 flex justify-between items-center py-1"
|
||||
:class="{
|
||||
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 w-fit':
|
||||
bulkSelectionState.hasSelected,
|
||||
@@ -315,25 +268,7 @@ onMounted(() => {
|
||||
v-if="!bulkSelectionState.hasSelected"
|
||||
class="flex gap-3 justify-between w-full items-center"
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
<OnClickOutside @trigger="isStatusFilterOpen = false">
|
||||
<Button
|
||||
:label="selectedStatusLabel"
|
||||
icon="i-lucide-chevron-down"
|
||||
size="sm"
|
||||
color="slate"
|
||||
trailing-icon
|
||||
class="max-w-48"
|
||||
@click="isStatusFilterOpen = !isStatusFilterOpen"
|
||||
/>
|
||||
|
||||
<DropdownMenu
|
||||
v-if="isStatusFilterOpen"
|
||||
:menu-items="statusOptions"
|
||||
class="mt-2"
|
||||
@action="handleStatusFilterChange"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
<div class="flex items-center gap-3">
|
||||
<AssistantSelector
|
||||
:assistant-id="selectedAssistant"
|
||||
@update="handleAssistantFilterChange"
|
||||
@@ -344,6 +279,7 @@ onMounted(() => {
|
||||
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
|
||||
class="w-64"
|
||||
size="sm"
|
||||
type="search"
|
||||
autofocus
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
@@ -380,15 +316,6 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<div class="flex gap-3 items-center">
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
|
||||
sm
|
||||
ghost
|
||||
icon="i-lucide-check"
|
||||
class="!px-1.5"
|
||||
@click="handleBulkApprove"
|
||||
/>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
|
||||
sm
|
||||
@@ -404,12 +331,29 @@ onMounted(() => {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #emptyState>
|
||||
<ResponsePageEmptyState @click="handleCreate" />
|
||||
</template>
|
||||
|
||||
<template #paywall>
|
||||
<CaptainPaywall />
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<LimitBanner class="mb-5" />
|
||||
<Banner
|
||||
v-if="pendingCount > 0"
|
||||
color="blue"
|
||||
class="mb-4 -mt-3"
|
||||
:action-label="$t('CAPTAIN.RESPONSES.PENDING_BANNER.ACTION')"
|
||||
@action="navigateToPendingFAQs"
|
||||
>
|
||||
{{ $t('CAPTAIN.RESPONSES.PENDING_BANNER.TITLE') }}
|
||||
</Banner>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<ResponseCard
|
||||
v-for="response in filteredResponses"
|
||||
v-for="response in responses"
|
||||
:id="response.id"
|
||||
:key="response.id"
|
||||
:question="response.question"
|
||||
@@ -422,6 +366,7 @@ onMounted(() => {
|
||||
:is-selected="bulkSelectedIds.has(response.id)"
|
||||
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
|
||||
:show-menu="!bulkSelectedIds.has(response.id)"
|
||||
:show-actions="false"
|
||||
@action="handleAction"
|
||||
@navigate="handleNavigationAction"
|
||||
@select="handleCardSelect"
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||
import AssistantSelector from 'dashboard/components-next/captain/pageComponents/AssistantSelector.vue';
|
||||
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
|
||||
import CreateResponseDialog from 'dashboard/components-next/captain/pageComponents/response/CreateResponseDialog.vue';
|
||||
import ResponsePageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/ResponsePageEmptyState.vue';
|
||||
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
|
||||
import LimitBanner from 'dashboard/components-next/captain/pageComponents/response/LimitBanner.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
const uiFlags = useMapGetter('captainResponses/getUIFlags');
|
||||
const assistants = useMapGetter('captainAssistants/getRecords');
|
||||
const responseMeta = useMapGetter('captainResponses/getMeta');
|
||||
const responses = useMapGetter('captainResponses/getRecords');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
|
||||
const selectedResponse = ref(null);
|
||||
const deleteDialog = ref(null);
|
||||
const bulkDeleteDialog = ref(null);
|
||||
|
||||
const selectedAssistant = ref('all');
|
||||
const dialogType = ref('');
|
||||
const searchQuery = ref('');
|
||||
const { t } = useI18n();
|
||||
|
||||
const createDialog = ref(null);
|
||||
|
||||
const shouldShowDropdown = computed(() => {
|
||||
if (assistants.value.length === 0) return false;
|
||||
return !isFetching.value;
|
||||
});
|
||||
|
||||
const backUrl = computed(() =>
|
||||
frontendURL(`accounts/${route.params.accountId}/captain/responses`)
|
||||
);
|
||||
|
||||
// Filter out approved responses in pending view
|
||||
const filteredResponses = computed(() =>
|
||||
responses.value.filter(response => response.status !== 'approved')
|
||||
);
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteDialog.value.dialogRef.open();
|
||||
};
|
||||
|
||||
const handleAccept = async () => {
|
||||
try {
|
||||
await store.dispatch('captainResponses/update', {
|
||||
id: selectedResponse.value.id,
|
||||
status: 'approved',
|
||||
});
|
||||
useAlert(t(`CAPTAIN.RESPONSES.EDIT.APPROVE_SUCCESS_MESSAGE`));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.message || t(`CAPTAIN.RESPONSES.EDIT.ERROR_MESSAGE`);
|
||||
useAlert(errorMessage);
|
||||
} finally {
|
||||
selectedResponse.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
dialogType.value = 'create';
|
||||
nextTick(() => createDialog.value.dialogRef.open());
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
dialogType.value = 'edit';
|
||||
nextTick(() => createDialog.value.dialogRef.open());
|
||||
};
|
||||
|
||||
const handleAction = ({ action, id }) => {
|
||||
selectedResponse.value = filteredResponses.value.find(
|
||||
response => id === response.id
|
||||
);
|
||||
nextTick(() => {
|
||||
if (action === 'delete') {
|
||||
handleDelete();
|
||||
}
|
||||
if (action === 'edit') {
|
||||
handleEdit();
|
||||
}
|
||||
if (action === 'approve') {
|
||||
handleAccept();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleNavigationAction = ({ id, type }) => {
|
||||
if (type === 'Conversation') {
|
||||
router.push({
|
||||
name: 'inbox_conversation',
|
||||
params: { conversation_id: id },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateClose = () => {
|
||||
dialogType.value = '';
|
||||
selectedResponse.value = null;
|
||||
};
|
||||
|
||||
const updateURLWithFilters = (page, search) => {
|
||||
const query = {
|
||||
page: page || 1,
|
||||
};
|
||||
|
||||
if (search) {
|
||||
query.search = search;
|
||||
}
|
||||
|
||||
router.replace({ query });
|
||||
};
|
||||
|
||||
const fetchResponses = (page = 1) => {
|
||||
const filterParams = { page, status: 'pending' };
|
||||
|
||||
if (selectedAssistant.value !== 'all') {
|
||||
filterParams.assistantId = selectedAssistant.value;
|
||||
}
|
||||
if (searchQuery.value) {
|
||||
filterParams.search = searchQuery.value;
|
||||
}
|
||||
|
||||
// Update URL with current filters
|
||||
updateURLWithFilters(page, searchQuery.value);
|
||||
|
||||
store.dispatch('captainResponses/get', filterParams);
|
||||
};
|
||||
|
||||
// Bulk action
|
||||
const bulkSelectedIds = ref(new Set());
|
||||
const hoveredCard = ref(null);
|
||||
|
||||
const bulkSelectionState = computed(() => {
|
||||
const selectedCount = bulkSelectedIds.value.size;
|
||||
const totalCount = filteredResponses.value?.length || 0;
|
||||
|
||||
return {
|
||||
hasSelected: selectedCount > 0,
|
||||
isIndeterminate: selectedCount > 0 && selectedCount < totalCount,
|
||||
allSelected: totalCount > 0 && selectedCount === totalCount,
|
||||
};
|
||||
});
|
||||
|
||||
const bulkCheckbox = computed({
|
||||
get: () => bulkSelectionState.value.allSelected,
|
||||
set: value => {
|
||||
bulkSelectedIds.value = value
|
||||
? new Set(filteredResponses.value.map(r => r.id))
|
||||
: new Set();
|
||||
},
|
||||
});
|
||||
|
||||
const buildSelectedCountLabel = computed(() => {
|
||||
const count = filteredResponses.value?.length || 0;
|
||||
return bulkSelectionState.value.allSelected
|
||||
? t('CAPTAIN.RESPONSES.UNSELECT_ALL', { count })
|
||||
: t('CAPTAIN.RESPONSES.SELECT_ALL', { count });
|
||||
});
|
||||
|
||||
const handleCardHover = (isHovered, id) => {
|
||||
hoveredCard.value = isHovered ? id : null;
|
||||
};
|
||||
|
||||
const handleCardSelect = id => {
|
||||
const selected = new Set(bulkSelectedIds.value);
|
||||
selected[selected.has(id) ? 'delete' : 'add'](id);
|
||||
bulkSelectedIds.value = selected;
|
||||
};
|
||||
|
||||
const fetchResponseAfterBulkAction = () => {
|
||||
const hasNoResponsesLeft = filteredResponses.value?.length === 0;
|
||||
const currentPage = responseMeta.value?.page;
|
||||
|
||||
if (hasNoResponsesLeft) {
|
||||
const pageToFetch = currentPage > 1 ? currentPage - 1 : currentPage;
|
||||
fetchResponses(pageToFetch);
|
||||
} else {
|
||||
fetchResponses(currentPage);
|
||||
}
|
||||
|
||||
bulkSelectedIds.value = new Set();
|
||||
};
|
||||
|
||||
const handleBulkApprove = async () => {
|
||||
try {
|
||||
await store.dispatch(
|
||||
'captainBulkActions/handleBulkApprove',
|
||||
Array.from(bulkSelectedIds.value)
|
||||
);
|
||||
|
||||
fetchResponseAfterBulkAction();
|
||||
useAlert(t('CAPTAIN.RESPONSES.BULK_APPROVE.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
error?.message || t('CAPTAIN.RESPONSES.BULK_APPROVE.ERROR_MESSAGE')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onPageChange = page => {
|
||||
const wasAllPageSelected = bulkSelectionState.value.allSelected;
|
||||
const hadPartialSelection = bulkSelectedIds.value.size > 0;
|
||||
|
||||
fetchResponses(page);
|
||||
|
||||
if (wasAllPageSelected || hadPartialSelection) {
|
||||
bulkSelectedIds.value = new Set();
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteSuccess = () => {
|
||||
if (filteredResponses.value?.length === 0 && responseMeta.value?.page > 1) {
|
||||
onPageChange(responseMeta.value.page - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const onBulkDeleteSuccess = () => {
|
||||
fetchResponseAfterBulkAction();
|
||||
};
|
||||
|
||||
const handleAssistantFilterChange = assistant => {
|
||||
selectedAssistant.value = assistant;
|
||||
fetchResponses(1);
|
||||
};
|
||||
|
||||
const debouncedSearch = debounce(async () => {
|
||||
fetchResponses(1);
|
||||
}, 500);
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
return Boolean(searchQuery.value || selectedAssistant.value !== 'all');
|
||||
});
|
||||
|
||||
const clearFilters = () => {
|
||||
searchQuery.value = '';
|
||||
selectedAssistant.value = 'all';
|
||||
fetchResponses(1);
|
||||
};
|
||||
|
||||
const initializeFromURL = () => {
|
||||
if (route.query.search) {
|
||||
searchQuery.value = route.query.search;
|
||||
}
|
||||
const pageFromURL = parseInt(route.query.page, 10) || 1;
|
||||
fetchResponses(pageFromURL);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('captainAssistants/get');
|
||||
initializeFromURL();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageLayout
|
||||
:total-count="responseMeta.totalCount"
|
||||
:current-page="responseMeta.page"
|
||||
:button-policy="['administrator']"
|
||||
:header-title="$t('CAPTAIN.RESPONSES.PENDING_FAQS')"
|
||||
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!filteredResponses.length"
|
||||
:show-pagination-footer="!isFetching && !!filteredResponses.length"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN"
|
||||
:back-url="backUrl"
|
||||
@update:current-page="onPageChange"
|
||||
@click="handleCreate"
|
||||
>
|
||||
<template #knowMore>
|
||||
<FeatureSpotlightPopover
|
||||
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
|
||||
:title="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
||||
:note="$t('CAPTAIN.RESPONSES.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
|
||||
:hide-actions="!isOnChatwootCloud"
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/faqs-popover-light.svg"
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/faqs-popover-dark.svg"
|
||||
learn-more-url="https://chwt.app/captain-faq"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #subHeader>
|
||||
<div
|
||||
v-if="shouldShowDropdown"
|
||||
class="mb-2 flex justify-between items-center py-1"
|
||||
:class="{
|
||||
'ltr:pl-3 rtl:pr-3 ltr:pr-1 rtl:pl-1 rounded-lg outline outline-1 outline-n-weak bg-n-solid-3 w-fit':
|
||||
bulkSelectionState.hasSelected,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="!bulkSelectionState.hasSelected"
|
||||
class="flex gap-3 justify-between w-full items-center"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<AssistantSelector
|
||||
:assistant-id="selectedAssistant"
|
||||
@update="handleAssistantFilterChange"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
:placeholder="$t('CAPTAIN.RESPONSES.SEARCH_PLACEHOLDER')"
|
||||
class="w-64"
|
||||
size="sm"
|
||||
type="search"
|
||||
autofocus
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<transition
|
||||
name="slide-fade"
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 transform ltr:-translate-x-4 rtl:translate-x-4"
|
||||
enter-to-class="opacity-100 transform translate-x-0"
|
||||
leave-active-class="hidden opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="bulkSelectionState.hasSelected"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
v-model="bulkCheckbox"
|
||||
:indeterminate="bulkSelectionState.isIndeterminate"
|
||||
/>
|
||||
<span class="text-sm text-n-slate-12 font-medium tabular-nums">
|
||||
{{ buildSelectedCountLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-sm text-n-slate-10 tabular-nums">
|
||||
{{
|
||||
$t('CAPTAIN.RESPONSES.SELECTED', {
|
||||
count: bulkSelectedIds.size,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<div class="flex gap-3 items-center">
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')"
|
||||
sm
|
||||
ghost
|
||||
icon="i-lucide-check"
|
||||
class="!px-1.5"
|
||||
@click="handleBulkApprove"
|
||||
/>
|
||||
<div class="h-4 w-px bg-n-strong" />
|
||||
<Button
|
||||
:label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')"
|
||||
sm
|
||||
ruby
|
||||
ghost
|
||||
class="!px-1.5"
|
||||
icon="i-lucide-trash"
|
||||
@click="bulkDeleteDialog.dialogRef.open()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #emptyState>
|
||||
<ResponsePageEmptyState
|
||||
variant="pending"
|
||||
:has-active-filters="hasActiveFilters"
|
||||
@click="handleCreate"
|
||||
@clear-filters="clearFilters"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #paywall>
|
||||
<CaptainPaywall />
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<LimitBanner class="mb-5" />
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<ResponseCard
|
||||
v-for="response in filteredResponses"
|
||||
:id="response.id"
|
||||
:key="response.id"
|
||||
:question="response.question"
|
||||
:answer="response.answer"
|
||||
:assistant="response.assistant"
|
||||
:documentable="response.documentable"
|
||||
:status="response.status"
|
||||
:created-at="response.created_at"
|
||||
:updated-at="response.updated_at"
|
||||
:is-selected="bulkSelectedIds.has(response.id)"
|
||||
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
|
||||
:show-menu="false"
|
||||
:show-actions="!bulkSelectedIds.has(response.id)"
|
||||
@action="handleAction"
|
||||
@navigate="handleNavigationAction"
|
||||
@select="handleCardSelect"
|
||||
@hover="isHovered => handleCardHover(isHovered, response.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<DeleteDialog
|
||||
v-if="selectedResponse"
|
||||
ref="deleteDialog"
|
||||
:entity="selectedResponse"
|
||||
type="Responses"
|
||||
@delete-success="onDeleteSuccess"
|
||||
/>
|
||||
|
||||
<BulkDeleteDialog
|
||||
v-if="bulkSelectedIds"
|
||||
ref="bulkDeleteDialog"
|
||||
:bulk-ids="bulkSelectedIds"
|
||||
type="Responses"
|
||||
@delete-success="onBulkDeleteSuccess"
|
||||
/>
|
||||
|
||||
<CreateResponseDialog
|
||||
v-if="dialogType"
|
||||
ref="createDialog"
|
||||
:type="dialogType"
|
||||
:selected-response="selectedResponse"
|
||||
@close="handleCreateClose"
|
||||
/>
|
||||
</PageLayout>
|
||||
</template>
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import LabelActions from 'dashboard/components/widgets/conversation/conversationBulkActions/LabelActions.vue';
|
||||
|
||||
const props = defineProps({
|
||||
visibleContactIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedContactIds: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['clearSelection', 'assignLabels', 'toggleAll']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const selectedCount = computed(() => props.selectedContactIds.length);
|
||||
const totalVisibleContacts = computed(() => props.visibleContactIds.length);
|
||||
const showLabelSelector = ref(false);
|
||||
|
||||
const selectAllLabel = computed(() => {
|
||||
if (!totalVisibleContacts.value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return t('CONTACTS_BULK_ACTIONS.SELECT_ALL', {
|
||||
count: totalVisibleContacts.value,
|
||||
});
|
||||
});
|
||||
|
||||
const selectedCountLabel = computed(() =>
|
||||
t('CONTACTS_BULK_ACTIONS.SELECTED_COUNT', {
|
||||
count: selectedCount.value,
|
||||
})
|
||||
);
|
||||
|
||||
const allItems = computed(() =>
|
||||
props.visibleContactIds.map(id => ({
|
||||
id,
|
||||
}))
|
||||
);
|
||||
|
||||
const selectionModel = computed({
|
||||
get: () => new Set(props.selectedContactIds),
|
||||
set: newSet => {
|
||||
if (!props.visibleContactIds.length) {
|
||||
emit('toggleAll', false);
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldSelectAll =
|
||||
newSet.size === props.visibleContactIds.length && newSet.size > 0;
|
||||
emit('toggleAll', shouldSelectAll);
|
||||
},
|
||||
});
|
||||
|
||||
const emitClearSelection = () => {
|
||||
showLabelSelector.value = false;
|
||||
emit('clearSelection');
|
||||
};
|
||||
|
||||
const toggleLabelSelector = () => {
|
||||
if (!selectedCount.value || props.isLoading) return;
|
||||
showLabelSelector.value = !showLabelSelector.value;
|
||||
};
|
||||
|
||||
const closeLabelSelector = () => {
|
||||
showLabelSelector.value = false;
|
||||
};
|
||||
|
||||
const handleAssignLabels = labels => {
|
||||
emit('assignLabels', labels);
|
||||
closeLabelSelector();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BulkSelectBar
|
||||
v-model="selectionModel"
|
||||
:all-items="allItems"
|
||||
:select-all-label="selectAllLabel"
|
||||
:selected-count-label="selectedCountLabel"
|
||||
class="py-2 ltr:!pr-3 rtl:!pl-3"
|
||||
>
|
||||
<template #secondary-actions>
|
||||
<Button
|
||||
sm
|
||||
ghost
|
||||
slate
|
||||
:label="t('CONTACTS_BULK_ACTIONS.CLEAR_SELECTION')"
|
||||
class="!px-1.5"
|
||||
@click="emitClearSelection"
|
||||
/>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<div
|
||||
v-on-click-outside="closeLabelSelector"
|
||||
class="relative flex items-center"
|
||||
>
|
||||
<Button
|
||||
sm
|
||||
faded
|
||||
slate
|
||||
icon="i-lucide-tags"
|
||||
:label="t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS')"
|
||||
:disabled="!selectedCount || isLoading"
|
||||
:is-loading="isLoading"
|
||||
class="[&>span:nth-child(2)]:hidden sm:[&>span:nth-child(2)]:inline w-fit"
|
||||
@click="toggleLabelSelector"
|
||||
/>
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-100"
|
||||
enter-from-class="transform opacity-0 scale-95"
|
||||
enter-to-class="transform opacity-100 scale-100"
|
||||
leave-active-class="transition ease-in duration-75"
|
||||
leave-from-class="transform opacity-100 scale-100"
|
||||
leave-to-class="transform opacity-0 scale-95"
|
||||
>
|
||||
<LabelActions
|
||||
v-if="showLabelSelector"
|
||||
class="[&>.triangle]:!hidden [&>div>button]:!hidden ltr:!right-0 rtl:!left-0 top-8 mt-0.5"
|
||||
@assign="handleAssignLabels"
|
||||
/>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</BulkSelectBar>
|
||||
</template>
|
||||
@@ -3,14 +3,17 @@ import { onMounted, computed, ref, reactive, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import filterQueryGenerator from 'dashboard/helper/filterQueryGenerator';
|
||||
|
||||
import ContactsListLayout from 'dashboard/components-next/Contacts/ContactsListLayout.vue';
|
||||
import ContactsList from 'dashboard/components-next/Contacts/Pages/ContactsList.vue';
|
||||
import ContactEmptyState from 'dashboard/components-next/Contacts/EmptyState/ContactEmptyState.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import ContactsList from 'dashboard/components-next/Contacts/Pages/ContactsList.vue';
|
||||
import ContactsBulkActionBar from '../components/ContactsBulkActionBar.vue';
|
||||
import BulkActionsAPI from 'dashboard/api/bulkActions';
|
||||
|
||||
const DEFAULT_SORT_FIELD = 'last_activity_at';
|
||||
const DEBOUNCE_DELAY = 300;
|
||||
@@ -58,6 +61,10 @@ const isFetchingList = computed(
|
||||
);
|
||||
const currentPage = computed(() => Number(meta.value?.currentPage));
|
||||
const totalItems = computed(() => meta.value?.count);
|
||||
|
||||
const selectedContactIds = ref([]);
|
||||
const isBulkActionLoading = ref(false);
|
||||
const hasSelection = computed(() => selectedContactIds.value.length > 0);
|
||||
const activeSegment = computed(() => {
|
||||
if (!activeSegmentId.value) return undefined;
|
||||
return segments.value.find(view => view.id === Number(activeSegmentId.value));
|
||||
@@ -105,6 +112,31 @@ const emptyStateMessage = computed(() => {
|
||||
return t('CONTACTS_LAYOUT.EMPTY_STATE.SEARCH_EMPTY_STATE_TITLE');
|
||||
});
|
||||
|
||||
const visibleContactIds = computed(() =>
|
||||
contacts.value.map(contact => contact.id)
|
||||
);
|
||||
|
||||
const clearSelection = () => {
|
||||
selectedContactIds.value = [];
|
||||
};
|
||||
|
||||
const toggleSelectAll = shouldSelect => {
|
||||
selectedContactIds.value = shouldSelect ? [...visibleContactIds.value] : [];
|
||||
};
|
||||
|
||||
const toggleContactSelection = ({ id, value }) => {
|
||||
const isAlreadySelected = selectedContactIds.value.includes(id);
|
||||
const shouldSelect = value ?? !isAlreadySelected;
|
||||
|
||||
if (shouldSelect && !isAlreadySelected) {
|
||||
selectedContactIds.value = [...selectedContactIds.value, id];
|
||||
} else if (!shouldSelect && isAlreadySelected) {
|
||||
selectedContactIds.value = selectedContactIds.value.filter(
|
||||
contactId => contactId !== id
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const updatePageParam = (page, search = '') => {
|
||||
const query = {
|
||||
...route.query,
|
||||
@@ -129,6 +161,7 @@ const getCommonFetchParams = (page = 1) => ({
|
||||
});
|
||||
|
||||
const fetchContacts = async (page = 1) => {
|
||||
clearSelection();
|
||||
await store.dispatch('contacts/clearContactFilters');
|
||||
await store.dispatch('contacts/get', getCommonFetchParams(page));
|
||||
updatePageParam(page);
|
||||
@@ -136,6 +169,7 @@ const fetchContacts = async (page = 1) => {
|
||||
|
||||
const fetchSavedOrAppliedFilteredContact = async (payload, page = 1) => {
|
||||
if (!activeSegmentId.value && !hasAppliedFilters.value) return;
|
||||
clearSelection();
|
||||
await store.dispatch('contacts/filter', {
|
||||
...getCommonFetchParams(page),
|
||||
queryPayload: payload,
|
||||
@@ -144,6 +178,7 @@ const fetchSavedOrAppliedFilteredContact = async (payload, page = 1) => {
|
||||
};
|
||||
|
||||
const fetchActiveContacts = async (page = 1) => {
|
||||
clearSelection();
|
||||
await store.dispatch('contacts/clearContactFilters');
|
||||
await store.dispatch('contacts/active', {
|
||||
page,
|
||||
@@ -153,6 +188,7 @@ const fetchActiveContacts = async (page = 1) => {
|
||||
};
|
||||
|
||||
const searchContacts = debounce(async (value, page = 1) => {
|
||||
clearSelection();
|
||||
await store.dispatch('contacts/clearContactFilters');
|
||||
searchValue.value = value;
|
||||
|
||||
@@ -170,6 +206,7 @@ const searchContacts = debounce(async (value, page = 1) => {
|
||||
}, DEBOUNCE_DELAY);
|
||||
|
||||
const fetchContactsBasedOnContext = async page => {
|
||||
clearSelection();
|
||||
updatePageParam(page, searchValue.value);
|
||||
if (isFetchingList.value) return;
|
||||
if (searchQuery.value) {
|
||||
@@ -197,6 +234,28 @@ const fetchContactsBasedOnContext = async page => {
|
||||
await fetchContacts(page);
|
||||
};
|
||||
|
||||
const assignLabels = async labels => {
|
||||
if (!labels.length || !selectedContactIds.value.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
isBulkActionLoading.value = true;
|
||||
try {
|
||||
await BulkActionsAPI.create({
|
||||
type: 'Contact',
|
||||
ids: selectedContactIds.value,
|
||||
labels: { add: labels },
|
||||
});
|
||||
useAlert(t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS_SUCCESS'));
|
||||
clearSelection();
|
||||
await fetchContactsBasedOnContext(pageNumber.value);
|
||||
} catch (error) {
|
||||
useAlert(t('CONTACTS_BULK_ACTIONS.ASSIGN_LABELS_FAILED'));
|
||||
} finally {
|
||||
isBulkActionLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = async ({ sort, order }) => {
|
||||
Object.assign(sortState, { activeSort: sort, activeOrdering: order });
|
||||
|
||||
@@ -227,6 +286,17 @@ const createContact = async contact => {
|
||||
await store.dispatch('contacts/create', contact);
|
||||
};
|
||||
|
||||
watch(
|
||||
contacts,
|
||||
newContacts => {
|
||||
const idsOnPage = newContacts.map(contact => contact.id);
|
||||
selectedContactIds.value = selectedContactIds.value.filter(id =>
|
||||
idsOnPage.includes(id)
|
||||
);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => uiSettings.value?.contacts_sort_by,
|
||||
newSortBy => {
|
||||
@@ -331,7 +401,23 @@ onMounted(async () => {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ContactsList v-else :contacts="contacts" />
|
||||
<div v-else class="flex flex-col gap-4 px-6 pt-4 pb-6">
|
||||
<div v-if="hasSelection">
|
||||
<ContactsBulkActionBar
|
||||
:visible-contact-ids="visibleContactIds"
|
||||
:selected-contact-ids="selectedContactIds"
|
||||
:is-loading="isBulkActionLoading"
|
||||
@toggle-all="toggleSelectAll"
|
||||
@clear-selection="clearSelection"
|
||||
@assign-labels="assignLabels"
|
||||
/>
|
||||
</div>
|
||||
<ContactsList
|
||||
:contacts="contacts"
|
||||
:selected-contact-ids="selectedContactIds"
|
||||
@toggle-contact="toggleContactSelection"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ContactsListLayout>
|
||||
</div>
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import {
|
||||
useFunctionGetter,
|
||||
useMapGetter,
|
||||
useStore,
|
||||
} from 'dashboard/composables/store';
|
||||
|
||||
import Integration from './Integration.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
|
||||
const store = useStore();
|
||||
|
||||
const integrationLoaded = ref(false);
|
||||
|
||||
const integration = useFunctionGetter('integrations/getIntegration', 'github');
|
||||
|
||||
const uiFlags = useMapGetter('integrations/getUIFlags');
|
||||
|
||||
const integrationAction = computed(() => {
|
||||
if (integration.value.enabled) {
|
||||
return 'disconnect';
|
||||
}
|
||||
return integration.value.action;
|
||||
});
|
||||
|
||||
const initializeGithubIntegration = async () => {
|
||||
await store.dispatch('integrations/get', 'github');
|
||||
integrationLoaded.value = true;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeGithubIntegration();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-grow flex-shrink max-w-6xl p-4 mx-auto overflow-auto">
|
||||
<div v-if="integrationLoaded && !uiFlags.isCreatingGithub">
|
||||
<Integration
|
||||
:integration-id="integration.id"
|
||||
:integration-logo="integration.logo"
|
||||
:integration-name="integration.name"
|
||||
:integration-description="integration.description"
|
||||
:integration-enabled="integration.enabled"
|
||||
:integration-action="integrationAction"
|
||||
:delete-confirmation-text="{
|
||||
title: $t('INTEGRATION_SETTINGS.GITHUB.DELETE.TITLE'),
|
||||
message: $t('INTEGRATION_SETTINGS.GITHUB.DELETE.MESSAGE'),
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center flex-1">
|
||||
<Spinner size="" color-scheme="primary" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+1
-11
@@ -10,7 +10,7 @@ import SettingsContent from '../Wrapper.vue';
|
||||
import Linear from './Linear.vue';
|
||||
import Notion from './Notion.vue';
|
||||
import Shopify from './Shopify.vue';
|
||||
import Github from './Github.vue';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
@@ -100,16 +100,6 @@ export default {
|
||||
},
|
||||
props: route => ({ code: route.query.code }),
|
||||
},
|
||||
{
|
||||
path: 'github',
|
||||
name: 'settings_integrations_github',
|
||||
component: Github,
|
||||
meta: {
|
||||
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
props: route => ({ error: route.query.error }),
|
||||
},
|
||||
{
|
||||
path: 'shopify',
|
||||
name: 'settings_integrations_shopify',
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import CaptainResponseAPI from 'dashboard/api/captain/response';
|
||||
import { createStore } from './storeFactory';
|
||||
|
||||
const SET_PENDING_COUNT = 'SET_PENDING_COUNT';
|
||||
|
||||
export default createStore({
|
||||
name: 'CaptainResponse',
|
||||
API: CaptainResponseAPI,
|
||||
getters: {
|
||||
getPendingCount: state => state.meta.pendingCount || 0,
|
||||
},
|
||||
mutations: {
|
||||
[SET_PENDING_COUNT](state, count) {
|
||||
state.meta = {
|
||||
...state.meta,
|
||||
pendingCount: Number(count),
|
||||
};
|
||||
},
|
||||
},
|
||||
actions: mutations => ({
|
||||
removeBulkResponses: ({ commit, state }, ids) => {
|
||||
const updatedRecords = state.records.filter(
|
||||
@@ -28,5 +41,18 @@ export default createStore({
|
||||
|
||||
commit(mutations.SET, updatedRecords);
|
||||
},
|
||||
fetchPendingCount: async ({ commit }, assistantId) => {
|
||||
try {
|
||||
const response = await CaptainResponseAPI.get({
|
||||
status: 'pending',
|
||||
page: 1,
|
||||
assistantId,
|
||||
});
|
||||
const count = response.data?.meta?.total_count || 0;
|
||||
commit(SET_PENDING_COUNT, count);
|
||||
} catch (error) {
|
||||
commit(SET_PENDING_COUNT, 0);
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -49,6 +49,7 @@ export const createMutations = mutationTypes => ({
|
||||
},
|
||||
[mutationTypes.SET_META](state, meta) {
|
||||
state.meta = {
|
||||
...state.meta,
|
||||
totalCount: Number(meta.total_count),
|
||||
page: Number(meta.page),
|
||||
};
|
||||
@@ -69,7 +70,7 @@ export const createCrudActions = (API, mutationTypes) => ({
|
||||
});
|
||||
|
||||
export const createStore = options => {
|
||||
const { name, API, actions, getters } = options;
|
||||
const { name, API, actions, getters, mutations } = options;
|
||||
const mutationTypes = generateMutationTypes(name);
|
||||
|
||||
const customActions = actions ? actions(mutationTypes) : {};
|
||||
@@ -81,7 +82,10 @@ export const createStore = options => {
|
||||
...createGetters(),
|
||||
...(getters || {}),
|
||||
},
|
||||
mutations: createMutations(mutationTypes),
|
||||
mutations: {
|
||||
...createMutations(mutationTypes),
|
||||
...(mutations || {}),
|
||||
},
|
||||
actions: {
|
||||
...createCrudActions(API, mutationTypes),
|
||||
...customActions,
|
||||
|
||||
@@ -78,6 +78,11 @@ export const getters = {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out authentication templates
|
||||
if (template.category === 'AUTHENTICATION') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
|
||||
const hasUnsupportedComponents = template.components.some(
|
||||
component =>
|
||||
|
||||
@@ -265,6 +265,39 @@ describe('#getters', () => {
|
||||
expect(result[0].name).toBe('regular_template');
|
||||
});
|
||||
|
||||
it('filters out authentication templates', () => {
|
||||
const authenticationTemplates = [
|
||||
{
|
||||
name: 'auth_template',
|
||||
status: 'approved',
|
||||
category: 'AUTHENTICATION',
|
||||
components: [
|
||||
{ type: 'BODY', text: 'Your verification code is {{1}}' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'regular_template',
|
||||
status: 'approved',
|
||||
category: 'MARKETING',
|
||||
components: [{ type: 'BODY', text: 'Regular message' }],
|
||||
},
|
||||
];
|
||||
|
||||
const state = {
|
||||
records: [
|
||||
{
|
||||
id: 1,
|
||||
channel_type: 'Channel::Whatsapp',
|
||||
message_templates: authenticationTemplates,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = getters.getFilteredWhatsAppTemplates(state)(1);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('regular_template');
|
||||
});
|
||||
|
||||
it('returns valid templates from fixture data', () => {
|
||||
const state = {
|
||||
records: [
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
class Contacts::BulkActionJob < ApplicationJob
|
||||
queue_as :medium
|
||||
|
||||
def perform(account_id, user_id, params)
|
||||
account = Account.find(account_id)
|
||||
user = User.find(user_id)
|
||||
|
||||
Contacts::BulkActionService.new(
|
||||
account: account,
|
||||
user: user,
|
||||
params: params
|
||||
).perform
|
||||
end
|
||||
end
|
||||
@@ -5,6 +5,7 @@ class ApplicationMailbox < ActionMailbox::Base
|
||||
# Eg: email should be something like : reply+6bdc3f4d-0bec-4515-a284-5d916fdde489@domain.com
|
||||
REPLY_EMAIL_UUID_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i
|
||||
CONVERSATION_MESSAGE_ID_PATTERN = %r{conversation/([a-zA-Z0-9-]*?)/messages/(\d+?)@(\w+\.\w+)}
|
||||
CONVERSATION_FALLBACK_ID_PATTERN = %r{account/(\d+)/conversation/([a-zA-Z0-9-]+)@}
|
||||
|
||||
# routes as a reply to existing conversations
|
||||
routing(
|
||||
@@ -24,14 +25,27 @@ class ApplicationMailbox < ActionMailbox::Base
|
||||
# <account/#{@account.id}/conversation/#{@conversation.uuid}@#{@account.inbound_email_domain}>
|
||||
def in_reply_to_mail?(inbound_mail)
|
||||
in_reply_to = inbound_mail.mail.in_reply_to
|
||||
references = inbound_mail.mail.references
|
||||
|
||||
in_reply_to.present? && (
|
||||
# Check in_reply_to first
|
||||
return true if in_reply_to.present? && (
|
||||
in_reply_to_matches?(in_reply_to) || Message.exists?(source_id: in_reply_to)
|
||||
)
|
||||
|
||||
# Fallback to checking references header
|
||||
references.present? && references_match?(references)
|
||||
end
|
||||
|
||||
def in_reply_to_matches?(in_reply_to)
|
||||
Array.wrap(in_reply_to).any? { it.match?(CONVERSATION_MESSAGE_ID_PATTERN) }
|
||||
Array.wrap(in_reply_to).any? { |id| id.match?(CONVERSATION_MESSAGE_ID_PATTERN) || id.match?(CONVERSATION_FALLBACK_ID_PATTERN) }
|
||||
end
|
||||
|
||||
def references_match?(references)
|
||||
Array.wrap(references).any? do |reference|
|
||||
reference.match?(CONVERSATION_MESSAGE_ID_PATTERN) ||
|
||||
reference.match?(CONVERSATION_FALLBACK_ID_PATTERN) ||
|
||||
Message.exists?(source_id: reference)
|
||||
end
|
||||
end
|
||||
|
||||
# checks if follow this pattern send it to reply_mailbox
|
||||
|
||||
@@ -21,8 +21,10 @@ class ReplyMailbox < ApplicationMailbox
|
||||
def find_relative_conversation
|
||||
if @conversation_uuid
|
||||
find_conversation_with_uuid
|
||||
elsif mail.in_reply_to.present?
|
||||
find_conversation_with_in_reply_to
|
||||
elsif mail.in_reply_to.present? || mail.references.present?
|
||||
find_conversation_with_in_reply_to if mail.in_reply_to.present?
|
||||
# If still no conversation found and references exist, try references as fallback
|
||||
find_conversation_with_references if @conversation.blank? && mail.references.present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -79,6 +81,55 @@ class ReplyMailbox < ApplicationMailbox
|
||||
find_conversation_by_message_id(in_reply_to_addresses) if @conversation.blank?
|
||||
end
|
||||
|
||||
# Find conversation from References header as fallback
|
||||
# Extract conversation UUID from any reference that matches our patterns
|
||||
def find_conversation_with_references
|
||||
references_addresses = mail.references
|
||||
references_addresses = [references_addresses] if references_addresses.is_a?(String)
|
||||
|
||||
references_addresses.each do |reference|
|
||||
conversation = find_conversation_from_reference(reference)
|
||||
next unless conversation && conversation_belongs_to_channel?(conversation)
|
||||
|
||||
@conversation = conversation
|
||||
@conversation_uuid = conversation.uuid
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
def find_conversation_from_reference(reference)
|
||||
# Try message-specific pattern: conversation/{uuid}/messages/{id}@domain
|
||||
message_match = reference.match(::ApplicationMailbox::CONVERSATION_MESSAGE_ID_PATTERN)
|
||||
if message_match
|
||||
uuid = message_match.captures[0]
|
||||
conversation = Conversation.find_by(uuid: uuid)
|
||||
return conversation if conversation.present?
|
||||
end
|
||||
|
||||
# Try conversation fallback pattern: account/{id}/conversation/{uuid}@domain
|
||||
fallback_match = reference.match(::ApplicationMailbox::CONVERSATION_FALLBACK_ID_PATTERN)
|
||||
if fallback_match
|
||||
uuid = fallback_match.captures[1]
|
||||
conversation = Conversation.find_by(uuid: uuid)
|
||||
return conversation if conversation.present?
|
||||
end
|
||||
|
||||
# Try finding by message source_id
|
||||
message = Message.find_by(source_id: reference)
|
||||
message&.conversation
|
||||
end
|
||||
|
||||
def conversation_belongs_to_channel?(conversation)
|
||||
return true unless conversation
|
||||
|
||||
# Get the channel from the email's To/CC addresses
|
||||
channel = EmailChannelFinder.new(mail).perform
|
||||
return false unless channel
|
||||
|
||||
# Check if the conversation's inbox matches the channel
|
||||
conversation.inbox.channel_id == channel.id
|
||||
end
|
||||
|
||||
def validate_resource(resource)
|
||||
Rails.logger.error "[App::Mailboxes::ReplyMailbox] Email conversation with uuid: #{conversation_uuid} not found" if resource.nil?
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
class Integrations::App
|
||||
include Linear::IntegrationHelper
|
||||
include Github::IntegrationHelper
|
||||
attr_accessor :params
|
||||
|
||||
def initialize(params)
|
||||
@@ -31,15 +30,10 @@ class Integrations::App
|
||||
params[:fields]
|
||||
end
|
||||
|
||||
# There is no way to get the account_id from the linear/github callback
|
||||
# so we are using the generate token method to generate a token and encode it in the state parameter
|
||||
# There is no way to get the account_id from the linear callback
|
||||
# so we are using the generate_linear_token method to generate a token and encode it in the state parameter
|
||||
def encode_state
|
||||
case params[:id]
|
||||
when 'linear'
|
||||
generate_linear_token(Current.account.id)
|
||||
when 'github'
|
||||
generate_github_token(Current.account.id)
|
||||
end
|
||||
generate_linear_token(Current.account.id)
|
||||
end
|
||||
|
||||
def action
|
||||
@@ -49,8 +43,6 @@ class Integrations::App
|
||||
"#{params[:action]}&client_id=#{client_id}&redirect_uri=#{self.class.slack_integration_url}"
|
||||
when 'linear'
|
||||
build_linear_action
|
||||
when 'github'
|
||||
build_github_action
|
||||
else
|
||||
params[:action]
|
||||
end
|
||||
@@ -62,8 +54,6 @@ class Integrations::App
|
||||
GlobalConfigService.load('SLACK_CLIENT_SECRET', nil).present?
|
||||
when 'linear'
|
||||
GlobalConfigService.load('LINEAR_CLIENT_ID', nil).present?
|
||||
when 'github'
|
||||
github_enabled?
|
||||
when 'shopify'
|
||||
shopify_enabled?(account)
|
||||
when 'leadsquared'
|
||||
@@ -88,17 +78,6 @@ class Integrations::App
|
||||
].join('&')
|
||||
end
|
||||
|
||||
def build_github_action
|
||||
GlobalConfigService.load('GITHUB_CLIENT_ID', nil)
|
||||
|
||||
# For GitHub Apps, redirect to installation page
|
||||
# The standard /installations/new URL should show org/user selection if the app is properly configured
|
||||
# Include state parameter with signed account ID for account context
|
||||
github_app_name = GlobalConfigService.load('GITHUB_APP_NAME', 'chatwoot-qa')
|
||||
state = Current.account.to_signed_global_id(expires_in: 1.hour)
|
||||
"https://github.com/apps/#{github_app_name}/installations/new?state=#{state}"
|
||||
end
|
||||
|
||||
def enabled?(account)
|
||||
case params[:id]
|
||||
when 'webhook'
|
||||
@@ -122,10 +101,6 @@ class Integrations::App
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/linear/callback"
|
||||
end
|
||||
|
||||
def self.github_integration_url
|
||||
"#{ENV.fetch('FRONTEND_URL', nil)}/github/callback"
|
||||
end
|
||||
|
||||
class << self
|
||||
def apps
|
||||
Hashie::Mash.new(APPS_CONFIG)
|
||||
@@ -144,10 +119,6 @@ class Integrations::App
|
||||
|
||||
private
|
||||
|
||||
def github_enabled?
|
||||
GlobalConfigService.load('GITHUB_CLIENT_ID', nil).present? && GlobalConfigService.load('GITHUB_CLIENT_SECRET', nil).present?
|
||||
end
|
||||
|
||||
def shopify_enabled?(account)
|
||||
account.feature_enabled?('shopify_integration') && GlobalConfigService.load('SHOPIFY_CLIENT_ID', nil).present?
|
||||
end
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
class Contacts::BulkActionService
|
||||
def initialize(account:, user:, params:)
|
||||
@account = account
|
||||
@user = user
|
||||
@params = params.deep_symbolize_keys
|
||||
end
|
||||
|
||||
def perform
|
||||
return assign_labels if labels_to_add.any?
|
||||
|
||||
Rails.logger.warn("Unknown contact bulk operation payload: #{@params.keys}")
|
||||
{ success: false, error: 'unknown_operation' }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assign_labels
|
||||
Contacts::BulkAssignLabelsService.new(
|
||||
account: @account,
|
||||
contact_ids: ids,
|
||||
labels: labels_to_add
|
||||
).perform
|
||||
end
|
||||
|
||||
def ids
|
||||
Array(@params[:ids]).compact
|
||||
end
|
||||
|
||||
def labels_to_add
|
||||
@labels_to_add ||= Array(@params.dig(:labels, :add)).reject(&:blank?)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
class Contacts::BulkAssignLabelsService
|
||||
def initialize(account:, contact_ids:, labels:)
|
||||
@account = account
|
||||
@contact_ids = Array(contact_ids)
|
||||
@labels = Array(labels).compact_blank
|
||||
end
|
||||
|
||||
def perform
|
||||
return { success: true, updated_contact_ids: [] } if @contact_ids.blank? || @labels.blank?
|
||||
|
||||
contacts = @account.contacts.where(id: @contact_ids)
|
||||
|
||||
contacts.find_each do |contact|
|
||||
contact.add_labels(@labels)
|
||||
end
|
||||
|
||||
{ success: true, updated_contact_ids: contacts.pluck(:id) }
|
||||
end
|
||||
end
|
||||
@@ -1,60 +0,0 @@
|
||||
class Github::GithubService
|
||||
attr_reader :hook
|
||||
|
||||
def initialize(hook:)
|
||||
@hook = hook
|
||||
end
|
||||
|
||||
def repositories
|
||||
client.repositories(nil, type: 'all')
|
||||
rescue Octokit::Error => e
|
||||
Rails.logger.error("GitHub API error fetching repositories: #{e.message}")
|
||||
raise_api_error(e)
|
||||
end
|
||||
|
||||
def assignees(repo_full_name)
|
||||
client.repository_assignees(repo_full_name)
|
||||
rescue Octokit::Error => e
|
||||
Rails.logger.error("GitHub API error fetching assignees for #{repo_full_name}: #{e.message}")
|
||||
raise_api_error(e)
|
||||
end
|
||||
|
||||
def search_repositories(query)
|
||||
return repositories if query.blank?
|
||||
|
||||
# Get all user's repositories first
|
||||
user_repos = repositories
|
||||
|
||||
# Filter repositories by query string (case-insensitive)
|
||||
user_repos.select do |repo|
|
||||
repo.full_name.downcase.include?(query.downcase) ||
|
||||
(repo.description&.downcase&.include?(query.downcase))
|
||||
end
|
||||
rescue Octokit::Error => e
|
||||
Rails.logger.error("GitHub API error searching repositories with query '#{query}': #{e.message}")
|
||||
raise_api_error(e)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def client
|
||||
@client ||= Octokit::Client.new(
|
||||
access_token: hook.access_token,
|
||||
auto_paginate: true,
|
||||
per_page: 100
|
||||
)
|
||||
end
|
||||
|
||||
def raise_api_error(error)
|
||||
case error.response_status
|
||||
when 401
|
||||
raise CustomExceptions::Base.new('GitHub authentication failed', 401)
|
||||
when 403
|
||||
raise CustomExceptions::Base.new('GitHub API rate limit exceeded or insufficient permissions', 403)
|
||||
when 404
|
||||
raise CustomExceptions::Base.new('GitHub resource not found', 404)
|
||||
else
|
||||
raise CustomExceptions::Base.new("GitHub API error: #{error.message}", error.response_status || 500)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -169,11 +169,7 @@
|
||||
<symbol id="icon-shopify" viewBox="0 0 32 32">
|
||||
<path fill="currentColor" d="m20.448 31.974l9.625-2.083s-3.474-23.484-3.5-23.641s-.156-.255-.281-.255c-.13 0-2.573-.182-2.573-.182s-1.703-1.698-1.922-1.88a.4.4 0 0 0-.161-.099l-1.219 28.141zm-4.833-16.901s-1.083-.563-2.365-.563c-1.932 0-2.005 1.203-2.005 1.521c0 1.641 4.318 2.286 4.318 6.172c0 3.057-1.922 5.01-4.542 5.01c-3.141 0-4.719-1.953-4.719-1.953l.859-2.781s1.661 1.422 3.042 1.422c.901 0 1.302-.724 1.302-1.245c0-2.156-3.542-2.255-3.542-5.807c-.047-2.984 2.094-5.891 6.438-5.891c1.677 0 2.5.479 2.5.479l-1.26 3.625zm-.719-13.969c.177 0 .359.052.536.182c-1.313.62-2.75 2.188-3.344 5.323a76 76 0 0 1-2.516.771c.688-2.38 2.359-6.26 5.323-6.26zm1.646 3.932v.182c-1.005.307-2.115.646-3.193.979c.62-2.37 1.776-3.526 2.781-3.958c.255.667.411 1.568.411 2.797zm.718-2.973c.922.094 1.521 1.151 1.901 2.339c-.464.151-.979.307-1.542.484v-.333c0-1.005-.13-1.828-.359-2.495zm3.99 1.718c-.031 0-.083.026-.104.026c-.026 0-.385.099-.953.281C19.63 2.442 18.625.927 16.849.927h-.156C16.183.281 15.558 0 15.021 0c-4.141 0-6.12 5.172-6.74 7.797c-1.594.484-2.75.844-2.88.896c-.901.286-.927.313-1.031 1.161c-.099.615-2.438 18.75-2.438 18.75L20.01 32z"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="icon-github" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</symbol>
|
||||
|
||||
|
||||
<symbol id="icon-slack" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M6.527 14.514A1.973 1.973 0 0 1 4.56 16.48a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h1.967zm.992 0c0-1.083.885-1.968 1.968-1.968s1.967.885 1.967 1.968v4.927a1.973 1.973 0 0 1-1.967 1.968a1.973 1.973 0 0 1-1.968-1.968zm1.968-7.987A1.973 1.973 0 0 1 7.519 4.56c0-1.083.885-1.967 1.968-1.967s1.967.884 1.967 1.967v1.968zm0 .992c1.083 0 1.967.884 1.967 1.967a1.973 1.973 0 0 1-1.967 1.968H4.56a1.973 1.973 0 0 1-1.968-1.968c0-1.083.885-1.967 1.968-1.967zm7.986 1.967c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967a1.973 1.973 0 0 1-1.968 1.968h-1.968zm-.991 0a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968V4.56c0-1.083.885-1.967 1.968-1.967s1.968.884 1.968 1.967zm-1.968 7.987c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.968a1.973 1.973 0 0 1-1.968-1.968v-1.968zm0-.992a1.973 1.973 0 0 1-1.968-1.967c0-1.083.885-1.968 1.968-1.968h4.927c1.083 0 1.968.885 1.968 1.968a1.973 1.973 0 0 1-1.968 1.967z"/>
|
||||
</symbol>
|
||||
|
||||
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 43 KiB |
@@ -176,9 +176,6 @@
|
||||
- name: notion_integration
|
||||
display_name: Notion Integration
|
||||
enabled: false
|
||||
- name: github_integration
|
||||
display_name: Github Integration
|
||||
enabled: false
|
||||
- name: captain_integration_v2
|
||||
display_name: Captain V2
|
||||
enabled: false
|
||||
|
||||
@@ -451,22 +451,3 @@
|
||||
locked: false
|
||||
description: 'Token expiry in days'
|
||||
## ------ End of Customizations for Customers ------ ##
|
||||
|
||||
# ------- Github Integration Config ------- #
|
||||
- name: GITHUB_CLIENT_ID
|
||||
display_title: 'Github Client ID'
|
||||
value:
|
||||
locked: false
|
||||
description: 'Github client ID'
|
||||
- name: GITHUB_CLIENT_SECRET
|
||||
display_title: 'Github Client Secret'
|
||||
value:
|
||||
locked: false
|
||||
description: 'Github client secret'
|
||||
type: secret
|
||||
- name: GITHUB_APP_NAME
|
||||
display_title: 'Github App Name'
|
||||
value:
|
||||
locked: false
|
||||
description: 'Github App name for installation URLs (e.g., "chatwoot-qa")'
|
||||
## ------ End of Github Integration Config ------- #
|
||||
|
||||
@@ -284,11 +284,3 @@ leadsquared:
|
||||
'conversation_activity_score',
|
||||
'transcript_activity_score',
|
||||
]
|
||||
|
||||
github:
|
||||
id: github
|
||||
logo: github.png
|
||||
i18n_key: github
|
||||
hook_type: account
|
||||
action: https://github.com/login/oauth/authorize
|
||||
allow_multiple_hooks: false
|
||||
|
||||
@@ -312,10 +312,6 @@ en:
|
||||
name: 'LeadSquared'
|
||||
short_description: 'Sync your contacts and conversations with LeadSquared CRM.'
|
||||
description: 'Sync your contacts and conversations with LeadSquared CRM. This integration automatically creates leads in LeadSquared when new contacts are added, and logs conversation activity to provide your sales team with complete context.'
|
||||
github:
|
||||
name: 'GitHub'
|
||||
short_description: 'Create and manage GitHub issues directly from conversations.'
|
||||
description: 'Connect your GitHub repositories to create issues directly from customer conversations. This integration helps you track bugs, feature requests, and development tasks seamlessly.'
|
||||
captain:
|
||||
copilot_message_required: Message is required
|
||||
copilot_error: 'Please connect an assistant to this inbox to use Copilot'
|
||||
|
||||
@@ -302,14 +302,6 @@ Rails.application.routes.draw do
|
||||
delete :destroy
|
||||
end
|
||||
end
|
||||
resource :github, controller: 'github', only: [] do
|
||||
collection do
|
||||
delete :destroy
|
||||
get :repositories
|
||||
get :search_repositories
|
||||
get 'repositories/:owner/:repo/assignees', to: 'github#assignees', as: :repository_assignees
|
||||
end
|
||||
end
|
||||
end
|
||||
resources :working_hours, only: [:update]
|
||||
|
||||
@@ -557,10 +549,6 @@ Rails.application.routes.draw do
|
||||
end
|
||||
end
|
||||
|
||||
namespace :github do
|
||||
get :callback, to: 'callbacks#show'
|
||||
end
|
||||
|
||||
get 'microsoft/callback', to: 'microsoft/callbacks#show'
|
||||
get 'google/callback', to: 'google/callbacks#show'
|
||||
get 'instagram/callback', to: 'instagram/callbacks#show'
|
||||
|
||||
@@ -10,8 +10,12 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
|
||||
Current.executed_by = @assistant
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
generate_and_process_response
|
||||
if captain_v2_enabled?
|
||||
generate_response_with_v2
|
||||
else
|
||||
ActiveRecord::Base.transaction do
|
||||
generate_and_process_response
|
||||
end
|
||||
end
|
||||
rescue StandardError => e
|
||||
raise e if e.is_a?(ActiveStorage::FileNotFoundError) || e.is_a?(Faraday::BadRequestError)
|
||||
@@ -26,16 +30,20 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
delegate :account, :inbox, to: :@conversation
|
||||
|
||||
def generate_and_process_response
|
||||
@response = if captain_v2_enabled?
|
||||
Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation).generate_response(
|
||||
message_history: collect_previous_messages
|
||||
)
|
||||
else
|
||||
Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
|
||||
message_history: collect_previous_messages
|
||||
)
|
||||
end
|
||||
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
|
||||
message_history: collect_previous_messages
|
||||
)
|
||||
process_response
|
||||
end
|
||||
|
||||
def generate_response_with_v2
|
||||
@response = Captain::Assistant::AgentRunnerService.new(assistant: @assistant, conversation: @conversation).generate_response(
|
||||
message_history: collect_previous_messages
|
||||
)
|
||||
process_response
|
||||
end
|
||||
|
||||
def process_response
|
||||
return process_action('handoff') if handoff_requested?
|
||||
|
||||
create_messages
|
||||
@@ -123,6 +131,6 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
end
|
||||
|
||||
def captain_v2_enabled?
|
||||
return account.feature_enabled?('captain_integration_v2')
|
||||
account.feature_enabled?('captain_integration_v2')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,11 +5,13 @@ class Captain::Tools::FirecrawlParserJob < ApplicationJob
|
||||
assistant = Captain::Assistant.find(assistant_id)
|
||||
metadata = payload[:metadata]
|
||||
|
||||
canonical_url = normalize_link(metadata['url'])
|
||||
document = assistant.documents.find_or_initialize_by(
|
||||
external_link: metadata['url']
|
||||
external_link: canonical_url
|
||||
)
|
||||
|
||||
document.update!(
|
||||
external_link: canonical_url,
|
||||
content: payload[:markdown],
|
||||
name: metadata['title'],
|
||||
status: :available
|
||||
@@ -17,4 +19,10 @@ class Captain::Tools::FirecrawlParserJob < ApplicationJob
|
||||
rescue StandardError => e
|
||||
raise "Failed to parse FireCrawl data: #{e.message}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def normalize_link(raw_url)
|
||||
raw_url.to_s.delete_suffix('/')
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,11 +15,11 @@ class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
|
||||
page_title = crawler.page_title || ''
|
||||
content = crawler.body_text_content || ''
|
||||
|
||||
document = assistant.documents.find_or_initialize_by(
|
||||
external_link: page_link
|
||||
)
|
||||
normalized_link = normalize_link(page_link)
|
||||
document = assistant.documents.find_or_initialize_by(external_link: normalized_link)
|
||||
|
||||
document.update!(
|
||||
external_link: normalized_link,
|
||||
name: page_title[0..254], content: content[0..14_999], status: :available
|
||||
)
|
||||
rescue StandardError => e
|
||||
@@ -28,6 +28,10 @@ class Captain::Tools::SimplePageCrawlParserJob < ApplicationJob
|
||||
|
||||
private
|
||||
|
||||
def normalize_link(raw_link)
|
||||
raw_link.to_s.delete_suffix('/')
|
||||
end
|
||||
|
||||
def limit_exceeded?(account)
|
||||
limits = account.usage_limits[:captain][:documents]
|
||||
limits[:current_available].negative? || limits[:current_available].zero?
|
||||
|
||||
@@ -37,6 +37,7 @@ class Captain::Document < ApplicationRecord
|
||||
validate :validate_file_attachment, if: -> { pdf_file.attached? }
|
||||
before_validation :ensure_account_id
|
||||
before_validation :set_external_link_for_pdf
|
||||
before_validation :normalize_external_link
|
||||
|
||||
enum status: {
|
||||
in_progress: 0,
|
||||
@@ -47,7 +48,7 @@ class Captain::Document < ApplicationRecord
|
||||
after_create_commit :enqueue_crawl_job
|
||||
after_create_commit :update_document_usage
|
||||
after_destroy :update_document_usage
|
||||
after_commit :enqueue_response_builder_job, on: :update, if: :should_enqueue_response_builder?
|
||||
after_commit :enqueue_response_builder_job
|
||||
scope :ordered, -> { order(created_at: :desc) }
|
||||
|
||||
scope :for_account, ->(account_id) { where(account_id: account_id) }
|
||||
@@ -94,15 +95,18 @@ class Captain::Document < ApplicationRecord
|
||||
end
|
||||
|
||||
def enqueue_response_builder_job
|
||||
return if status != 'available'
|
||||
return unless should_enqueue_response_builder?
|
||||
|
||||
Captain::Documents::ResponseBuilderJob.perform_later(self)
|
||||
end
|
||||
|
||||
def should_enqueue_response_builder?
|
||||
# Only enqueue when status changes to available
|
||||
# Avoid re-enqueueing when metadata is updated by the job itself
|
||||
saved_change_to_status? && status == 'available'
|
||||
return false if destroyed?
|
||||
return false unless available?
|
||||
|
||||
return saved_change_to_status? if pdf_document?
|
||||
|
||||
(saved_change_to_status? || saved_change_to_content?) && content.present?
|
||||
end
|
||||
|
||||
def update_document_usage
|
||||
@@ -140,4 +144,11 @@ class Captain::Document < ApplicationRecord
|
||||
timestamp = Time.current.strftime('%Y%m%d%H%M%S')
|
||||
self.external_link = "PDF: #{pdf_file.filename.base}_#{timestamp}"
|
||||
end
|
||||
|
||||
def normalize_external_link
|
||||
return if external_link.blank?
|
||||
return if pdf_document?
|
||||
|
||||
self.external_link = external_link.delete_suffix('/')
|
||||
end
|
||||
end
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.1 KiB |
@@ -199,19 +199,6 @@ describe Messages::MessageBuilder do
|
||||
expect(message.content_attributes.dig('email', 'text_content', 'reply')).to eq 'Regular message content'
|
||||
end
|
||||
|
||||
it 'does not process custom email content when quoted_email_reply feature is disabled' do
|
||||
account.disable_features('quoted_email_reply')
|
||||
params = ActionController::Parameters.new({
|
||||
content: 'Regular message content',
|
||||
email_html_content: '<p>Custom HTML content</p>'
|
||||
})
|
||||
|
||||
message = described_class.new(user, conversation, params).perform
|
||||
|
||||
expect(message.content_attributes.dig('email', 'html_content')).to be_nil
|
||||
expect(message.content_attributes.dig('email', 'text_content')).to be_nil
|
||||
end
|
||||
|
||||
it 'does not process custom email content for private messages' do
|
||||
params = ActionController::Parameters.new({
|
||||
content: 'Regular message content',
|
||||
|
||||
@@ -195,6 +195,37 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
|
||||
expect(Conversation.first.label_list).to contain_exactly('support', 'priority_customer')
|
||||
expect(Conversation.second.label_list).to contain_exactly('support', 'priority_customer')
|
||||
end
|
||||
|
||||
it 'enqueues contact bulk action job with permitted params' do
|
||||
contact_one = create(:contact, account: account)
|
||||
contact_two = create(:contact, account: account)
|
||||
|
||||
previous_adapter = ActiveJob::Base.queue_adapter
|
||||
ActiveJob::Base.queue_adapter = :test
|
||||
|
||||
expect do
|
||||
post "/api/v1/accounts/#{account.id}/bulk_actions",
|
||||
headers: agent.create_new_auth_token,
|
||||
params: {
|
||||
type: 'Contact',
|
||||
ids: [contact_one.id, contact_two.id],
|
||||
labels: { add: %w[vip support] },
|
||||
extra: 'ignored'
|
||||
}
|
||||
end.to have_enqueued_job(Contacts::BulkActionJob).with(
|
||||
account.id,
|
||||
agent.id,
|
||||
hash_including(
|
||||
'ids' => [contact_one.id, contact_two.id],
|
||||
'labels' => hash_including('add' => %w[vip support])
|
||||
)
|
||||
)
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
ensure
|
||||
ActiveJob::Base.queue_adapter = previous_adapter
|
||||
clear_enqueued_jobs
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Github::CallbacksController, type: :controller do
|
||||
let(:account) { create(:account) }
|
||||
let(:signed_account_id) { account.to_signed_global_id(expires_in: 1.hour) }
|
||||
|
||||
before do
|
||||
# Clear any existing GitHub hooks for the account
|
||||
account.hooks.where(app_id: 'github').destroy_all
|
||||
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('test_client_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return('test_client_secret')
|
||||
end
|
||||
|
||||
describe 'route accessibility' do
|
||||
it 'is accessible via the github callback route' do
|
||||
expect(get: '/github/callback').to route_to(
|
||||
controller: 'github/callbacks',
|
||||
action: 'show'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'helper methods' do
|
||||
describe '#account' do
|
||||
it 'resolves account from valid signed global id' do
|
||||
controller.params = { state: signed_account_id }
|
||||
expect(controller.send(:account)).to eq(account)
|
||||
end
|
||||
|
||||
it 'raises error for missing state' do
|
||||
controller.params = {}
|
||||
expect { controller.send(:account) }.to raise_error(ActionController::BadRequest, 'Invalid account context')
|
||||
end
|
||||
|
||||
it 'raises error for invalid state' do
|
||||
controller.params = { state: 'invalid_state' }
|
||||
expect { controller.send(:account) }.to raise_error(ActionController::BadRequest, 'Invalid account context')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#oauth_client' do
|
||||
it 'creates OAuth2 client with correct configuration' do
|
||||
oauth_client = controller.send(:oauth_client)
|
||||
expect(oauth_client).to be_a(OAuth2::Client)
|
||||
expect(oauth_client.id).to eq('test_client_id')
|
||||
expect(oauth_client.secret).to eq('test_client_secret')
|
||||
expect(oauth_client.site).to eq('https://github.com')
|
||||
expect(oauth_client.options[:token_url]).to eq('/login/oauth/access_token')
|
||||
expect(oauth_client.options[:authorize_url]).to eq('/login/oauth/authorize')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#github_redirect_uri' do
|
||||
before do
|
||||
allow(controller).to receive(:account).and_return(account)
|
||||
end
|
||||
|
||||
it 'generates correct GitHub redirect URI' do
|
||||
with_modified_env FRONTEND_URL: 'http://localhost:3000' do
|
||||
uri = controller.send(:github_redirect_uri)
|
||||
expect(uri).to eq("http://localhost:3000/app/accounts/#{account.id}/settings/integrations/github")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#fallback_redirect_uri' do
|
||||
it 'generates fallback redirect URI when account is unavailable' do
|
||||
allow(controller).to receive(:account).and_raise(StandardError)
|
||||
|
||||
with_modified_env FRONTEND_URL: 'http://localhost:3000' do
|
||||
uri = controller.send(:fallback_redirect_uri)
|
||||
expect(uri).to eq('http://localhost:3000/app/settings/integrations')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_hook_settings' do
|
||||
let(:mock_parsed_body) do
|
||||
{
|
||||
'access_token' => 'test_token',
|
||||
'token_type' => 'bearer',
|
||||
'scope' => 'repo,read:org'
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(controller).to receive(:parsed_body).and_return(mock_parsed_body)
|
||||
end
|
||||
|
||||
it 'builds hook settings with installation_id' do
|
||||
settings = controller.send(:build_hook_settings, '12345')
|
||||
expect(settings).to include(
|
||||
token_type: 'bearer',
|
||||
scope: 'repo,read:org',
|
||||
installation_id: '12345'
|
||||
)
|
||||
end
|
||||
|
||||
it 'builds hook settings without installation_id when nil' do
|
||||
settings = controller.send(:build_hook_settings, nil)
|
||||
expect(settings).to include(
|
||||
token_type: 'bearer',
|
||||
scope: 'repo,read:org'
|
||||
)
|
||||
expect(settings).not_to have_key(:installation_id)
|
||||
end
|
||||
|
||||
it 'removes nil installation_id values' do
|
||||
settings = controller.send(:build_hook_settings, nil)
|
||||
expect(settings.keys).not_to include(:installation_id)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_integration_hook' do
|
||||
let(:settings) do
|
||||
{
|
||||
token_type: 'bearer',
|
||||
scope: 'repo,read:org',
|
||||
installation_id: '12345'
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(controller).to receive(:account).and_return(account)
|
||||
allow(controller).to receive(:parsed_body).and_return({
|
||||
'access_token' => 'test_token'
|
||||
})
|
||||
end
|
||||
|
||||
it 'creates integration hook with correct attributes' do
|
||||
hook = controller.send(:create_integration_hook, settings)
|
||||
expect(hook.access_token).to eq('test_token')
|
||||
expect(hook.status).to eq('enabled')
|
||||
expect(hook.app_id).to eq('github')
|
||||
expect(hook.settings).to eq(settings.stringify_keys)
|
||||
expect(hook.account).to eq(account)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_oauth_url' do
|
||||
it 'builds OAuth URL with installation context' do
|
||||
with_modified_env FRONTEND_URL: 'http://localhost:3000' do
|
||||
url = controller.send(:build_oauth_url, '12345')
|
||||
expect(url).to include('github?setup_action=install&installation_id=12345')
|
||||
expect(controller.session[:github_installation_id]).to eq('12345')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#base_url' do
|
||||
it 'returns FRONTEND_URL when set' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(controller.send(:base_url)).to eq('https://app.chatwoot.com')
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns default localhost when FRONTEND_URL not set' do
|
||||
with_modified_env FRONTEND_URL: nil do
|
||||
expect(controller.send(:base_url)).to eq('http://localhost:3000')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -23,7 +23,7 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
|
||||
expect(document).to have_attributes(
|
||||
content: payload[:markdown],
|
||||
name: payload[:metadata]['title'],
|
||||
external_link: payload[:metadata]['url'],
|
||||
external_link: 'https://www.firecrawl.dev',
|
||||
status: 'available'
|
||||
)
|
||||
end
|
||||
@@ -32,7 +32,7 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
|
||||
existing_document = create(:captain_document,
|
||||
assistant: assistant,
|
||||
account: assistant.account,
|
||||
external_link: payload[:metadata]['url'],
|
||||
external_link: 'https://www.firecrawl.dev',
|
||||
content: 'old content',
|
||||
name: 'old title',
|
||||
status: :in_progress)
|
||||
@@ -42,7 +42,9 @@ RSpec.describe Captain::Tools::FirecrawlParserJob, type: :job do
|
||||
end.not_to change(assistant.documents, :count)
|
||||
|
||||
existing_document.reload
|
||||
# Payload URL ends with '/', but we persist the canonical URL without it.
|
||||
expect(existing_document).to have_attributes(
|
||||
external_link: 'https://www.firecrawl.dev',
|
||||
content: payload[:markdown],
|
||||
name: payload[:metadata]['title'],
|
||||
status: 'available'
|
||||
|
||||
@@ -3,7 +3,7 @@ require 'rails_helper'
|
||||
RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
|
||||
describe '#perform' do
|
||||
let(:assistant) { create(:captain_assistant) }
|
||||
let(:page_link) { 'https://example.com/page' }
|
||||
let(:page_link) { 'https://example.com/page/' }
|
||||
let(:page_title) { 'Example Page Title' }
|
||||
let(:content) { 'Some page content here' }
|
||||
let(:crawler) { instance_double(Captain::Tools::SimplePageCrawlService) }
|
||||
@@ -24,7 +24,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
|
||||
end.to change(assistant.documents, :count).by(1)
|
||||
|
||||
document = assistant.documents.last
|
||||
expect(document.external_link).to eq(page_link)
|
||||
expect(document.external_link).to eq('https://example.com/page')
|
||||
expect(document.name).to eq(page_title)
|
||||
expect(document.content).to eq(content)
|
||||
expect(document.status).to eq('available')
|
||||
@@ -33,7 +33,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
|
||||
it 'updates existing document if one exists' do
|
||||
existing_document = create(:captain_document,
|
||||
assistant: assistant,
|
||||
external_link: page_link,
|
||||
external_link: 'https://example.com/page',
|
||||
name: 'Old Title',
|
||||
content: 'Old content')
|
||||
|
||||
|
||||
@@ -4,6 +4,17 @@ RSpec.describe Captain::Document, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:assistant) { create(:captain_assistant, account: account) }
|
||||
|
||||
describe 'URL normalization' do
|
||||
it 'removes a trailing slash before validation' do
|
||||
document = create(:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
external_link: 'https://example.com/path/')
|
||||
|
||||
expect(document.external_link).to eq('https://example.com/path')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PDF support' do
|
||||
let(:pdf_document) do
|
||||
doc = build(:captain_document, assistant: assistant, account: account)
|
||||
@@ -82,4 +93,161 @@ RSpec.describe Captain::Document, type: :model do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'response builder job callback' do
|
||||
before { clear_enqueued_jobs }
|
||||
|
||||
describe 'non-PDF documents' do
|
||||
it 'enqueues when created with available status and content' do
|
||||
expect do
|
||||
create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue when created available without content' do
|
||||
expect do
|
||||
create(:captain_document, assistant: assistant, account: account, status: :available, content: nil)
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'enqueues when status transitions to available with existing content' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
|
||||
|
||||
expect do
|
||||
document.update!(status: :available)
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue when status transitions to available without content' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :in_progress,
|
||||
content: nil
|
||||
)
|
||||
|
||||
expect do
|
||||
document.update!(status: :available)
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'enqueues when content is populated on an available document' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
content: nil
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(content: 'Fresh content from crawl')
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'enqueues when content changes on an available document' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
content: 'Initial content'
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(content: 'Updated crawl content')
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue when content is cleared on an available document' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: :available,
|
||||
content: 'Initial content'
|
||||
)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(content: nil)
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue for metadata-only updates' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(metadata: { 'title' => 'Updated Again' })
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue while document remains in progress' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :in_progress)
|
||||
|
||||
expect do
|
||||
document.update!(metadata: { 'title' => 'Updated' })
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PDF documents' do
|
||||
def build_pdf_document(status:, content:)
|
||||
build(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
account: account,
|
||||
status: status,
|
||||
content: content
|
||||
).tap do |doc|
|
||||
doc.pdf_file.attach(
|
||||
io: StringIO.new('PDF content'),
|
||||
filename: 'sample.pdf',
|
||||
content_type: 'application/pdf'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it 'enqueues when created available without content' do
|
||||
document = build_pdf_document(status: :available, content: nil)
|
||||
|
||||
expect do
|
||||
document.save!
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'enqueues when status transitions to available' do
|
||||
document = build_pdf_document(status: :in_progress, content: nil)
|
||||
document.save!
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(status: :available)
|
||||
end.to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
|
||||
it 'does not enqueue when content updates without status change' do
|
||||
document = build_pdf_document(status: :available, content: nil)
|
||||
document.save!
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.update!(content: 'Extracted PDF text')
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not enqueue when the document is destroyed' do
|
||||
document = create(:captain_document, assistant: assistant, account: account, status: :available)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect do
|
||||
document.destroy!
|
||||
end.not_to have_enqueued_job(Captain::Documents::ResponseBuilderJob)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Contacts::BulkActionJob, type: :job do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:params) { { 'ids' => [1], 'labels' => { 'add' => ['vip'] } } }
|
||||
|
||||
it 'invokes the bulk action service with account and user' do
|
||||
service_instance = instance_double(Contacts::BulkActionService, perform: true)
|
||||
|
||||
allow(Contacts::BulkActionService).to receive(:new).and_return(service_instance)
|
||||
|
||||
described_class.perform_now(account.id, user.id, params)
|
||||
|
||||
expect(Contacts::BulkActionService).to have_received(:new).with(
|
||||
account: account,
|
||||
user: user,
|
||||
params: params
|
||||
)
|
||||
expect(service_instance).to have_received(:perform)
|
||||
end
|
||||
end
|
||||
@@ -248,5 +248,143 @@ RSpec.describe ReplyMailbox do
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with references header' do
|
||||
let(:reply_mail_with_references) { create_inbound_email_from_fixture('reply_mail_without_uuid.eml') }
|
||||
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
|
||||
let(:conversation_1) do
|
||||
create(
|
||||
:conversation,
|
||||
assignee: agent,
|
||||
inbox: email_channel.inbox,
|
||||
account: account,
|
||||
additional_attributes: { mail_subject: "Discussion: Let's debate these attachments" }
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
conversation_1.update!(uuid: '6bdc3f4d-0bec-4515-a284-5d916fdde489')
|
||||
end
|
||||
|
||||
context 'with message-specific pattern in references' do
|
||||
before do
|
||||
reply_mail_with_references.mail['References'] = '<conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123@test.com>'
|
||||
end
|
||||
|
||||
it 'finds conversation from references header with message pattern' do
|
||||
described_class.receive reply_mail_with_references
|
||||
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
|
||||
end
|
||||
end
|
||||
|
||||
context 'with conversation fallback pattern in references' do
|
||||
before do
|
||||
reply_mail_with_references.mail['References'] = "<account/#{account.id}/conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489@test.com>"
|
||||
end
|
||||
|
||||
it 'finds conversation from references header with fallback pattern' do
|
||||
described_class.receive reply_mail_with_references
|
||||
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
|
||||
end
|
||||
end
|
||||
|
||||
context 'with multiple references including conversation pattern' do
|
||||
before do
|
||||
reply_mail_with_references.mail['References'] = [
|
||||
'<some-random-message-id@gmail.com>',
|
||||
"<account/#{account.id}/conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489@test.com>",
|
||||
'<another-random-message-id@outlook.com>'
|
||||
].join("\r\n ")
|
||||
end
|
||||
|
||||
it 'finds conversation from any reference in the chain' do
|
||||
described_class.receive reply_mail_with_references
|
||||
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
|
||||
end
|
||||
end
|
||||
|
||||
context 'with message source_id in references' do
|
||||
before do
|
||||
conversation_1.messages.create!(
|
||||
source_id: 'original-message-id@test.com',
|
||||
account_id: account.id,
|
||||
message_type: 'outgoing',
|
||||
inbox_id: email_channel.inbox.id,
|
||||
content: 'Original message'
|
||||
)
|
||||
reply_mail_with_references.mail['References'] = '<original-message-id@test.com>'
|
||||
end
|
||||
|
||||
it 'finds conversation from message source_id in references' do
|
||||
described_class.receive reply_mail_with_references
|
||||
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
|
||||
end
|
||||
end
|
||||
|
||||
context 'with conversation from different channel in references' do
|
||||
let(:other_email_channel) { create(:channel_email, email: 'other@example.com', account: account) }
|
||||
let(:other_conversation) do
|
||||
create(
|
||||
:conversation,
|
||||
assignee: agent,
|
||||
inbox: other_email_channel.inbox,
|
||||
account: account
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
other_conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
|
||||
reply_mail_with_references.mail['References'] = '<conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/messages/456@test.com>'
|
||||
end
|
||||
|
||||
it 'does not use conversation from different channel' do
|
||||
described_class.receive reply_mail_with_references
|
||||
expect(other_conversation.messages.count).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when in_reply_to fails but references has valid conversation' do
|
||||
before do
|
||||
reply_mail_with_references.mail['In-Reply-To'] = '<non-existent-message-id@test.com>'
|
||||
reply_mail_with_references.mail['References'] = "<account/#{account.id}/conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489@test.com>"
|
||||
end
|
||||
|
||||
it 'falls back to references header when in_reply_to lookup fails' do
|
||||
described_class.receive reply_mail_with_references
|
||||
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when in_reply_to fails and references has message pattern' do
|
||||
before do
|
||||
reply_mail_with_references.mail['In-Reply-To'] = '<random-invalid-id@gmail.com>'
|
||||
reply_mail_with_references.mail['References'] = '<conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/789@test.com>'
|
||||
end
|
||||
|
||||
it 'falls back to references with message pattern when in_reply_to lookup fails' do
|
||||
described_class.receive reply_mail_with_references
|
||||
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when in_reply_to fails and references has message source_id' do
|
||||
before do
|
||||
conversation_1.messages.create!(
|
||||
source_id: 'valid-message-id@test.com',
|
||||
account_id: account.id,
|
||||
message_type: 'outgoing',
|
||||
inbox_id: email_channel.inbox.id,
|
||||
content: 'Previous message'
|
||||
)
|
||||
reply_mail_with_references.mail['In-Reply-To'] = '<wrong-message-id@test.com>'
|
||||
reply_mail_with_references.mail['References'] = '<valid-message-id@test.com>'
|
||||
end
|
||||
|
||||
it 'falls back to references with source_id when in_reply_to lookup fails' do
|
||||
described_class.receive reply_mail_with_references
|
||||
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -38,25 +38,6 @@ RSpec.describe Integrations::App do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the app is github' do
|
||||
let(:app_name) { 'github' }
|
||||
|
||||
it 'returns the GitHub App installation URL with state parameter' do
|
||||
with_modified_env GITHUB_CLIENT_ID: 'dummy_client_id', GITHUB_APP_NAME: 'test-app' do
|
||||
expect(app.action).to include('https://github.com/apps/test-app/installations/new')
|
||||
expect(app.action).to include('state=')
|
||||
end
|
||||
end
|
||||
|
||||
it 'uses default app name when GITHUB_APP_NAME is not set' do
|
||||
with_modified_env GITHUB_CLIENT_ID: 'dummy_client_id' do
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('dummy_client_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_APP_NAME', 'chatwoot-qa').and_return('chatwoot-qa')
|
||||
expect(app.action).to include('https://github.com/apps/chatwoot-qa/installations/new')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#active?' do
|
||||
@@ -106,34 +87,6 @@ RSpec.describe Integrations::App do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the app is github' do
|
||||
let(:app_name) { 'github' }
|
||||
|
||||
it 'returns false if github credentials are not present' do
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return(nil)
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return(nil)
|
||||
expect(app.active?(account)).to be false
|
||||
end
|
||||
|
||||
it 'returns false if only client_id is present' do
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('client_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return(nil)
|
||||
expect(app.active?(account)).to be false
|
||||
end
|
||||
|
||||
it 'returns false if only client_secret is present' do
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return(nil)
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return('client_secret')
|
||||
expect(app.active?(account)).to be false
|
||||
end
|
||||
|
||||
it 'returns true if both client_id and client_secret are present' do
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_ID', nil).and_return('client_id')
|
||||
allow(GlobalConfigService).to receive(:load).with('GITHUB_CLIENT_SECRET', nil).and_return('client_secret')
|
||||
expect(app.active?(account)).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when other apps are queried' do
|
||||
let(:app_name) { 'webhook' }
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Contacts::BulkAssignLabelsService do
|
||||
subject(:service) do
|
||||
described_class.new(
|
||||
account: account,
|
||||
contact_ids: [contact_one.id, contact_two.id, other_contact.id],
|
||||
labels: labels
|
||||
)
|
||||
end
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let!(:contact_one) { create(:contact, account: account) }
|
||||
let!(:contact_two) { create(:contact, account: account) }
|
||||
let!(:other_contact) { create(:contact) }
|
||||
let(:labels) { %w[vip support] }
|
||||
|
||||
it 'assigns labels to the contacts that belong to the account' do
|
||||
service.perform
|
||||
|
||||
expect(contact_one.reload.label_list).to include(*labels)
|
||||
expect(contact_two.reload.label_list).to include(*labels)
|
||||
end
|
||||
|
||||
it 'does not assign labels to contacts outside the account' do
|
||||
service.perform
|
||||
|
||||
expect(other_contact.reload.label_list).to be_empty
|
||||
end
|
||||
|
||||
it 'returns ids of contacts that were updated' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:success]).to be(true)
|
||||
expect(result[:updated_contact_ids]).to contain_exactly(contact_one.id, contact_two.id)
|
||||
end
|
||||
|
||||
it 'returns success with no updates when labels are blank' do
|
||||
result = described_class.new(
|
||||
account: account,
|
||||
contact_ids: [contact_one.id],
|
||||
labels: []
|
||||
).perform
|
||||
|
||||
expect(result).to eq(success: true, updated_contact_ids: [])
|
||||
expect(contact_one.reload.label_list).to be_empty
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user